diff --git a/go.mod b/go.mod index 84c87c821..f2b12cc65 100644 --- a/go.mod +++ b/go.mod @@ -5,8 +5,11 @@ go 1.13 require ( github.com/atotto/clipboard v0.1.2 github.com/charmbracelet/bubbletea v0.12.2 + github.com/jinzhu/copier v0.0.0-20201025035756-632e723a6687 github.com/mattn/go-runewidth v0.0.9 + github.com/muesli/reflow v0.1.1-0.20200715144030-a312cb5b2d8d github.com/muesli/termenv v0.7.4 golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897 // indirect + golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211 // indirect golang.org/x/sys v0.0.0-20201020230747-6e5568b54d1a // indirect ) diff --git a/go.sum b/go.sum index a1c370bb7..6e40cf0e3 100644 --- a/go.sum +++ b/go.sum @@ -6,12 +6,17 @@ github.com/containerd/console v1.0.1 h1:u7SFAJyRqWcG6ogaMAx3KjSTy1e3hT9QxqX7Jco7 github.com/containerd/console v1.0.1/go.mod h1:XUsP6YE/mKtz6bxc+I8UiKKTP04qjQL4qcS3XoQ5xkw= github.com/google/goterm v0.0.0-20190703233501-fc88cf888a3f h1:5CjVwnuUcp5adK4gmY6i72gpVFVnZDP2h5TmPScB6u4= github.com/google/goterm v0.0.0-20190703233501-fc88cf888a3f/go.mod h1:nOFQdrUlIlx6M6ODdSpBj1NVA+VgLC6kmw60mkw34H4= +github.com/jinzhu/copier v0.0.0-20201025035756-632e723a6687 h1:bWXum+xWafUxxJpcXnystwg5m3iVpPYtrGJFc1rjfLc= +github.com/jinzhu/copier v0.0.0-20201025035756-632e723a6687/go.mod h1:24xnZezI2Yqac9J61UC6/dG/k76ttpq0DdJI3QmUvro= github.com/lucasb-eyer/go-colorful v1.0.3 h1:QIbQXiugsb+q10B+MI+7DI1oQLdmnep86tWFlaaUAac= github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/muesli/reflow v0.1.1-0.20200715144030-a312cb5b2d8d h1:ok5jhmKQze1yERN73u46EZhUH4vgC1z4qfeFJ8iOwvg= +github.com/muesli/reflow v0.1.1-0.20200715144030-a312cb5b2d8d/go.mod h1:I9bWAt7QTg/que/qmUCJBGlj7wEq8OAFBjPNjc6xK4I= github.com/muesli/termenv v0.7.2 h1:r1raklL3uKE7rOvWgSenmEm2px+dnc33OTisZ8YR1fw= github.com/muesli/termenv v0.7.2/go.mod h1:ct2L5N2lmix82RaY3bMWwVu/jUFc9Ule0KGDCiKYPh8= github.com/muesli/termenv v0.7.4 h1:/pBqvU5CpkY53tU0vVn+xgs2ZTX63aH5nY+SSps5Xa8= @@ -30,6 +35,7 @@ golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200916030750-2334cc1a136f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201009025420-dfb3f7c4e634 h1:bNEHhJCnrwMKNMmOx3yAynp5vs5/gRy+XWFtZFu7NBM= golang.org/x/sys v0.0.0-20201009025420-dfb3f7c4e634/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201020230747-6e5568b54d1a h1:e3IU37lwO4aq3uoRKINC7JikojFmE5gO7xhfxs8VC34= golang.org/x/sys v0.0.0-20201020230747-6e5568b54d1a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/list/example/checkboxes/main.go b/list/example/checkboxes/main.go new file mode 100644 index 000000000..ab483578a --- /dev/null +++ b/list/example/checkboxes/main.go @@ -0,0 +1,201 @@ +package main + +import ( + "fmt" + "os" + "strconv" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" +) + +// DISCLAIMER: This is not a template but a example. +// This code is NOT performant or good for any other purpose except to show the possibility's of the list bubble. + +func main() { + m := model{} + m.vis = list.NewModel() + m.vis.PrefixGen = NewPrefixer() + m.head = "My TODO list!\n=============" + m.AddItems([]string{ + "buying eggs", + "take the trash out", + "get a hair cut", + "be nice\nto the neighbours", + "get milk", + }) + m.tail = "============================================\nuse ' ' to change the done state of a item\nuse 'q' or 'ctrl+c' to exit" + p := tea.NewProgram(m) + if err := p.Start(); err != nil { + fmt.Println("could not run program:", err) + os.Exit(1) + } +} + +type item struct { + selected bool + content string + id int +} + +func (m item) String() string { + return m.content +} + +type model struct { + vis list.Model + jump string + ready bool + head string + tail string +} + +func (m model) Init() tea.Cmd { + return nil +} + +// update recives messages and the model and changes the model accordingly to the messages +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if m.vis.PrefixGen == nil { + // use default + m.vis.PrefixGen = NewPrefixer() + } + + switch msg := msg.(type) { + case tea.KeyMsg: + + // Ctrl+c exits + if msg.Type == tea.KeyCtrlC { + return m, tea.Quit + } + keyString := msg.String() + switch keyString { + case "q": + return m, tea.Quit + case "1", "2", "3", "4", "5", "6", "7", "8", "9", "0": + m.jump += keyString + return m, nil + case "down", "j": + m.vis.MoveCursor(m.popJump(1)) + return m, nil + case "up", "k": + m.vis.MoveCursor(-m.popJump(1)) + return m, nil + case "r": + d, ok := m.vis.PrefixGen.(*SelectPrefixer) + if ok { + d.NumberRelative = !d.NumberRelative + } + return m, nil + case "J": + m.vis.MoveItem(m.popJump(1)) + return m, nil + case "K": + m.vis.MoveItem(-m.popJump(1)) + return m, nil + case "t", "home": + j := m.popJump(0) + if j > 0 { + j-- + } + m.vis.Top() + m.vis.MoveCursor(j) + return m, nil + case "b", "end": + j := m.popJump(0) + if j > 0 { + j-- + } + m.vis.Bottom() + m.vis.MoveCursor(-j) + return m, nil + case "w": + m.vis.Wrap = m.popJump(0) + return m, nil + case "s": + less := func(a, b fmt.Stringer) bool { return a.String() < b.String() } + m.vis.SetLess(less) + m.vis.Sort() + return m, nil + case "o": + less := func(a, b fmt.Stringer) bool { + d, _ := a.(item) + e, _ := b.(item) + return d.id < e.id + } + m.vis.SetLess(less) + m.vis.Sort() + return m, nil + case " ": + updater := func(a fmt.Stringer) (fmt.Stringer, tea.Cmd) { + i, ok := a.(item) + if !ok { + return a, nil + } + i.selected = !i.selected + return i, nil + } + i, _ := m.vis.GetCursorIndex() + cmd := m.vis.UpdateItem(i, updater) + return m, cmd + default: + // resets jump buffer to prevent confusion + m.jump = "" + + // pipe all other commands to the update from the vis + l, newMsg := m.vis.Update(msg) + vis, _ := l.(list.Model) + m.vis = vis + return m, newMsg + } + + case tea.WindowSizeMsg: + + width := msg.Width + height := msg.Height + m.vis.Screen.Width = width + m.vis.Screen.Height = height + + if !m.ready { + // Since this program can use the full size of the viewport we need + // to wait until we've received the window dimensions before we + // can initialize the viewport. The initial dimensions come in + // quickly, though asynchronously, which is why we wait for them + // here. + m.ready = true + } + return m, nil + + default: + // pipe all other commands to the update from the vis + l, newMsg := m.vis.Update(msg) + vis, _ := l.(list.Model) + m.vis = vis + return m, newMsg + } +} + +func (m model) View() string { + return fmt.Sprintf("%s\n%s\n%s", m.head, m.vis.View(), m.tail) +} +func (m *model) AddItems(toAdd []string) { + strList := make([]fmt.Stringer, len(toAdd)) + for i, str := range toAdd { + strList[i] = item{content: str} + } + m.vis.AddItems(strList) +} + +// popJump takes default vaule and returns the integer value of the jump string +// if its empty or fails the default is returned. +func (m *model) popJump(dft int) int { + if m.jump == "" { + return dft + } + j, err := strconv.Atoi(m.jump) + if err != nil { + return dft + } + m.jump = "" + return j +} diff --git a/list/example/checkboxes/prefixer.go b/list/example/checkboxes/prefixer.go new file mode 100644 index 000000000..e7c8d2200 --- /dev/null +++ b/list/example/checkboxes/prefixer.go @@ -0,0 +1,180 @@ +package main + +import ( + "fmt" + "github.com/charmbracelet/bubbles/list" + "github.com/muesli/reflow/ansi" + "strings" +) + +// SelectPrefixer is the default struct used for Prefixing a line +type SelectPrefixer struct { + PrefixWrap bool + + // Make clear where a item begins and where it ends + Seperator string + SeperatorWrap string + + // Mark it so that even without color support all is explicit + CurrentMarker string + + // Mark if item is selected or not + Selected string + UnSelect string + + // enable Linenumber + Number bool + NumberRelative bool + + prefixWidth int + viewPos list.ViewPos + + markWidth int + numWidth int + + unmark string + mark string + + sepItem string + sepWrap string + + selecStr string + unselStr string + + selWidth int + unselWid int + + currentIndex int + model list.Model + value item +} + +// NewPrefixer returns a DefautPrefixer with default values +func NewPrefixer() *SelectPrefixer { + return &SelectPrefixer{ + PrefixWrap: false, + + // Make clear where a item begins and where it ends + Seperator: "•", + SeperatorWrap: " ", + + // Mark it so that even without color support all is explicit + CurrentMarker: ">", + + Selected: "[✓]", + UnSelect: "[ ]", + + // enable Linenumber + Number: true, + NumberRelative: false, + } +} + +// InitPrefixer sets up all strings used to prefix a given line later by Prefix() +func (s *SelectPrefixer) InitPrefixer(value fmt.Stringer, currentItemIndex int, position list.ViewPos, screen list.ScreenInfo) int { + // TODO adapt to per item call + n, ok := value.(item) + if ok { + s.value = n + } + s.currentIndex = currentItemIndex + s.viewPos = position + + offset := position.Cursor - position.LineOffset + if offset < 0 { + offset = 0 + } + + // get widest possible number, for padding + // TODO handle wrap, cause only correct when wrap off: + s.numWidth = len(fmt.Sprintf("%d", offset+screen.Height)) + + seWidth := ansi.PrintableRuneWidth(s.Selected) + unWidth := ansi.PrintableRuneWidth(s.UnSelect) + s.selWidth = seWidth + if unWidth > s.selWidth { + s.selWidth = unWidth + } + // pad the selectStrings incase they have different lenght + s.selecStr = s.Selected + strings.Repeat(" ", s.selWidth-seWidth) + s.unselStr = s.UnSelect + strings.Repeat(" ", s.selWidth-unWidth) + + // Get separators width + widthItem := ansi.PrintableRuneWidth(s.Seperator) + widthWrap := ansi.PrintableRuneWidth(s.SeperatorWrap) + // Find max width + sepWidth := widthItem + if widthWrap > sepWidth { + sepWidth = widthWrap + } + // pad all separators to the same width for easy exchange + s.sepItem = strings.Repeat(" ", sepWidth-widthItem) + s.Seperator + s.sepWrap = strings.Repeat(" ", sepWidth-widthWrap) + s.SeperatorWrap + + // pad right of prefix, with length of current pointer + s.mark = s.CurrentMarker + s.markWidth = ansi.PrintableRuneWidth(s.mark) + s.unmark = strings.Repeat(" ", s.markWidth) + + // Get the hole prefix width + s.prefixWidth = s.numWidth + s.selWidth + sepWidth + s.markWidth + + return s.prefixWidth +} + +// Prefix prefixes a given line +func (s *SelectPrefixer) Prefix(lineIndex, allLines int) string { + // if a number is set, prepend first line with number and both with enough spaces + firstPad := strings.Repeat(" ", s.numWidth) + var wrapPad string + var lineNum int + if s.Number { + lineNum = lineNumber(s.NumberRelative, s.viewPos.Cursor, s.currentIndex) + } + number := fmt.Sprintf("%d", lineNum) + // since digits are only single bytes, len is sufficient: + padTo := s.numWidth - len(number) + if padTo < 0 { + // TODO log error + padTo = 0 + } + firstPad = strings.Repeat(" ", padTo) + number + // pad wrapped lines + wrapPad = strings.Repeat(" ", s.numWidth) + + // add un/selected string + selPad := s.unselStr + + if s.value.selected { + selPad = s.selecStr + } + + // Current: handle marking of current item/first-line + curPad := s.unmark + if s.currentIndex == s.viewPos.Cursor { + curPad = s.mark + } + + // join all prefixes + linePrefix := strings.Join([]string{firstPad, selPad, s.sepItem, curPad}, "") + if lineIndex > 0 && !s.PrefixWrap { + linePrefix = strings.Join([]string{wrapPad, strings.Repeat(" ", s.selWidth), s.sepWrap, s.unmark}, "") // don't prefix wrap lines with CurrentMarker (unmark) + } + + return linePrefix +} + +// lineNumber returns line number of the given index +// and if relative is true the absolute difference to the cursor +// or if on the cursor the absolute line number +func lineNumber(relativ bool, curser, current int) int { + if !relativ || curser == current { + return current + 1 + } + + diff := curser - current + if diff < 0 { + diff *= -1 + } + return diff +} diff --git a/list/example/editable/main.go b/list/example/editable/main.go new file mode 100644 index 000000000..6b5d97f4f --- /dev/null +++ b/list/example/editable/main.go @@ -0,0 +1,440 @@ +package main + +import ( + "fmt" + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/muesli/termenv" + //"log" + "os" + "strconv" +) + +type model struct { + ready bool + list list.Model + finished bool + edit bool + jump string + lastViews []string + + // Channels to create unique ids for all added/new items + requestID chan<- struct{} + resultID <-chan int +} + +// newModel returns a new example Model and starts the goroutine go generate the unique id +func newModel() *model { + m := model{} + + req := make(chan struct{}) + res := make(chan int) + + m.requestID = req + m.resultID = res + + go func(requ <-chan struct{}, send chan<- int) { + for c := 0; true; c++ { + _ = <-requ + send <- c + } + }(req, res) + + l := list.NewModel() + l.SuffixGen = list.NewSuffixer() + + // only used if one wants to get the Index of a item. + l.SetEquals(func(first, second fmt.Stringer) bool { + f := first.(stringItem) + s := second.(stringItem) + return f.id == s.id + }) + // used for custom sorting, if not set string comparison will be used. + l.SetLess(func(first, second fmt.Stringer) bool { + f := first.(stringItem) + s := second.(stringItem) + return f.id < s.id + }) + + m.list = l + + return &m +} + +// GetID returns a new for this list unique id +func (m *model) GetID() (int, error) { + if m.requestID == nil || m.resultID == nil { + return 0, fmt.Errorf("no ID generator running") + } + var e struct{} + m.requestID <- e + return <-m.resultID, nil +} + +func (m *model) AddStrings(items []string) error { + newList := make([]fmt.Stringer, 0, len(items)) + for _, i := range items { + id, e := m.GetID() + if e != nil { + return e + } + newList = append(newList, stringItem{value: i, id: id}) + } + m.list.AddItems(newList) + return nil +} + +func (m *model) SetStyle(index int, style termenv.Style) error { + updater := func(toUp fmt.Stringer) (fmt.Stringer, tea.Cmd) { + i := toUp.(stringItem) + i.style = style + return i, nil + } + _, err := m.list.ValidIndex(index) + if err != nil { + return err + } + m.list.UpdateItem(index, updater) + return nil +} + +type stringItem struct { + value string + id int + edit bool + style termenv.Style + input textinput.Model +} + +func (s stringItem) String() string { + if s.edit { + // prepend with ansi-escape sequence to end all hightlighting to not interfere with the textinput-hightlighting + return "\x1b[0m" + s.input.View() + } + return s.style.Styled(string(s.value)) +} + +func main() { + + m := newModel() + itemList := []string{ + "Welcome to the bubbles-list example!", + "", + "Use 'q' or 'ctrl-c' to quit!", + "This example serves the purpose to show what one can do with this list, not what is this list.", + "A Item of the list is one struct value that was added to this list, that satisfies the fmt.Stringer interface (has a String() string methode),\nsince a string can have linebreaks so does a item of this list.", + "", + "Movements:", + "You can move the highlighted index up and down with the arrow keys or 'k' and 'j'.", + "Move to the top with 't' and to the bottom with 'b'.", + "All keys that change the cursor position can be preceded with the press of numbers and change the movement to that amount.\nI.e.: the key press order '1','2' and 't' moves the cursor to the twelfth item from the top.", + "If you know on which index you want to be, type the number and confirm with 'enter'.", + "", + "Order:", + "Use 'K' or 'J' to move the item under the curser up and down.", + "Sort the entries with 's' depending on a custom sort (less) function, in this case string sorting.", + "Or bring them back into the original order with the 'o' key.", + "", + "Settings:", + "To toggle between only absolute item numbers and relative numbers use the 'r' key.", + "To limit the amount of lines displayed per item, type the limit and press 'w' or just press 'w' without any numbers to unlimit again.", + "", + "Edit:", + "With the key 'e' you can edit the string of the current item. Which shows that you can embed other bubbles into the list items.", + "There you can make changes to the string and apply them with 'enter' or discard them with 'escape'", + "While you can add new empty Items with the 'a' key.", + "You can permanently delete an item, with the key 'd'.", + "", + "Here are some more items for you to test the scrolling\nand the cursor offset, which defaults to 5 lines relative to the screen border.", + "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", + "Multi-line items are not a problem, either.\nBut you may have the problem that you cant see all of the lines of the items, because movement is by design only possible by item and not by line. But since it is possible to embed other bubble-widgets, you could embed a paginator to overcome this problem.\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nCan you see me?\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n", + "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", + "If you want to jump directly to me type '5' and than 'b',\nbecause I am the fifth item (not line) from the bottom.", "", "", "", + "Hey, i am the last item :) you can move to me directly with the 'b' key, which stands for bottom.", + } + + m.AddStrings(itemList) + + m.SetStyle(0, termenv.Style{}.Foreground(termenv.ColorProfile().Color("#ffff00"))) + + p := tea.NewProgram(m) + + // Use the full size of the terminal in its "alternate screen buffer" + fullScreen := true // change to false if you dont want fullscreen + + if fullScreen { + p.EnterAltScreen() + } + + if err := p.Start(); err != nil { + fmt.Println("could not run program:", err) + os.Exit(1) + } + if fullScreen { + p.ExitAltScreen() + } +} + +func (m model) Init() tea.Cmd { + return nil +} + +// View waits till the terminal sizes is known to the model and than, +// pipes the model to the list View for rendering the list +func (m model) View() string { + if !m.ready { + return "\n Initalizing...\n\n Waiting for info about window size.\n" + } + + listString := m.list.View() + return listString +} + +// update recives messages and the model and changes the model accordingly to the messages +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if m.list.PrefixGen == nil { + // use default + m.list.PrefixGen = list.NewPrefixer() + } + + // if there is a item to be edit, pass the massage to the Update methode of the item. + if k, ok := msg.(tea.KeyMsg); m.edit && ok && k.Type != tea.KeyEscape && k.Type != tea.KeyEnter { + updater := func(toUp fmt.Stringer) (fmt.Stringer, tea.Cmd) { + item, _ := toUp.(stringItem) + if !item.edit { + return item, nil + } + newInput, cmd := item.input.Update(msg) + item.input = newInput + return item, cmd + } + i, _ := m.list.GetCursorIndex() + cmd := m.list.UpdateItem(i, updater) + return m, cmd + } + + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.Type == tea.KeyEscape { + if m.edit { + // make sure that all items edit-fields are false and discard the change + updater := func(toUp fmt.Stringer) (fmt.Stringer, tea.Cmd) { + item, _ := toUp.(stringItem) + + item.edit = false + return item, nil + } + for c := 0; c < m.list.Len(); c++ { + m.list.UpdateItem(c, updater) + } + + } + m.edit = false + return m, nil + } + + // Ctrl+c exits + if msg.Type == tea.KeyCtrlC { + return m, tea.Quit + } + keyString := msg.String() + switch keyString { + case "e": + m.edit = true + i, _ := m.list.GetCursorIndex() + + updater := func(toUp fmt.Stringer) (fmt.Stringer, tea.Cmd) { + item, _ := toUp.(stringItem) + item.input = textinput.NewModel() + item.input.Prompt = "" + item.input.SetValue(item.value) + item.input.Focus() + item.edit = true + + j, _ := strconv.Atoi(m.jump) + item.input.SetCursor(j) + m.jump = "" + return item, nil + } + m.list.UpdateItem(i, updater) + return m, nil + + case "enter": + if m.edit { + // Update the value and make sure that all items edit-fields are false + updater := func(toUp fmt.Stringer) (fmt.Stringer, tea.Cmd) { + item, _ := toUp.(stringItem) + if item.edit { + item.value = item.input.Value() + } + + item.edit = false + return item, nil + } + for c := 0; c < m.list.Len(); c++ { + m.list.UpdateItem(c, updater) + } + m.edit = false + return m, nil + + } + + j := 0 + if m.jump != "" { + j, _ = strconv.Atoi(m.jump) + m.jump = "" + m.list.SetCursor(j - 1) + } + return m, nil + + case "q": + return m, tea.Quit + case "1", "2", "3", "4", "5", "6", "7", "8", "9", "0": + m.jump += keyString + return m, nil + case "down", "j": + j := 1 + if m.jump != "" { + j, _ = strconv.Atoi(m.jump) + m.jump = "" + } + m.list.MoveCursor(j) + return m, nil + case "up", "k": + j := 1 + if m.jump != "" { + j, _ = strconv.Atoi(m.jump) + m.jump = "" + } + m.list.MoveCursor(-j) + return m, nil + case "r": + d, ok := m.list.PrefixGen.(*list.DefaultPrefixer) + if ok { + d.NumberRelative = !d.NumberRelative + } + return m, nil + case "J": + j := 1 + if m.jump != "" { + j, _ = strconv.Atoi(m.jump) + m.jump = "" + } + m.list.MoveItem(j) + return m, nil + case "K": + j := 1 + if m.jump != "" { + j, _ = strconv.Atoi(m.jump) + m.jump = "" + } + m.list.MoveItem(-j) + return m, nil + case "t", "home": + j := 0 + if m.jump != "" { + j, _ = strconv.Atoi(m.jump) + m.jump = "" + } + if j > 0 { + j-- + } + m.list.Top() + m.list.MoveCursor(j) + return m, nil + case "b", "end": + j := 0 + if m.jump != "" { + j, _ = strconv.Atoi(m.jump) + m.jump = "" + } + if j > 0 { + j-- + } + m.list.Bottom() + m.list.MoveCursor(-j) + return m, nil + + case "w": + if m.jump != "" { + j, _ := strconv.Atoi(m.jump) + m.jump = "" + m.list.Wrap = j + return m, nil + } + m.list.Wrap = 0 + return m, nil + case "s": + less := func(a, b fmt.Stringer) bool { return a.String() < b.String() } + m.list.SetLess(less) + m.list.Sort() + return m, nil + case "o": + less := func(a, b fmt.Stringer) bool { + d, _ := a.(stringItem) + e, _ := b.(stringItem) + return d.id < e.id + } + m.list.SetLess(less) + m.list.Sort() + return m, nil + case "a": + j := 1 + if m.jump != "" { + j, _ = strconv.Atoi(m.jump) + m.jump = "" + } + m.AddStrings(make([]string, j)) + return m, nil + case "d": + j := 1 + if m.jump != "" { + j, _ = strconv.Atoi(m.jump) + m.jump = "" + } + var ok bool + var i int + for c := 0; c < j && !ok; c++ { + i, _ = m.list.GetCursorIndex() + _, cmd := m.list.RemoveIndex(i) + _, ok = cmd().(error) + } + return m, nil + + default: + // resets jump buffer to prevent confusion + m.jump = "" + + // pipe all other commands to the update from the list + l, newMsg := m.list.Update(msg) + list, _ := l.(list.Model) + m.list = list + return m, newMsg + } + + case tea.WindowSizeMsg: + + width := msg.Width + height := msg.Height + m.list.Screen.Width = width + m.list.Screen.Height = height + + if !m.ready { + // Since this program can use the full size of the viewport we need + // to wait until we've received the window dimensions before we + // can initialize the viewport. The initial dimensions come in + // quickly, though asynchronously, which is why we wait for them + // here. + m.ready = true + } + return m, nil + + default: + // pipe all other commands to the update from the list + l, newMsg := m.list.Update(msg) + list, _ := l.(list.Model) + m.list = list + return m, newMsg + } +} diff --git a/list/example/tree/less_test.go b/list/example/tree/less_test.go new file mode 100644 index 000000000..4e230210f --- /dev/null +++ b/list/example/tree/less_test.go @@ -0,0 +1,48 @@ +package main + +import ( + "fmt" + "testing" +) + +// TestLess test the symmetric of the used less function. +// Since if the less function yields a < b == b < a +// for any possible input the sort while not be reproducible!!! +func TestLess(t *testing.T) { + allNodes := []fmt.Stringer{ + node{ + parentIDs: []int{7}, + value: "no children here"}, + node{ + parentIDs: []int{1}, + value: "use '+' to unfold a node"}, + node{ + parentIDs: []int{1, 4}, + value: "use '-' to hide all children of this node"}, + node{ + parentIDs: []int{1, 8}, + value: "use 'up' and 'down' to move around"}, + node{ + parentIDs: []int{1, 4, 5}, + value: "grand child\nwith a line break"}, + node{ + parentIDs: []int{3}, + value: "parent with no grand children"}, + node{ + parentIDs: []int{3, 2}, + value: "hänsel"}, + node{ + parentIDs: []int{3, 6}, + value: "gretel"}, + } + allLen := len(allNodes) + for c := 0; c < allLen; c++ { + for i := c + 1; i < allLen; i++ { + if less(allNodes[c], allNodes[i]) == less(allNodes[i], allNodes[c]) { + cIDs, _ := allNodes[c].(node) + iIDs, _ := allNodes[i].(node) + t.Errorf("%v, %v", cIDs.parentIDs, iIDs.parentIDs) + } + } + } +} diff --git a/list/example/tree/main.go b/list/example/tree/main.go new file mode 100644 index 000000000..661953220 --- /dev/null +++ b/list/example/tree/main.go @@ -0,0 +1,241 @@ +package main + +import ( + "fmt" + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "os" + "strings" +) + +// DISCLAIMER: This is not a template but a example. +// This code is NOT performant or good for any other purpose except to show the possibility's of the list bubble. + +func main() { + allNodes := []fmt.Stringer{ + node{ + parentIDs: []int{7}, + value: "no children here"}, + node{ + parentIDs: []int{1}, + value: "use '+' to unfold a node"}, + node{ + parentIDs: []int{1, 4}, + value: "use '-' to hide all children of this node"}, + node{ + parentIDs: []int{1, 8}, + value: "use 'up' and 'down' to move around"}, + node{ + parentIDs: []int{1, 4, 5}, + value: "grand child\nwith a line break"}, + node{ + parentIDs: []int{3}, + value: "parent with no grand children"}, + node{ + parentIDs: []int{3, 2}, + value: "hänsel"}, + node{ + parentIDs: []int{3, 6}, + value: "gretel"}, + } + var visNodes []fmt.Stringer + for i, v := range allNodes { + n, ok := v.(node) + if !ok { + continue + } + n.vis = true + visNodes = append(visNodes, n) + allNodes[i] = n + } + m := model{allNodes: allNodes} + m.visible = list.NewModel() + m.visible.SetLess(less) + m.visible.SetEquals(equals) + m.visible.AddItems(visNodes) + m.startCmd = func() tea.Msg { return startMsg{} } + + m.visible.PrefixGen = NewPrefixer() + + p := tea.NewProgram(m) + if err := p.Start(); err != nil { + fmt.Println("could not run program:", err) + os.Exit(1) + } +} + +type model struct { + visible list.Model + allNodes []fmt.Stringer + startCmd tea.Cmd +} + +type node struct { + parentIDs []int + value string + vis bool +} + +type startMsg struct{} + +func (n node) String() string { + return n.value +} + +func (n node) GetID() (int, error) { + lenID := len(n.parentIDs) + if lenID == 0 { + return 0, fmt.Errorf("no id set") + } + return n.parentIDs[lenID-1], nil +} + +func (m model) Init() tea.Cmd { return m.startCmd } +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.Type == tea.KeyCtrlC { + return m, tea.Quit + } + switch msg.String() { + case "q": + return m, tea.Quit + case "+": + v, cmd := m.visible.GetCursorItem() + if cmd != nil { + msg := cmd() + if _, ok := msg.(error); ok { + return nil, cmd + } + } + + parent, ok := v.(node) + if !ok { + return m, nil + } + var newNodes []fmt.Stringer + for i, v := range m.allNodes { + n, ok := v.(node) + parLen := len(parent.parentIDs) + if !ok || len(n.parentIDs) <= parLen { + continue + } + if len(n.parentIDs) == parLen+1 && n.parentIDs[parLen-1] == parent.parentIDs[parLen-1] && !n.vis { + n.vis = true + newNodes = append(newNodes, n) + m.allNodes[i] = n + } + } + cmd = m.visible.AddItems(newNodes) + m.visible.Sort() + return m, cmd + + case "-": + v, cmd := m.visible.GetCursorItem() + if cmd != nil { + msg := cmd() + if _, ok := msg.(error); ok { + return m, cmd + } + } + parent, ok := v.(node) + if !ok { + return m, nil + } + + // TODO NOTE this is not performant: a round O(1/2(n*m)) (average) + // whereby 'n' m.allNodes and 'm' all m.visible.GetAllItems() + for i, v := range m.allNodes { + n, ok := v.(node) + parLen := len(parent.parentIDs) + if !ok || len(n.parentIDs) <= parLen { + continue + } + if n.vis && len(n.parentIDs) > parLen && n.parentIDs[parLen-1] == parent.parentIDs[parLen-1] { + index, err := m.visible.GetIndex(n) + if err != nil { + continue + } + m.visible.RemoveIndex(index) + n.vis = false + m.allNodes[i] = n + } + + } + return m, cmd + case "s": + // dont return the command issued by the Sort command, possible endless sort loop! + // Because we are sorting when receiving a ListChange Msg and they get issued by the Sort method -> loop. + _ = m.visible.Sort() + return m, nil + default: + newList, cmd := m.visible.Update(msg) + newVis, ok := newList.(list.Model) + if ok { + m.visible = newVis + } + return m, cmd + } + case list.ListChange: + // dont return the command issued by the Sort command, endless sort loop! + // Because we are sorting here when receiving a ListChange Msg and they get issued by the Sort method -> loop. + _ = m.visible.Sort() + return m, nil + case startMsg: + _ = m.visible.Sort() + _, _ = m.visible.SetCursor(0) + return m, nil + default: + newList, cmd := m.visible.Update(msg) + newVis, ok := newList.(list.Model) + if ok { + m.visible = newVis + } + return m, cmd + } +} +func (m model) View() string { + lines, err := m.visible.Lines() + if err != nil { + return err.Error() + } + return strings.Join(lines, "\n") +} +func (m model) Lines() ([]string, error) { + return m.visible.Lines() +} + +func less(a, b fmt.Stringer) bool { + first, ok1 := a.(node) + if !ok1 { + panic("cant sort something else than nodes") + } + second, ok2 := b.(node) + if !ok2 { + panic("cant sort something else than nodes") + } + firLen, secLen := len(first.parentIDs), len(second.parentIDs) + shorter := firLen + if secLen < shorter { + shorter = secLen + } + for c := 0; c < shorter; c++ { + if first.parentIDs[c] > second.parentIDs[c] { + return false + } + if first.parentIDs[c] < second.parentIDs[c] { + return true + } + } + + return firLen <= secLen +} +func equals(a, b fmt.Stringer) bool { + first, ok1 := a.(node) + second, ok2 := b.(node) + if !ok1 || !ok2 { + return false + } + firLen, secLen := len(first.parentIDs), len(second.parentIDs) + return firLen == secLen && first.parentIDs[firLen-1] == second.parentIDs[secLen-1] +} diff --git a/list/example/tree/prefixer.go b/list/example/tree/prefixer.go new file mode 100644 index 000000000..0d08c0784 --- /dev/null +++ b/list/example/tree/prefixer.go @@ -0,0 +1,158 @@ +package main + +import ( + "fmt" + "github.com/charmbracelet/bubbles/list" + "github.com/muesli/reflow/ansi" + "github.com/muesli/termenv" + "strings" +) + +// TreePrefixer is the default struct used for Prefixing a line +type TreePrefixer struct { + PrefixWrap bool + + // Make clear where a item begins and where it ends + Seperator string + SeperatorWrap string + + // Mark it so that even without color support all is explicit + CurrentMarker string + + // enable Linenumber + Number bool + NumberRelative bool + + prefixWidth int + viewPos list.ViewPos + + sepWidth int + markWidth int + numWidth int + + currentIndex int + + level int + LevelPadder func(int) string +} + +// NewPrefixer returns a DefautPrefixer with default values +func NewPrefixer() *TreePrefixer { + return &TreePrefixer{ + PrefixWrap: true, + + // Make clear where a item begins and where it ends + Seperator: "╭", + SeperatorWrap: "│", + + // Mark it so that even without color support all is explicit + CurrentMarker: ">", + + // enable Linenumber + Number: true, + NumberRelative: false, + LevelPadder: padLevel, + } +} + +// InitPrefixer sets up all strings used to prefix a given line later by Prefix() +func (d *TreePrefixer) InitPrefixer(value fmt.Stringer, currentItemIndex int, position list.ViewPos, screen list.ScreenInfo) int { + d.currentIndex = currentItemIndex + d.viewPos = position + + offset := position.Cursor - position.LineOffset + if offset < 0 { + offset = 0 + } + + // Get max separators width + d.sepWidth = ansi.PrintableRuneWidth(d.Seperator) + if widthWrap := ansi.PrintableRuneWidth(d.SeperatorWrap); widthWrap > d.sepWidth { + d.sepWidth = widthWrap + } + + // get widest possible number, for padding + // TODO handle wrap, cause only correct when wrap off: + d.numWidth = len(fmt.Sprintf("%d", offset+screen.Height)) + + // pad right of prefix, with length of current pointer + + d.markWidth = ansi.PrintableRuneWidth(d.CurrentMarker) + + n, ok := value.(node) + if ok { + d.level = len(n.parentIDs) - 1 + } + + // Get the hole prefix width + d.prefixWidth = d.numWidth + d.sepWidth + d.markWidth + + return d.prefixWidth +} + +// Prefix prefixes a given line +func (d *TreePrefixer) Prefix(lineIndex, allLines int) string { + // pad all separators to the same width for easy exchange + sepItem := strings.Repeat(" ", d.sepWidth-ansi.PrintableRuneWidth(d.Seperator)) + d.Seperator + sepWrap := strings.Repeat(" ", d.sepWidth-ansi.PrintableRuneWidth(d.SeperatorWrap)) + d.SeperatorWrap + + // if a number is set, prepend first line with number and both with enough spaces + firstPad := strings.Repeat(" ", d.numWidth) + var lineNum int + if d.Number { + lineNum = lineNumber(d.NumberRelative, d.viewPos.Cursor, d.currentIndex) + } + number := fmt.Sprintf("%d", lineNum) + // since digits are only single bytes, len is sufficient: + padTo := d.numWidth - len(number) + if padTo < 0 { + // TODO log error + padTo = 0 + } + firstPad = strings.Repeat(" ", padTo) + number + // pad wrapped lines + wrapPad := strings.Repeat(" ", d.numWidth) + + // Current: handle highlighting of current item/first-line + curPad := strings.Repeat(" ", d.markWidth) + if d.currentIndex == d.viewPos.Cursor { + curPad = d.CurrentMarker + } + + // join all prefixes + linePrefix := strings.Join([]string{firstPad, sepItem, curPad}, "") + if lineIndex > 0 { + linePrefix = strings.Join([]string{wrapPad, sepWrap, strings.Repeat(" ", ansi.PrintableRuneWidth(curPad))}, "") // don't prefix wrap lines with CurrentMarker (unmark) + } + if d.LevelPadder != nil { + linePrefix += d.LevelPadder(d.level) + } + + return linePrefix +} + +// lineNumber returns line number of the given index +// and if relative is true the absolute difference to the cursor +// or if on the cursor the absolute line number +func lineNumber(relativ bool, curser, current int) int { + if !relativ || curser == current { + return current + 1 + } + + diff := curser - current + if diff < 0 { + diff *= -1 + } + return diff +} + +func padLevel(level int) string { + if level > 0 { + color := termenv.ColorProfile().Color("#0000ff") + sty := termenv.Style{} + sty = sty.Foreground(color) + sty = sty.Background(color) + return fmt.Sprintf("%s %s", strings.Repeat(" ", level-1), sty.Styled(" ")) + } + return "" +} diff --git a/list/item.go b/list/item.go new file mode 100644 index 000000000..3ef090618 --- /dev/null +++ b/list/item.go @@ -0,0 +1,118 @@ +package list + +import ( + "fmt" + "github.com/muesli/reflow/ansi" + "github.com/muesli/reflow/wordwrap" + "strings" +) + +// Item are Items used in the list Model +// to hold the Content represented as a string +type item struct { + value fmt.Stringer + id int +} + +// itemLines returns the lines of the item string value wrapped to the according content-width +// and the write amount of lines accoring to m.Wrap +func (m *Model) itemLines(i item, index int) []string { + var preWidth, sufWidth int + if m.PrefixGen != nil { + preWidth = m.PrefixGen.InitPrefixer(i.value, index, m.viewPos, m.Screen) + } + if m.SuffixGen != nil { + sufWidth = m.SuffixGen.InitSuffixer(i.value, index, m.viewPos, m.Screen) + } + contentWith := m.Screen.Width - preWidth - sufWidth + // TODO hard limit the string length + lines := strings.Split(wordwrap.String(i.value.String(), contentWith), "\n") + if m.Wrap != 0 && len(lines) > m.Wrap { + return lines[:m.Wrap] + } + return lines +} + +// getItemLines surrounds the line content with the according prefix and suffix +func (m *Model) getItemLines(index, contentWidth int) ([]string, error) { + _, err := m.ValidIndex(index) + if err != nil { + return nil, err + } + item := m.listItems[index] + lines := m.itemLines(item, index) + lenLines := len(lines) + completLines := make([]string, lenLines) + + for c := 0; c < lenLines; c++ { + lineContent := lines[c] + // Surrounding content + var linePrefix, lineSuffix string + if m.PrefixGen != nil { + linePrefix = m.PrefixGen.Prefix(c, lenLines) + } + if m.SuffixGen != nil { + free := contentWidth - ansi.PrintableRuneWidth(lineContent) + if free < 0 { + free = 0 // TODO is this nessecary? + } + suffix := m.SuffixGen.Suffix(c, lenLines) + if suffix != "" { + lineSuffix = fmt.Sprintf("%s%s", strings.Repeat(" ", free), suffix) + } + } + + // Join all + line := fmt.Sprintf("%s%s%s", linePrefix, lineContent, lineSuffix) + + // Highlighting of current item lines + style := m.LineStyle + if index == m.viewPos.Cursor { + style = m.CurrentStyle + } + + // Highlight and write line + completLines[c] = style.Styled(line) + } + return completLines, nil +} + +// StringItem is just a convenience to satisfy the fmt.Stringer interface with plain strings +type StringItem string + +func (s StringItem) String() string { + return string(s) +} + +// MakeStringerList is a shortcut to convert a string List to a List that satisfies the fmt.Stringer Interface +func MakeStringerList(list []string) []fmt.Stringer { + stringerList := make([]fmt.Stringer, len(list)) + for i, item := range list { + stringerList[i] = StringItem(item) + } + return stringerList +} + +// itemList is a struct only to make the list-Model Sortable without exposing the demanded methodes, +// cause they would undermine the event loop. (Swap would change the list without the possibility of +// returning a tea.Cmd to signal the change. +type itemList struct { + list *[]item + less *func(fmt.Stringer, fmt.Stringer) bool +} + +func (m *itemList) Less(i, j int) bool { + // If User does not provide less function use string comparison, but dont change m.less, to be able to see when user set one. + if *m.less == nil { + return (*m.list)[i].value.String() < (*m.list)[j].value.String() + } + return (*m.less)((*m.list)[i].value, (*m.list)[j].value) +} + +func (m *itemList) Swap(i, j int) { + (*m.list)[i], (*m.list)[j] = (*m.list)[j], (*m.list)[i] +} + +func (m *itemList) Len() int { + return len(*m.list) +} diff --git a/list/list.go b/list/list.go new file mode 100644 index 000000000..820244c12 --- /dev/null +++ b/list/list.go @@ -0,0 +1,730 @@ +package list + +import ( + "fmt" + tea "github.com/charmbracelet/bubbletea" + "github.com/muesli/termenv" + "sort" + "strings" +) + +// Model is a bubbletea List of strings +type Model struct { + focus bool + + listItems []item + + less func(fmt.Stringer, fmt.Stringer) bool // function used for sorting + equals func(fmt.Stringer, fmt.Stringer) bool // used after sorting, to be set from the user + + // offset or margin between the cursor and the viewport(visible) border + CursorOffset int + + Screen ScreenInfo + viewPos ViewPos + + // Wrap changes the number of lines which get displayed. 0 means unlimited lines. + Wrap int + + PrefixGen Prefixer + SuffixGen Suffixer + + LineStyle termenv.Style + CurrentStyle termenv.Style + + // Channels to create unique ids for all added/new items + requestID chan<- struct{} + resultID <-chan int +} + +// NewModel returns a Model with some save/sane defaults +// design to transfer as much internal information to the user +func NewModel() Model { + // just reverse colors to keep there information + curStyle := termenv.Style{}.Reverse() + return Model{ + // Accept key presses + focus: true, + + // Try to keep $CursorOffset lines between Cursor and screen Border + CursorOffset: 5, + viewPos: ViewPos{LineOffset: 5}, + + // show all lines + Wrap: 0, + + // show line number + PrefixGen: NewPrefixer(), + + CurrentStyle: curStyle, + } +} + +// Init does nothing +func (m Model) Init() tea.Cmd { + return nil +} + +// View renders the List output according to the current model +// and returns "empty" if the list has no items. This might change in the future. +func (m Model) View() string { + + lines, err := m.lines() + if err != nil { + return err.Error() + } + + return strings.Join(lines, "\n") +} + +// Update changes the Model of the List according to the messages received +// if the list is focused, only WindowSizeMsg are handeled. +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // handel Window resizes even if the model is not focused + if msg, ok := msg.(tea.WindowSizeMsg); ok { + m.Screen.Width = msg.Width + m.Screen.Height = msg.Height + m.Screen.Profile = termenv.ColorProfile() + return m, nil + } + + if !m.focus { + return m, nil + } + + switch msg := msg.(type) { + case tea.KeyMsg: + // Quit + if msg.Type == tea.KeyCtrlC { + return m, tea.Quit + } + switch msg.String() { + // Move + case "down": + m.MoveCursor(1) + return m, nil + case "up": + m.MoveCursor(-1) + return m, nil + case "home": + m.Top() + return m, nil + case "end": + m.Bottom() + return m, nil + default: + return m, func() tea.Msg { return UnhandledKey(fmt.Errorf("no binding for the key: '%s'", msg.String())) } + } + + case tea.MouseMsg: + switch msg.Type { + case tea.MouseWheelUp: + m.MoveCursor(-1) + return m, nil + + case tea.MouseWheelDown: + m.MoveCursor(1) + return m, nil + } + } + return m, nil +} + +// Lines renders the visible lines of the list +// by calling the String Methodes of the items +// and if present the pre- and suffix function. +// If there is not enough space, or there a no +// item within the list, nil and a error is returned. +func (m Model) Lines() ([]string, error) { + return m.lines() +} + +// lines is a method which gets called by View and Lines, +// because the are functions and if the View would call the Lines function directly, +// the model would be copied twice, once for the View call and ones for the Lines call. +// But since they both (Lines and View) can call this method, +// its only one copy of the model when caling either View or Lines. +func (m *Model) lines() ([]string, error) { + if m.Len() == 0 { + return nil, NoItems(fmt.Errorf("no items within the list")) + } + // check visible area + if m.Screen.Height*m.Screen.Width <= 0 { + return nil, fmt.Errorf("Can't display with zero width or hight of Viewport") + } + + linesBefor := make([]string, 0, m.viewPos.LineOffset) + // loop to add the item(-lines) befor the cursor to the return lines + // dont add cursor item + for c := 1; m.viewPos.Cursor-c >= 0; c++ { + index := m.viewPos.Cursor - c + // Get the Width of each suf/prefix + var prefixWidth, suffixWidth int + if m.PrefixGen != nil { + prefixWidth = m.PrefixGen.InitPrefixer(m.listItems[index].value, c, m.viewPos, m.Screen) + } + if m.SuffixGen != nil { + suffixWidth = m.SuffixGen.InitSuffixer(m.listItems[index].value, c, m.viewPos, m.Screen) + } + // Get actual content width + contentWidth := m.Screen.Width - prefixWidth - suffixWidth + + // Check if there is space for the content left + if contentWidth <= 0 { + return nil, fmt.Errorf("Can't display with zero width or hight of Viewport") + } + itemLines, _ := m.getItemLines(index, contentWidth) + // append lines in revers order + for i := len(itemLines) - 1; i >= 0 && len(linesBefor) < m.viewPos.LineOffset; i-- { + linesBefor = append(linesBefor, itemLines[i]) + } + } + + // append lines (befor cursor) in correct order to allLines + allLines := make([]string, 0, m.Screen.Height) + for c := len(linesBefor) - 1; c >= 0; c-- { + allLines = append(allLines, linesBefor[c]) + } + + // Handle list items, start at cursor and go till end of list or visible (break) + for index := m.viewPos.Cursor; index < m.Len(); index++ { + // Get the Width of each suf/prefix + var prefixWidth, suffixWidth int + if m.PrefixGen != nil { + prefixWidth = m.PrefixGen.InitPrefixer(m.listItems[index].value, index, m.viewPos, m.Screen) + } + if m.SuffixGen != nil { + suffixWidth = m.SuffixGen.InitSuffixer(m.listItems[index].value, index, m.viewPos, m.Screen) + } + // Get actual content width + contentWidth := m.Screen.Width - prefixWidth - suffixWidth + + // Check if there is space for the content left + if contentWidth <= 0 { + return nil, fmt.Errorf("Can't display with zero width or hight of Viewport") + } + itemLines, _ := m.getItemLines(index, contentWidth) + // append lines in correct order + for i := 0; i < len(itemLines) && len(allLines) < m.Screen.Height; i++ { + allLines = append(allLines, itemLines[i]) + } + } + if len(allLines) == 0 { + return nil, fmt.Errorf("no visible lines") + } + + return allLines, nil +} + +// NoItems is a error returned when the list is empty +type NoItems error + +// NotFound gets return if the search does not yield a result +type NotFound error + +// OutOfBounds is return if and index is outside the list boundary's +type OutOfBounds error + +// MultipleMatches gets return if the search yield more result +type MultipleMatches error + +// ConfigError is return if there is a error with the configuration of the list Model +type ConfigError error + +// NotFocused is a error return if the action can only be applied to a focused list. +type NotFocused error + +// NilValue is returned if there was a request to set nil as value of a list item. +type NilValue error + +// UnhandledKey is returned when there is no binding for this key press. +type UnhandledKey error + +// CursorIndexChange is used to signal the numeric change of the Cursor index +type CursorIndexChange int + +// CursorItemChange signals the change of the cursor item. +// Maybe caused by updating the item, changing the cursor position or deletion of the cursor item +type CursorItemChange struct{} + +// ListChange signals the adding, changing (updating), deletion or the change of order of items within the list +type ListChange struct{} + +//TODO make New functions for errors and Messages + +// ValidIndex returns a error when the list has no items, is not focused, the index is out of bounds. +// And the nearest valid index in case of OutOfBounds error, else the index it self and no error. +func (m *Model) ValidIndex(index int) (int, error) { + if m.Len() <= 0 { + return 0, NoItems(fmt.Errorf("the list has no items")) + } + if !m.focus { + // TODO remove focus state of the list model because user should handel the flow of the messages + // and thus no unintended messages or function calls should reach the list objekt. + return 0, NotFocused(fmt.Errorf("the list is not focused")) + } + if index < 0 { + return 0, OutOfBounds(fmt.Errorf("the requested index (%d) is in front the list begin (%d)", index, 0)) + } + if index > m.Len()-1 { + return m.Len() - 1, OutOfBounds(fmt.Errorf("the requested index (%d) is beyond the list end (%d)", index, m.Len()-1)) + } + return index, nil +} + +func (m *Model) validOffset(newCursor int) (int, error) { + if m.CursorOffset*2 > m.Screen.Height { + return 0, ConfigError(fmt.Errorf("CursorOffset must be less than have the screen height")) + } + newCursor, err := m.ValidIndex(newCursor) + if m.Len() <= 0 { + return m.CursorOffset, err + } + amount := newCursor - m.viewPos.Cursor + if amount == 0 { + if m.viewPos.LineOffset < m.CursorOffset { + return m.CursorOffset, nil + } + return m.viewPos.LineOffset, nil + } + newOffset := m.viewPos.LineOffset + amount + + if m.Wrap != 1 { + // assume down (positive) movement + start := 0 + stop := amount - 1 // exclude target item (-lines) + + d := 1 + if amount < 0 { + d = -1 + stop = amount * d + start = 1 // exclude old cursor position + } + + var lineSum int + for i := start; i <= stop; i++ { + lineSum += len(m.itemLines(m.listItems[m.viewPos.Cursor+i*d], m.viewPos.Cursor+i*d)) + } + newOffset = m.viewPos.LineOffset + lineSum*d + } + + if newOffset < m.CursorOffset { + newOffset = m.CursorOffset + } else if newOffset > m.Screen.Height-m.CursorOffset-1 { + newOffset = m.Screen.Height - m.CursorOffset - 1 + } + return newOffset, err +} + +// ViewPos is used for holding the information about the View parameters +type ViewPos struct { + LineOffset int + Cursor int +} + +// ScreenInfo holds all information about the screen Area +type ScreenInfo struct { + Width int + Height int + Profile termenv.Profile +} + +// MoveCursor moves the cursor by amount and returns the absolut index of the cursor after the movement. +// If any error occurs the cursor is not moved and the returning tea.Cmd while yield the according error. +// If all goes well and the cursor has changed tea.Cmd while yield a CursorItemChange and a CursorIndexChange. +func (m *Model) MoveCursor(amount int) (int, tea.Cmd) { + target := m.viewPos.Cursor + amount + + target, err1 := m.ValidIndex(target) + newOffset, err2 := m.validOffset(target) + if amount == 0 { + return target, nil + } + if err1 != nil || err2 != nil { + return target, tea.Batch(func() tea.Msg { return err1 }, func() tea.Msg { return err2 }) + } + + m.viewPos.Cursor = target + m.viewPos.LineOffset = newOffset + return target, tea.Batch(func() tea.Msg { return CursorItemChange{} }, func() tea.Msg { return CursorIndexChange(target) }) +} + +// SetCursor set the cursor to the specified index if possible, but If any error occurs +// the cursor is not moved and the returning tea.Cmd while yield the according error. +// If all goes well and the cursor has changed tea.Cmd while yield a CursorItemChange and a CursorIndexChange. +func (m *Model) SetCursor(target int) (int, tea.Cmd) { + target, err := m.ValidIndex(target) + newOffset, _ := m.validOffset(target) + if err != nil { + return target, func() tea.Msg { return err } + } + if target == m.viewPos.Cursor { + return target, nil + } + + m.viewPos.Cursor = target + m.viewPos.LineOffset = newOffset + return target, tea.Batch(func() tea.Msg { return CursorItemChange{} }, func() tea.Msg { return CursorIndexChange(target) }) +} + +// Top moves the cursor to the first item if the list is not empty, else the cursor +// is not moved and the returning tea.Cmd while yield the according error. +// If all goes well and the cursor has changed tea.Cmd while yield a CursorItemChange and a CursorIndexChange. +func (m *Model) Top() tea.Cmd { + _, err := m.ValidIndex(0) + if err != nil { + return func() tea.Msg { return err } + } + if m.viewPos.Cursor == 0 { + return nil + } + m.viewPos.Cursor = 0 + m.viewPos.LineOffset = m.CursorOffset + return tea.Batch(func() tea.Msg { return CursorItemChange{} }, func() tea.Msg { return CursorIndexChange(0) }) +} + +// Bottom moves the cursor to the last item if the list is not empty, else the cursor +// is not moved and the returning tea.Cmd while yield the according error. +// If all goes well and the cursor has changed tea.Cmd while yield a CursorItemChange and a CursorIndexChange. +func (m *Model) Bottom() tea.Cmd { + end := len(m.listItems) - 1 + _, err := m.ValidIndex(end) + if err != nil { + return func() tea.Msg { return err } + } + if m.viewPos.Cursor == end { + return nil + } + m.viewPos.LineOffset = m.Screen.Height - m.CursorOffset + m.SetCursor(end) + return tea.Batch(func() tea.Msg { return CursorItemChange{} }, func() tea.Msg { return CursorIndexChange(end) }) +} + +// AddItems adds the given Items to the list Model. Run Sort() afterwards, if you want to keep the list sorted. +// If entrys of itemList are nil they will not be added, and a NilValue error is returned through tea.Cmd. +// Neither the cursor item nor its index will change, but if items where added, tea.Cmd will yield a ListChange Msg. +// If you add very many Items, the program will get slower, since bubbletea is a elm architektur, +// Update and View are functions and are call with a copy of the list-Model which takes more time if the Model/List is bigger. +func (m *Model) AddItems(itemList []fmt.Stringer) tea.Cmd { + if len(itemList) == 0 { + return nil + } + oldLenght := m.Len() + for _, i := range itemList { + if i != nil { + m.listItems = append(m.listItems, item{ + value: i, + id: m.getID(), + }, + ) + } + } + var err error + var cmd tea.Cmd + if oldLenght != m.Len() { + cmd = func() tea.Msg { return ListChange{} } + } + if m.Len() < oldLenght+len(itemList) { + err = NilValue(fmt.Errorf("there where '%d' nil values which where not added", m.Len()-oldLenght+len(itemList))) + return tea.Batch(func() tea.Msg { return err }, cmd) + } + return cmd +} + +// ResetItems replaces all list items with the new items, if a entry is nil its not added. +// If equals function is set and a new item yields true in comparison to the old cursor item +// the cursor is set on this (or if equals-func is bad the last-)item. +// If the Cursor Index or Item has changed the corrisponding tea.Cmd is returned, +// but in any case a ListChange is returned through the tea.Cmd. +func (m *Model) ResetItems(newStringers []fmt.Stringer) tea.Cmd { + oldCursorItem, err := m.GetCursorItem() + // Reset Cursor + m.viewPos.Cursor = 0 + + //TODO handel len(newStringers) == 0 && m.Len() == 0 + + cmd := func() tea.Msg { return CursorItemChange{} } + + newItems := make([]item, len(newStringers)) + for i, newValue := range newStringers { + if newValue == nil { + continue + } + newItems[i].value = newValue + newItems[i].id = m.getID() + + if m.equals != nil && err != nil && m.equals(oldCursorItem, newValue) { + m.viewPos.Cursor = i + cmd = func() tea.Msg { return CursorIndexChange(i) } + } + } + + m.listItems = newItems + + // reset LineOffset if Cursor was not set by matching through equals + if m.viewPos.Cursor == 0 { + m.viewPos.LineOffset = m.CursorOffset + } + // only sort if user set less function + if m.less != nil { + // Sort will take care of the correct position of Cursor and Offset + cmd = m.Sort() + } + return tea.Batch(cmd, func() tea.Msg { return ListChange{} }) +} + +// RemoveIndex removes and returns the item at the given index if it exists, +// else a error is returned through the tea.Cmd. +// If the cursor index or item has changed tea.Cmd while yield a CursorItemChange or a CursorIndexChange. +// The cursor will hold its numeric position except the list gets to short one which case its on the end of the list. +func (m *Model) RemoveIndex(index int) (fmt.Stringer, tea.Cmd) { + if _, err := m.ValidIndex(index); err != nil { + return nil, func() tea.Msg { return err } + } + var rest []item + itemValue, _ := m.GetItem(index) + if index+1 < m.Len() { + rest = m.listItems[index+1:] + } + m.listItems = append(m.listItems[:index], rest...) + cmd := func() tea.Msg { return ListChange{} } + + oldCursor := m.viewPos.Cursor + newCursor, err := m.ValidIndex(oldCursor) + newOffset, _ := m.validOffset(newCursor) + m.viewPos.Cursor = newCursor + m.viewPos.LineOffset = newOffset + + if err != nil { + cmd = tea.Batch(func() tea.Msg { return CursorItemChange{} }, cmd) + } + if oldCursor != newCursor { + cmd = tea.Batch(cmd, func() tea.Msg { return CursorIndexChange(newCursor) }) + } + return itemValue, cmd +} + +// Sort sorts the list items according to the set less-function or, if not set, after String comparison. +// Internally the sort.Sort interface is used, so this is not guaranteed to be a stable sort. +// If you need stable sorting, sort the items your self and reset the list with them. +// While sorting the cursor item can not change, but the cursor index can, +// so a CursorIndexChange Msg through tea.Cmd is returned in this case. +// A ListChange Cmd is returned, if the list was not empty. +func (m *Model) Sort() tea.Cmd { + if m.Len() < 1 { + return nil + } + cmd := func() tea.Msg { return ListChange{} } + old := m.listItems[m.viewPos.Cursor].id + // to be able to satisfy the sort.Interface without exposing a Methode which could change the List silently, + // a private struct (itemList) was created to fullfill it indirectly. + sort.Sort(&itemList{&(m.listItems), &(m.less)}) + for i, item := range m.listItems { + if item.id == old { + if i != m.viewPos.Cursor { + cmd = tea.Batch(cmd, func() tea.Msg { return CursorIndexChange(i) }) + } + m.viewPos.Cursor = i + break + } + } + return cmd +} + +// Len returns the amount of list-items. +func (m *Model) Len() int { + return len(m.listItems) +} + +// SetLess sets the internal less function only used for the Sort method of the list Model. +// If you want to keep the list sorted, you have to Sort it yourself after adding Items or Updating Items. +func (m *Model) SetLess(less func(a, b fmt.Stringer) bool) { + m.less = less +} + +// SetEquals sets the internal equals method used to get the index (GetIndex) of a provided fmt.Stringer value, +// or to set the cursor on the right item when reseting the list items. +func (m *Model) SetEquals(equ func(first, second fmt.Stringer) bool) { + m.equals = equ +} + +// GetEquals returns the internal equals method +// used to get the index (GetIndex) of a provided fmt.Stringer value +func (m *Model) GetEquals() func(first, second fmt.Stringer) bool { + // TODO remove? + return m.equals +} + +// MoveItem moves the current item by amount to the end of the list. +// If the target does not exist a error is returned through tea.Cmd. +// Else a ListChange and a CursorIndexChange is returned. +func (m *Model) MoveItem(amount int) tea.Cmd { + cur := m.viewPos.Cursor + target, err := m.ValidIndex(cur + amount) + if err != nil { + return func() tea.Msg { return err } + } + + d := 1 + if amount < 0 { + d = -1 + } + // TODO change to not O(n) + for c := 0; c*d < amount*d; c += d { + m.listItems[cur+c], m.listItems[cur+c+d] = m.listItems[cur+c+d], m.listItems[cur+c] + } + linOff, _ := m.validOffset(target) + m.viewPos.LineOffset = linOff + m.viewPos.Cursor = target + + return tea.Batch(func() tea.Msg { return ListChange{} }, func() tea.Msg { return CursorIndexChange(target) }) +} + +// Focus sets the list Model according to focus. +// If true the model accepts keypresses +func (m *Model) Focus(focus bool) { + m.focus = focus +} + +// Focused returns if the list Model is focused and accepts key presses +func (m *Model) Focused() bool { + return m.focus +} + +// GetIndex returns NotFound error if the Equals Method is not set (SetEquals) +// else it returns the index of the found item +func (m *Model) GetIndex(toSearch fmt.Stringer) (int, tea.Cmd) { + if m.equals == nil { + return -1, func() tea.Msg { return NotFound(fmt.Errorf("no equals function provided. Use SetEquals to set it")) } + } + tmpList := m.listItems + matchList := make([]chan bool, len(tmpList)) + equ := m.equals + + for i, item := range tmpList { + resChan := make(chan bool) + matchList[i] = resChan + go func(f, s fmt.Stringer, equ func(fmt.Stringer, fmt.Stringer) bool, res chan<- bool) { + res <- equ(f, s) + }(item.value, toSearch, equ, resChan) + } + + var c, lastIndex int + for i, resChan := range matchList { + if <-resChan { + c++ + lastIndex = i + } + } + if c > 1 { + // TODO performance: trust User and remove check for multiple matches? + return -c, func() tea.Msg { + return MultipleMatches(fmt.Errorf("The provided equals function yields multiple matches betwen one and other fmt.Stringer's")) + } + } + return lastIndex, nil +} + +// UpdateItem takes a index and updates the item at the index with the given function +// or if index outside the list returns OutOfBounds error. +// If the returned fmt.Stringer value is nil, then the item gets removed from the list. +// If you want to keep the list sorted run Sort() after updating a item. +// tea.Cmd contains the cmd returned by the updater and possible ListChange or CursorItemChange through tea.Cmd. +func (m *Model) UpdateItem(index int, updater func(fmt.Stringer) (fmt.Stringer, tea.Cmd)) tea.Cmd { + // TODO should UpdateItem accept a function which also returns a error, so that no new item is accepted? Returning the same item, if something goes wrong does not feel right... + index, err := m.ValidIndex(index) + if err != nil { + return func() tea.Msg { return err } + } + v, cmd := updater(m.listItems[index].value) + + cmd = tea.Batch(func() tea.Msg { return ListChange{} }, cmd) + if index == m.viewPos.Cursor { + cmd = tea.Batch(func() tea.Msg { return CursorItemChange{} }, cmd) + } + // remove item when value equals nil + if v == nil { + m.RemoveIndex(index) + return cmd + } + m.listItems[index].value = v + return cmd +} + +// GetCursorIndex returns the current cursor position within the List, +// and also NotFocused error through tea.Cmd if the Model is not focused, +// or a NoItems error if the list has no items on which the cursor could be. +func (m *Model) GetCursorIndex() (int, tea.Cmd) { + if m.Len() == 0 { + return 0, func() tea.Msg { return NoItems(fmt.Errorf("the list has no items on which the cursor could be")) } + } + if !m.focus { + return m.viewPos.Cursor, func() tea.Msg { return NotFocused(fmt.Errorf("Model is not focused")) } + } + return m.viewPos.Cursor, nil +} + +// GetCursorItem returns the item at the current cursor position within the List +// and also NotFocused error through tea.Cmd if the Model is not focused +// or a NoItems error if the list has no items on which the cursor could be. +func (m *Model) GetCursorItem() (fmt.Stringer, tea.Cmd) { + if m.Len() == 0 { + return nil, func() tea.Msg { return NoItems(fmt.Errorf("the list has no items on which the cursor could be")) } + } + if !m.focus { + return m.listItems[m.viewPos.Cursor].value, func() tea.Msg { return NotFocused(fmt.Errorf("Model is not focused")) } + } + return m.listItems[m.viewPos.Cursor].value, nil +} + +// GetItem returns the item if the index exists +// a error through tea.Cmd otherwise. +func (m *Model) GetItem(index int) (fmt.Stringer, tea.Cmd) { + index, err := m.ValidIndex(index) + if err != nil { + return nil, func() tea.Msg { return err } + } + return m.listItems[index].value, nil +} + +// GetAllItems returns all items in the list in current order +func (m *Model) GetAllItems() []fmt.Stringer { + list := m.listItems + stringerList := make([]fmt.Stringer, len(list)) + for i, item := range list { + stringerList[i] = item.value + } + return stringerList +} + +// Copy returns a deep copy of the list-model +func (m *Model) Copy() *Model { + copiedModel := &Model{} + *copiedModel = *m + return copiedModel +} + +// getID returns a new for this list unique id +// to identify the items and set the cursor after sorting correctly. +func (m *Model) getID() int { + if m.requestID == nil || m.resultID == nil { + req := make(chan struct{}) + res := make(chan int) + + m.requestID = req + m.resultID = res + + // the id '0' is skiped to be able to distinguish zero-value and proper id TODO is this a valid/good way to go? + go func(requ <-chan struct{}, send chan<- int) { + for c := 2; true; c++ { + _ = <-requ + send <- c + } + }(req, res) + + return 1 + } + var e struct{} + m.requestID <- e + return <-m.resultID +} diff --git a/list/list_test.go b/list/list_test.go new file mode 100644 index 000000000..dc31c1513 --- /dev/null +++ b/list/list_test.go @@ -0,0 +1,431 @@ +package list + +import ( + "fmt" + tea "github.com/charmbracelet/bubbletea" + "strings" + "testing" +) + +// TestLines test if the models Lines methode returns the write amount of lines +func TestEmptyLines(t *testing.T) { + m := NewModel() + cmd := m.Init() + if cmd != nil { + t.Error("Init should do nothing") // yet + } + m.Screen = ScreenInfo{Height: 50, Width: 80} + _, err := m.Lines() + if err == nil { + t.Error("A list with no entrys should return a error.") + } + m.Sort() + _, err = m.Lines() + if err == nil { + t.Error("A list with no entrys should return a error.") + } +} + +// TestBasicsLines test lines without linebreaks and with content shorter than the max content-width. +func TestBasicsLines(t *testing.T) { + m := NewModel() + m.Screen = ScreenInfo{Height: 50, Width: 80, Profile: 0} // No color + m.PrefixGen = NewPrefixer() + m.SuffixGen = NewSuffixer() + + m.Wrap = 1 + + // Check Cursor position + if i, err := m.GetCursorIndex(); i != 0 || err == nil { + t.Errorf("the cursor index of a new Model should be '0' and not: '%d' and there should be a error: %#v", i, err) + } + + // first two swaped + orgList := MakeStringerList([]string{"2", "1", "3", "4", "5", "6", "7", "8", "9"}) + m.AddItems(orgList) + + m.MoveCursor(1) + // Sort them + m.Sort() + // swap them again + m.MoveItem(1) + // should be the like the beginning + sortedItemList := m.GetAllItems() + + if len(orgList) != len(sortedItemList) { + t.Errorf("the list should not change size") + } + + // Process/check all orgList + for c, item := range orgList { + if item.String() != sortedItemList[c].String() { + t.Errorf("the old strings should match the new, but dont: %q, %q", item.String(), sortedItemList[c].String()) + } + } + + m.Top() + out, _ := m.Lines() + if len(out) > 50 { + t.Errorf("Lines should never have more (%d) lines than Screen has lines: %d", len(out), m.Screen.Height) + } + + light := "\x1b[7m" + cur := ">" + sep := "╭" + for i, line := range out { + // Check Prefixes + num := fmt.Sprintf("%d", i+1) + prefix := light + strings.Repeat(" ", 2-len(num)) + num + sep + cur + if !strings.HasPrefix(line, prefix) { + t.Errorf("The prefix of the line:\n%s\n with linenumber %d should be:\n%s\n", line, i, prefix) + } + cur = " " + sep = "├" + light = "" + } +} + +// TestWrappedLines test a simple case of many items with linebreaks. +func TestWrappedLines(t *testing.T) { + m := NewModel() + m.PrefixGen = NewPrefixer() + m.SuffixGen = NewSuffixer() + m.Screen = ScreenInfo{Height: 50, Width: 80} + m.AddItems(MakeStringerList([]string{"\n0", "1\n2", "3\n4", "5\n6", "7\n8"})) + + out, _ := m.Lines() + wrap, sep := "│", "├" + num := "\x1b[7m " + for i := 1; i < len(out); i++ { + line := out[i] + if i%2 == 0 { + num = fmt.Sprintf(" %1d", (i/2)+1) + } + if i%2 == 1 { + sep = wrap + } + prefix := fmt.Sprintf("%s%s %d", num, sep, i-1) + if !strings.HasPrefix(line, prefix) { + t.Errorf("The prefix of the line:\n'%s'\n with linenumber %d should be:\n'%s'\n", line, i, prefix) + } + num = " " + sep = "├" + } +} + +// TestMultiLineBreaks test one selected item +func TestMultiLineBreaks(t *testing.T) { + m := NewModel() + m.PrefixGen = NewPrefixer() + m.SuffixGen = NewSuffixer() + m.Screen = ScreenInfo{Height: 50, Width: 80} + m.AddItems(MakeStringerList([]string{"\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n"})) + out, _ := m.Lines() + prefix := "\x1b[7m 1╭>" + for i, line := range out { + if !strings.HasPrefix(line, prefix) { + t.Errorf("The prefix of the line:\n'%s'\n with linenumber %d should be:\n'%s'\n", line, i, prefix) + } + prefix = "\x1b[7m │ " + } +} + +// TestUpdateKeys test if the ctrl-c key send to the Update function work properly +func TestUpdateKeys(t *testing.T) { + m := NewModel() + m.Screen = ScreenInfo{Height: 50, Width: 80} + + // Quit massages + _, cmd := m.Update(tea.KeyMsg(tea.Key{Type: tea.KeyCtrlC})) + if cmd() != tea.Quit() { + t.Errorf("ctrl-c should result in Quit message, not into: %#v", cmd) + } +} + +// Movements +func TestMovementKeys(t *testing.T) { + m := NewModel() + m.Wrap = 1 + m.Screen = ScreenInfo{Height: 50, Width: 80} + m.AddItems(MakeStringerList([]string{"\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n"})) + + start, finish := 0, 1 + _, cmd := m.MoveCursor(1) + err, ok := cmd().(error) + if m.viewPos.Cursor != finish || err != nil { + t.Errorf("'MoveCursor(1)' should have nil error but got: '%#v' and move the Cursor to index '%d', but got: %d", err, finish, m.viewPos.Cursor) + } + start, finish = 15, 14 + m.viewPos.Cursor = start + _, cmd = m.MoveCursor(-1) + err, ok = cmd().(error) + if m.viewPos.Cursor != finish || err != nil { + t.Errorf("'MoveCursor(-1)' should have nil error but got: '%#v' and move the Cursor to index '%d', but got: %d", err, finish, m.viewPos.Cursor) + } + + start, finish = 55, 56 + m.viewPos.Cursor = start + cmd = m.MoveItem(1) + err, ok = cmd().(error) + if m.viewPos.Cursor != finish || err != nil { + t.Errorf("'MoveItem(1)' should have nil error but got: '%#v' and move the Cursor to index '%d', but got: %d", err, finish, m.viewPos.Cursor) + } + m.viewPos.LineOffset = 15 + start, finish = 15, 14 + m.viewPos.Cursor = start + cmd = m.MoveItem(-1) + err, ok = cmd().(error) + if m.viewPos.Cursor != finish || err != nil { + t.Errorf("'MoveItem(-1)' should have nil error but got: '%#v' and move the Cursor to index '%d', but got: %d", err, finish, m.viewPos.Cursor) + } + if m.viewPos.LineOffset != 14 { + t.Errorf("up movement should change the Item offset to '14' but got: %d", m.viewPos.LineOffset) + } + finish = m.Len() - 1 + cmd = m.Bottom() + err, ok = cmd().(error) + if m.viewPos.Cursor != finish || err != nil { + t.Errorf("'Bottom()' should have nil error but got: '%#v' and move the Cursor to last index: '%d', but got: %d", err, m.Len()-1, m.viewPos.Cursor) + } + finish = 0 + m.viewPos.Cursor = start + cmd = m.Top() + err, ok = cmd().(error) + if m.viewPos.Cursor != finish || err != nil { + t.Errorf("'Top()' should have nil error but got: '%#v' and move the Cursor to index '%d', but got: %d", err, finish, m.viewPos.Cursor) + } + _, cmd = m.SetCursor(10) + err, ok = cmd().(error) + if m.viewPos.Cursor != 10 || ok && err != nil { + t.Errorf("SetCursor should set the cursor to index '10' but gut '%d' and err should be nil but got '%s'", m.viewPos.Cursor, err) + } +} + +// WindowMsg +func TestWindowMsg(t *testing.T) { + m := NewModel() + + newModel, cmd := m.Update(tea.WindowSizeMsg{Width: 80, Height: 50}) + m, _ = newModel.(Model) + + // Because within the Update the termenv.Profile will be set, when reciving the Windowszie, depending on currently running terminal + // we overwrite it her to have a reproduceable test-result + m.Screen.Profile = 0 + + if cmd != nil { + t.Errorf("comand should be nil and not: '%#v'", cmd) + } + soll := ScreenInfo{Width: 80, Height: 50} + if m.Screen != soll { + t.Errorf("Screen should be %#v and not: %#v", soll, m.Screen) + } + +} + +// TestUnfocused should make sure that the update does not change anything if model is not focused +func TestUnfocused(t *testing.T) { + m := NewModel() + m.Focus(true) + if !m.Focused() { + t.Error("model should be focused but isn't") + } + m.Focus(false) + // Check Cursor position + if i, err := m.GetCursorIndex(); i != 0 || err == nil { + t.Errorf("the cursor index of a new Model should be '0' and not: '%d' and there should be a NotFocused error: %#v", i, err) + } + + newModel, cmd := m.Update(tea.KeyMsg(tea.Key{Type: tea.KeyRunes, Runes: []rune{'j'}})) + oldM := fmt.Sprintf("%#v", newModel) + newM := fmt.Sprintf("%#v", m) + if oldM != newM || cmd != nil { + t.Errorf("Update changes unfocused Model form:\n%#v\nto:\n%#v or returns a not nil command: %#v", oldM, newM, cmd) + } +} + +// TestGetIndex sets a equals function and searches After the index of a specific item with GetIndex +func TestGetIndex(t *testing.T) { + m := NewModel() + _, cmd := m.GetIndex(StringItem("z")) + err, ok := cmd().(error) + if !ok || err == nil { + t.Errorf("Get Index should return a error but got nil") + } + m.AddItems(MakeStringerList([]string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z"})) + m.SetEquals(func(a, b fmt.Stringer) bool { return a.String() == b.String() }) + index, cmd := m.GetIndex(StringItem("z")) + if cmd != nil { + t.Errorf("GetIndex should not return a command: %s", err) + } + if index != m.Len()-1 { + t.Errorf("GetIndex returns wrong index: '%d' instead of '%d'", index, m.Len()-1) + } +} + +// TestWithinBorder test if indexes are within the listborders +func TestWithinBorder(t *testing.T) { + m := NewModel() + _, err := m.ValidIndex(0) + if _, ok := err.(NoItems); !ok { + t.Errorf("a empty list has no item '0', should return a NoItems error, but got: %#v", err) + } +} + +// TestCopy test if if Copy returns a deep copy +func TestCopy(t *testing.T) { + org := NewModel() + sec := org.Copy() + + org.SetLess(func(a, b fmt.Stringer) bool { return a.String() < b.String() }) + + if &org == sec { + t.Errorf("Copy should return a deep copy but has the same pointer:\norginal: '%p', copy: '%p'", &org, sec) + } + + if org.focus != sec.focus || + fmt.Sprintf("%#v", org.listItems) != fmt.Sprintf("%#v", sec.listItems) || + + // All should be the same except the changed less function + fmt.Sprintf("%p", org.less) == fmt.Sprintf("%p", sec.less) || + fmt.Sprintf("%p", org.equals) != fmt.Sprintf("%p", sec.equals) || + + fmt.Sprintf("%#v", org.CursorOffset) != fmt.Sprintf("%#v", sec.CursorOffset) || + + fmt.Sprintf("%#v", org.Screen) != fmt.Sprintf("%#v", sec.Screen) || + fmt.Sprintf("%#v", org.viewPos) != fmt.Sprintf("%#v", sec.viewPos) || + + fmt.Sprintf("%#v", org.Wrap) != fmt.Sprintf("%#v", sec.Wrap) || + + fmt.Sprintf("%#v", org.PrefixGen) != fmt.Sprintf("%#v", sec.PrefixGen) || + fmt.Sprintf("%#v", org.SuffixGen) != fmt.Sprintf("%#v", sec.SuffixGen) || + + fmt.Sprintf("%#v", org.LineStyle) != fmt.Sprintf("%#v", sec.LineStyle) || + fmt.Sprintf("%#v", org.CurrentStyle) != fmt.Sprintf("%#v", sec.CurrentStyle) { + + t.Errorf("Copy should have same string repesentation except different less function pointer:\n orginal: '%#v'\n copy: '%#v'", org, sec) + } +} + +// TestSetCursor tests if the LineOffset and Cursor positions are correct +func TestSetCursor(t *testing.T) { + m := NewModel() + m.Screen = ScreenInfo{Height: 50, Width: 80} + m.AddItems(MakeStringerList([]string{"\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n", ""})) + type test struct { + oldView ViewPos + target int + newView ViewPos + } + toTest := []test{ + // forwards + {ViewPos{0, 0}, -2, ViewPos{0, 0}}, // wrong request -> no change + {ViewPos{0, 0}, 2, ViewPos{5, 2}}, + {ViewPos{0, 4}, 8, ViewPos{8, 8}}, + {ViewPos{0, 5}, 0, ViewPos{5, 0}}, + {ViewPos{0, 0}, 19, ViewPos{38, 19}}, + {ViewPos{0, 0}, 25, ViewPos{44, 25}}, + {ViewPos{0, 0}, 100, ViewPos{0, 0}}, // wrong request -> no change + // backwards + {ViewPos{45, m.Len() - 1}, -2, ViewPos{45, m.Len() - 1}}, // wrong request -> no change + {ViewPos{45, m.Len() - 1}, 2, ViewPos{5, 2}}, + {ViewPos{45, m.Len() - 1}, 8, ViewPos{5, 8}}, + {ViewPos{45, m.Len() - 1}, 0, ViewPos{5, 0}}, + {ViewPos{45, m.Len() - 1}, 19, ViewPos{5, 19}}, + {ViewPos{45, m.Len() - 1}, 25, ViewPos{5, 25}}, + {ViewPos{45, m.Len() - 1}, 100, ViewPos{45, m.Len() - 1}}, // wrong request -> no change + } + for i, tCase := range toTest { + m.viewPos = tCase.oldView + m.SetCursor(tCase.target) + if m.viewPos != tCase.newView { + t.Errorf("In Test number: %d, the returned ViewPos is wrong:\n'%#v' and should be:\n'%#v' after requesting target: %d", i, m.viewPos, tCase.newView, tCase.target) + } + } +} + +// TestMoveItem test wrong arguments +func TestMoveItem(t *testing.T) { + m := NewModel() + cmd := m.MoveItem(0) + err, ok := cmd().(OutOfBounds) + if !ok { + t.Errorf("MoveItem called on a empty list should return a OutOfBounds error, but got: %s", err) + } + m.AddItems(MakeStringerList([]string{""})) + cmd = m.MoveItem(0) + err, ok = cmd().(error) + if ok && err != nil { + t.Errorf("MoveItem(0) should not return a error on a not empty list, but got '%s'", err) + } + cmd = m.MoveItem(1) + err, ok = cmd().(OutOfBounds) + if !ok { + t.Errorf("MoveItem should return a OutOfBounds error if traget is beyond list border, but got: '%s'", err) + } +} + +// TestView tests if View returns a String (of a returned lines) +func TestView(t *testing.T) { + m := NewModel() + if m.View() == "" { + t.Error("View should never return a empty string since this does not update the screen") // TODO changed this in bubbletea + } + if _, err := m.Lines(); err != nil && m.View() != err.Error() { + t.Error("if Lines returnes a error View should return the error string") + } + testStr := "test" + m.AddItems(MakeStringerList([]string{testStr, testStr})) + m.SetCursor(1) + if _, err := m.Lines(); err == nil { + t.Error("a none empty list should return a error when the screen is to small to displax anything") + } + m.Screen.Height = 10 + m.Screen.Width = 100 + if _, err := m.Lines(); err != nil || !strings.Contains(m.View(), testStr) { + t.Errorf("a none empty list should not return a error but got:\n%sand the content should be within the returned string from View:\n%s", err, m.View()) + } +} + +// TestRemoveIndex test if the item at the index was removed +func TestRemoveIndex(t *testing.T) { + m := NewModel() + item, cmd := m.RemoveIndex(0) + if _, ok := cmd().(error); item != nil && ok { + t.Error("RemoveIndex should return a error and a nil value when the index is not valid") + } + testStr := "test" + m.AddItems(MakeStringerList([]string{testStr})) + item, cmd = m.RemoveIndex(0) + if _, ok := cmd().(error); item.String() != testStr && !ok && m.Len() != 0 { + t.Error("RemoveIndex should return no error and the corresponding string value when the index is valid") + } +} + +// TestResetItems test if list is replaced +func TestResetItems(t *testing.T) { + m := NewModel() + testStr := "test" + m.AddItems(MakeStringerList([]string{testStr})) + secondStr := "replaced" + m.ResetItems(MakeStringerList([]string{secondStr})) + if item, cmd := m.RemoveIndex(0); item.String() != secondStr || cmd == nil || m.Len() > 1 { + t.Error("ResetItems should return a command and the list should be replaced") + } +} + +// TestUpdateItem +func TestUpdateItem(t *testing.T) { + m := NewModel() + testStr := "test" + m.AddItems(MakeStringerList([]string{testStr})) + m.UpdateItem(0, func(fmt.Stringer) (fmt.Stringer, tea.Cmd) { return nil, nil }) + if item, cmd := m.RemoveIndex(0); item != nil || cmd == nil { + t.Error("UpdateItem should return a command and the item should be deleted if the returned Stringer is nil") + } + m.AddItems(MakeStringerList([]string{testStr})) + secondStr := "replaced" + m.UpdateItem(0, func(fmt.Stringer) (fmt.Stringer, tea.Cmd) { return StringItem(secondStr), nil }) + if item, cmd := m.RemoveIndex(0); item.String() != secondStr || cmd == nil { + t.Error("UpdateItem should return a command and the item should be replaced") + } +} diff --git a/list/prefixer.go b/list/prefixer.go new file mode 100644 index 000000000..3d7960e41 --- /dev/null +++ b/list/prefixer.go @@ -0,0 +1,160 @@ +package list + +import ( + "fmt" + "github.com/muesli/reflow/ansi" + "strings" +) + +// Prefixer is used to prefix all visible Lines. +// Init gets called ones on the beginning of the Lines methode +// and then Prefix ones, per line to draw, to generate according prefixes. +type Prefixer interface { + InitPrefixer(currentItem fmt.Stringer, currentItemIndex int, viewPos ViewPos, screenInfo ScreenInfo) int + Prefix(currentLine, allLines int) string +} + +// DefaultPrefixer is the default struct used for Prefixing a line +type DefaultPrefixer struct { + PrefixWrap bool + + // Make clear where a item begins and where it ends + FirstSep string + Seperator string + SeperatorWrap string + + // Mark it so that even without color support all is explicit + CurrentMarker string + + // enable Linenumber + Number bool + NumberRelative bool + + prefixWidth int + viewPos ViewPos + + markWidth int + numWidth int + + unmark string + mark string + + sepItem string + sepWrap string + + currentIndex int +} + +// NewPrefixer returns a DefautPrefixer with default values +func NewPrefixer() *DefaultPrefixer { + return &DefaultPrefixer{ + PrefixWrap: true, + + // Make clear where a item begins and where it ends + FirstSep: "╭", + Seperator: "├", + SeperatorWrap: "│", + + // Mark it so that even without color support all is explicit + CurrentMarker: ">", + + // enable Linenumber + Number: true, + NumberRelative: false, + } +} + +// InitPrefixer sets up all strings used to prefix a given line later by Prefix() +func (d *DefaultPrefixer) InitPrefixer(value fmt.Stringer, currentItemIndex int, position ViewPos, screen ScreenInfo) int { + // TODO adapt to per item call + d.currentIndex = currentItemIndex + d.viewPos = position + + offset := position.Cursor - position.LineOffset + if offset < 0 { + offset = 0 + } + seperator := d.Seperator + if currentItemIndex == 0 { + seperator = d.FirstSep + } + + // Get separators width + widthItem := ansi.PrintableRuneWidth(seperator) + widthWrap := ansi.PrintableRuneWidth(d.SeperatorWrap) + + // Find max width + sepWidth := widthItem + if widthWrap > sepWidth { + sepWidth = widthWrap + } + + // get widest possible number, for padding + // TODO handle wrap, cause only correct when wrap off: + d.numWidth = len(fmt.Sprintf("%d", offset+screen.Height)) + + // pad all prefixes to the same width for easy exchange + // pad all separators to the same width for easy exchange + d.sepItem = strings.Repeat(" ", sepWidth-widthItem) + seperator + d.sepWrap = strings.Repeat(" ", sepWidth-widthWrap) + d.SeperatorWrap + + // pad right of prefix, with length of current pointer + d.mark = d.CurrentMarker + d.markWidth = ansi.PrintableRuneWidth(d.mark) + d.unmark = strings.Repeat(" ", d.markWidth) + + // Get the hole prefix width + d.prefixWidth = d.numWidth + sepWidth + d.markWidth + + return d.prefixWidth +} + +// Prefix prefixes a given line +func (d *DefaultPrefixer) Prefix(lineIndex, allLines int) string { + // if a number is set, prepend first line with number and both with enough spaces + firstPad := strings.Repeat(" ", d.numWidth) + var wrapPad string + var lineNum int + if d.Number { + lineNum = lineNumber(d.NumberRelative, d.viewPos.Cursor, d.currentIndex) + } + number := fmt.Sprintf("%d", lineNum) + // since digits are only single bytes, len is sufficient: + padTo := d.numWidth - len(number) + if padTo < 0 { + // TODO log error + padTo = 0 + } + firstPad = strings.Repeat(" ", padTo) + number + // pad wrapped lines + wrapPad = strings.Repeat(" ", d.numWidth) + + // Current: handle highlighting of current item/first-line + curPad := d.unmark + if d.currentIndex == d.viewPos.Cursor { + curPad = d.mark + } + + // join all prefixes + linePrefix := strings.Join([]string{firstPad, d.sepItem, curPad}, "") + if lineIndex > 0 { + linePrefix = strings.Join([]string{wrapPad, d.sepWrap, d.unmark}, "") // don't prefix wrap lines with CurrentMarker (unmark) + } + + return linePrefix +} + +// lineNumber returns line number of the given index +// and if relative is true the absolute difference to the cursor +// or if on the cursor the absolute line number +func lineNumber(relativ bool, curser, current int) int { + if !relativ || curser == current { + return current + 1 + } + + diff := curser - current + if diff < 0 { + diff *= -1 + } + return diff +} diff --git a/list/suffixer.go b/list/suffixer.go new file mode 100644 index 000000000..7c96cd0e3 --- /dev/null +++ b/list/suffixer.go @@ -0,0 +1,47 @@ +package list + +import ( + "fmt" + "github.com/muesli/reflow/ansi" +) + +// Suffixer is used to suffix all visible Lines. +// InitSuffixer gets called ones on the beginning of the Lines method +// and then Suffix ones, per line to draw, to generate according suffixes. +type Suffixer interface { + InitSuffixer(item fmt.Stringer, currentIndex int, viewPos ViewPos, screenInfo ScreenInfo) int + Suffix(currentLine, allLines int) string +} + +// DefaultSuffixer is more a example than a default but still it highlights +// the usage and the line. Also if used the line gets padded to the List Width +// So that it can be horizontally joined with other strings/Views. +type DefaultSuffixer struct { + viewPos ViewPos + currentMarker string + markerLenght int + item int +} + +// NewSuffixer returns a simple suffixer +func NewSuffixer() *DefaultSuffixer { + return &DefaultSuffixer{currentMarker: "<"} +} + +// InitSuffixer returns the visible Width of the strings used to suffix the lines +func (e *DefaultSuffixer) InitSuffixer(_ fmt.Stringer, currentItemIndex int, viewPos ViewPos, screen ScreenInfo) int { + e.item = currentItemIndex + e.viewPos = viewPos + e.markerLenght = ansi.PrintableRuneWidth(e.currentMarker) + return e.markerLenght +} + +// Suffix returns a suffix string for the given line +func (e *DefaultSuffixer) Suffix(line, allLines int) string { + if e.item == e.viewPos.Cursor && line == 0 { + return e.currentMarker + } + // a line with a empty suffix string becomes not padded with spaces + // so if you want to have everything padded to the list-width, return a space. + return "" +}