From 0ab2af9c6088827e076a148729af924b639ccd3d Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Wed, 4 Nov 2020 10:27:41 +0100 Subject: [PATCH 01/73] started implementing list Module --- go.mod | 1 + go.sum | 2 ++ list/list.go | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+) create mode 100644 list/list.go diff --git a/go.mod b/go.mod index 84c87c821..6cf5f8761 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/atotto/clipboard v0.1.2 github.com/charmbracelet/bubbletea v0.12.2 github.com/mattn/go-runewidth v0.0.9 + github.com/muesli/reflow v0.2.0 github.com/muesli/termenv v0.7.4 golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897 // indirect golang.org/x/sys v0.0.0-20201020230747-6e5568b54d1a // indirect diff --git a/go.sum b/go.sum index a1c370bb7..d16a38795 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,8 @@ github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHX github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= 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.2.0 h1:2o0UBJPHHH4fa2GCXU4Rg4DwOtWPMekCeyc5EWbAQp0= +github.com/muesli/reflow v0.2.0/go.mod h1:qT22vjVmM9MIUeLgsVYe/Ye7eZlbv9dZjL3dVhUqLX8= 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= diff --git a/list/list.go b/list/list.go new file mode 100644 index 000000000..37cc7b34c --- /dev/null +++ b/list/list.go @@ -0,0 +1,74 @@ +package list + +import ( + "bytes" + "fmt" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + runewidth "github.com/mattn/go-runewidth" + "github.com/muesli/reflow/wordwrap" + "strings" +) + +// Model is a bubbletea List of strings with get wraped +type Model struct { + seperator string + listItems []listItem + relativeNumber bool + absoluteNumber bool + curIndex int + visibleItems []listItem + viewport viewport.Model + visibleOffset int +} + +type listItem struct { + selected bool + content string +} + +// View renders the Lst to a (displayable) string +func (m *Model) View() string { + width := m.viewport.Width + max := m.viewport.Height + if m.curIndex > max { + max = m.curIndex + } + + padTo := runewidth.StringWidth(fmt.Sprintf("%d", max)) + sep := runewidth.StringWidth(fmt.Sprintf(m.seperator)) + + contentWidth := width - (padTo + sep) + if contentWidth <= 0 { + panic("Can't display with zero width for content") + } + var holeString bytes.Buffer + for index, item := range m.visibleItems { + content := wordwrap.String(item.content, contentWidth) + contentLines := strings.SplitN(content, "\n", 1) //split into first line and the rest + holeString.WriteString(fmt.Sprintf("%"+fmt.Sprint(padTo)+"d"+m.seperator, m.visibleOffset+index)) // prepend firstline with linenumber + holeString.WriteString(contentLines[0]) //write firstline + if len(contentLines) == 1 { + continue + } + holeString.WriteString(strings.ReplaceAll(contentLines[1], "\n", "\n"+strings.Repeat(" ", padTo)+m.seperator)) // Pad all remaning lines + + } + return holeString.String() +} + +// Update changes the Model of the List according to the messages recieved +func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + + } + } + return m, nil +} + +// Init does nothing +func (m Model) Init() tea.Cmd { + return nil +} From 400d07bfe86aa62628216ea3d778d072c8c24d2e Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Wed, 4 Nov 2020 10:31:36 +0100 Subject: [PATCH 02/73] wrote line wraping sorted struct files renamed item struct started to add some color rendering all items added end of line to write lines in to new line of list added setter // QUEST or should the fileds be public? --- list/list.go | 117 +++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 99 insertions(+), 18 deletions(-) diff --git a/list/list.go b/list/list.go index 37cc7b34c..739cba126 100644 --- a/list/list.go +++ b/list/list.go @@ -7,30 +7,45 @@ import ( tea "github.com/charmbracelet/bubbletea" runewidth "github.com/mattn/go-runewidth" "github.com/muesli/reflow/wordwrap" + "github.com/muesli/termenv" "strings" ) -// Model is a bubbletea List of strings with get wraped +// Model is a bubbletea List of strings type Model struct { + listItems []item + curIndex int + visibleItems []item + visibleOffset int + + Viewport viewport.Model + wrap bool + seperator string - listItems []listItem relativeNumber bool absoluteNumber bool - curIndex int - visibleItems []listItem - viewport viewport.Model - visibleOffset int + + jump int + + LineForeGroundColor string + LineBackGroundColor string + SelectedForeGroundColor string + SelectedBackGroundColor string } -type listItem struct { +// Item are Items used in the list Model +// to hold the Content representat as a string +type item struct { selected bool content string } // View renders the Lst to a (displayable) string func (m *Model) View() string { - width := m.viewport.Width - max := m.viewport.Height + width := m.Viewport.Width + + // padding for the right amount of numbers + max := m.Viewport.Height if m.curIndex > max { max = m.curIndex } @@ -40,18 +55,51 @@ func (m *Model) View() string { contentWidth := width - (padTo + sep) if contentWidth <= 0 { - panic("Can't display with zero width for content") + panic("Can't display width zero width for content") } + + p := termenv.ColorProfile() + + var visLines int var holeString bytes.Buffer - for index, item := range m.visibleItems { - content := wordwrap.String(item.content, contentWidth) - contentLines := strings.SplitN(content, "\n", 1) //split into first line and the rest - holeString.WriteString(fmt.Sprintf("%"+fmt.Sprint(padTo)+"d"+m.seperator, m.visibleOffset+index)) // prepend firstline with linenumber - holeString.WriteString(contentLines[0]) //write firstline - if len(contentLines) == 1 { +out: + for index, item := range m.listItems { + //handel highlighting of current or selected lines + colored := termenv.String() + if item.selected { + colored.Background(p.Color("ff1111")) + } + if index+m.visibleOffset == m.curIndex { + colored.Reverse() + } + contentLines := strings.Split(wordwrap.String(colored.Styled(item.content), contentWidth), "\n") // QUEST why does colored.Styled needs the argument? + + // is set prepend firstline with linenumber + if m.absoluteNumber || m.relativeNumber { + holeString.WriteString(fmt.Sprintf("%"+fmt.Sprint(padTo)+"d"+m.seperator, m.visibleOffset+index)) + } + // Only handel lines that are visible + if visLines+len(contentLines) >= m.Viewport.Height { + break out + } + // Write first line + holeString.WriteString(contentLines[0]) + holeString.WriteString("\n") + + visLines++ + if len(contentLines) == 1 || !m.wrap { continue } - holeString.WriteString(strings.ReplaceAll(contentLines[1], "\n", "\n"+strings.Repeat(" ", padTo)+m.seperator)) // Pad all remaning lines + // Write wraped lines + for _, line := range contentLines[1:] { + holeString.WriteString("\n" + strings.Repeat(" ", padTo) + m.seperator) // Pad line + holeString.WriteString(line) // write line + visLines++ + // Only write lines that are visible + if visLines >= m.Viewport.Height { + break out + } + } } return holeString.String() @@ -62,9 +110,20 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { - + case "down": + if m.jump > 0 { + m.curIndex -= m.jump + m.jump = 0 // TODO check if this realy resets jump (if m is a pointer) likely pointer + } else { + m.curIndex-- + } + case " ": + m.listItems[m.curIndex].selected = !m.listItems[m.curIndex].selected } } + if m.Viewport.Height >= len(m.listItems) { + m.visibleItems = m.listItems + } return m, nil } @@ -72,3 +131,25 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Model) Init() tea.Cmd { return nil } + +// AddItems addes the given Items to the list Model +// Without performing updating the View TODO +func (m *Model) AddItems(itemList []string) { + for _, i := range itemList { + m.listItems = append(m.listItems, item{ + selected: false, + content: i}, + ) + } +} + +// SetAbsNumber sets if absolute Linenumbers should be displayed +func (m *Model) SetAbsNumber(setTo bool) { + m.absoluteNumber = setTo +} + +// SetSeperator sets the seperator string +// between left border and the content of the line +func (m *Model) SetSeperator(sep string) { + m.seperator = sep +} From 5edffcb7896b69af28bba0870a996275496f8518 Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Wed, 4 Nov 2020 10:31:38 +0100 Subject: [PATCH 03/73] Handled color of current and selected lines added Methodes for movement and selecting items changed seperator fixed wrap continue bug --- list/list.go | 58 +++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 48 insertions(+), 10 deletions(-) diff --git a/list/list.go b/list/list.go index 739cba126..1213df34f 100644 --- a/list/list.go +++ b/list/list.go @@ -13,6 +13,8 @@ import ( // Model is a bubbletea List of strings type Model struct { + focus bool + listItems []item curIndex int visibleItems []item @@ -22,6 +24,7 @@ type Model struct { wrap bool seperator string + seperatorWrap string relativeNumber bool absoluteNumber bool @@ -55,45 +58,55 @@ func (m *Model) View() string { contentWidth := width - (padTo + sep) if contentWidth <= 0 { - panic("Can't display width zero width for content") + panic("Can't display with zero width for content") } + m.seperator = " ╭ " + m.seperatorWrap = " │ " + p := termenv.ColorProfile() var visLines int var holeString bytes.Buffer out: + // Handle list items for index, item := range m.listItems { - //handel highlighting of current or selected lines + sep := m.seperator + // handel highlighting of current or selected lines colored := termenv.String() if item.selected { - colored.Background(p.Color("ff1111")) + colored = colored.Background(p.Color("#ff0000")) } if index+m.visibleOffset == m.curIndex { - colored.Reverse() + colored = colored.Reverse() + sep = " ╭>" } - contentLines := strings.Split(wordwrap.String(colored.Styled(item.content), contentWidth), "\n") // QUEST why does colored.Styled needs the argument? + contentLines := strings.Split(wordwrap.String(colored.Styled(item.content), contentWidth), "\n") - // is set prepend firstline with linenumber + var firstPad string + // if set prepend firstline with linenumber if m.absoluteNumber || m.relativeNumber { - holeString.WriteString(fmt.Sprintf("%"+fmt.Sprint(padTo)+"d"+m.seperator, m.visibleOffset+index)) + firstPad = colored.Styled(fmt.Sprintf("%"+fmt.Sprint(padTo)+"d"+sep, m.visibleOffset+index)) } // Only handel lines that are visible if visLines+len(contentLines) >= m.Viewport.Height { break out } // Write first line + holeString.WriteString(firstPad) holeString.WriteString(contentLines[0]) holeString.WriteString("\n") visLines++ - if len(contentLines) == 1 || !m.wrap { + if len(contentLines) == 1 || m.wrap { continue } + // Write wraped lines for _, line := range contentLines[1:] { - holeString.WriteString("\n" + strings.Repeat(" ", padTo) + m.seperator) // Pad line - holeString.WriteString(line) // write line + holeString.WriteString(strings.Repeat(" ", padTo) + m.seperatorWrap) // Pad line // TODO test seperator width + holeString.WriteString(line) // write line + holeString.WriteString("\n") // Write end of line visLines++ // Only write lines that are visible if visLines >= m.Viewport.Height { @@ -153,3 +166,28 @@ func (m *Model) SetAbsNumber(setTo bool) { func (m *Model) SetSeperator(sep string) { m.seperator = sep } + +// Down moves the "cursor" or current line down. +// If the end is allready reached err is not nil. +func (m *Model) Down() error { + if m.curIndex > len(m.listItems) { + return fmt.Errorf("Can't go beyond last item") + } + m.curIndex++ + return nil +} + +// Up moves the "cursor" or current line up. +// If the start is allready reached err is not nil. +func (m *Model) Up() error { + if m.curIndex <= 0 { + return fmt.Errorf("Can't go infront of first item") + } + m.curIndex-- + return nil +} + +// ToggleSelect toggles the selected status of the current Index +func (m *Model) ToggleSelect() { + m.listItems[m.curIndex].selected = !m.listItems[m.curIndex].selected +} From c2eee8b053dd8e8a4465a7a99be2ccfe489356e5 Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Wed, 4 Nov 2020 10:31:38 +0100 Subject: [PATCH 04/73] added lineCurserOffset to keep the current line within the borders fixed line padding errors added NewModel function to aggregate defaults Implemented (new) line movment functions better to keeping curser inside the visible range --- list/list.go | 78 ++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 67 insertions(+), 11 deletions(-) diff --git a/list/list.go b/list/list.go index 1213df34f..f4f0a3055 100644 --- a/list/list.go +++ b/list/list.go @@ -19,6 +19,7 @@ type Model struct { curIndex int visibleItems []item visibleOffset int + lineCurserOffset int Viewport viewport.Model wrap bool @@ -48,9 +49,10 @@ func (m *Model) View() string { width := m.Viewport.Width // padding for the right amount of numbers - max := m.Viewport.Height - if m.curIndex > max { - max = m.curIndex + max := m.Viewport.Height // relativ + abs := m.visibleOffset+m.Viewport.Height-1 // absolute + if abs > max { + max = abs } padTo := runewidth.StringWidth(fmt.Sprintf("%d", max)) @@ -61,8 +63,21 @@ func (m *Model) View() string { panic("Can't display with zero width for content") } - m.seperator = " ╭ " - m.seperatorWrap = " │ " + // set Visible lines + if len(m.listItems) <= m.Viewport.Height { + m.visibleItems = m.listItems + } else { + begin := m.visibleOffset + if begin < 0 { + begin = 0 + } + end := m.visibleOffset+m.Viewport.Height + lenght := len(m.listItems) + if end > lenght { + end = len(m.listItems) + } + m.visibleItems = m.listItems[begin:end] + } p := termenv.ColorProfile() @@ -70,12 +85,12 @@ func (m *Model) View() string { var holeString bytes.Buffer out: // Handle list items - for index, item := range m.listItems { + for index, item := range m.visibleItems { sep := m.seperator // handel highlighting of current or selected lines colored := termenv.String() if item.selected { - colored = colored.Background(p.Color("#ff0000")) + colored = colored.Background(p.Color(m.SelectedBackGroundColor)) } if index+m.visibleOffset == m.curIndex { colored = colored.Reverse() @@ -134,9 +149,6 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.listItems[m.curIndex].selected = !m.listItems[m.curIndex].selected } } - if m.Viewport.Height >= len(m.listItems) { - m.visibleItems = m.listItems - } return m, nil } @@ -170,10 +182,17 @@ func (m *Model) SetSeperator(sep string) { // Down moves the "cursor" or current line down. // If the end is allready reached err is not nil. func (m *Model) Down() error { - if m.curIndex > len(m.listItems) { + length := len(m.listItems)-1 + if m.curIndex >= length { + m.curIndex = length return fmt.Errorf("Can't go beyond last item") } m.curIndex++ + // move visible part of list if Curser is going beyond border. + lowerBorder := m.Viewport.Height+m.visibleOffset-m.lineCurserOffset + if m.curIndex >= lowerBorder { + m.visibleOffset++ + } return nil } @@ -181,9 +200,15 @@ func (m *Model) Down() error { // If the start is allready reached err is not nil. func (m *Model) Up() error { if m.curIndex <= 0 { + m.curIndex = 0 return fmt.Errorf("Can't go infront of first item") } m.curIndex-- + // move visible part of list if Curser is going beyond border. + upperBorder := m.visibleOffset+m.lineCurserOffset + if m.visibleOffset > 0 && m.curIndex <= upperBorder { + m.visibleOffset-- + } return nil } @@ -191,3 +216,34 @@ func (m *Model) Up() error { func (m *Model) ToggleSelect() { m.listItems[m.curIndex].selected = !m.listItems[m.curIndex].selected } + +// NewModel returns a Model with some save/sane defaults +func NewModel() Model { + return Model{ + lineCurserOffset: 5, + + wrap: true, + + seperator: " ╭ ", + seperatorWrap: " │ ", + absoluteNumber: true, + + SelectedBackGroundColor: "#ff0000", + } +} + +// Top moves the cursor to the first line +func (m *Model) Top() { + m.visibleOffset = 0 + m.visibleItems = m.listItems[0:m.Viewport.Height] + m.curIndex = 0 +} + +// Bottom moves the cursor to the first line +func (m *Model) Bottom() { + visLines := m.Viewport.Height-m.lineCurserOffset + start :=len(m.listItems)-visLines// FIXME acount for wraped lines + m.visibleOffset = start + m.visibleItems = m.listItems[start:start+visLines] + m.curIndex = len(m.listItems)-1 +} From 7df79da6dc3ab29b863af034725001a99eb18559 Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Wed, 4 Nov 2020 10:31:39 +0100 Subject: [PATCH 05/73] added left padding of seperators and removed unneccesary if statement --- list/list.go | 91 +++++++++++++++++++++++++++++----------------------- 1 file changed, 51 insertions(+), 40 deletions(-) diff --git a/list/list.go b/list/list.go index f4f0a3055..032963d59 100644 --- a/list/list.go +++ b/list/list.go @@ -15,19 +15,20 @@ import ( type Model struct { focus bool - listItems []item - curIndex int - visibleItems []item - visibleOffset int + listItems []item + curIndex int + visibleItems []item + visibleOffset int lineCurserOffset int Viewport viewport.Model wrap bool - seperator string - seperatorWrap string - relativeNumber bool - absoluteNumber bool + seperator string + seperatorWrap string + currentSeperator string + relativeNumber bool + absoluteNumber bool jump int @@ -49,35 +50,31 @@ func (m *Model) View() string { width := m.Viewport.Width // padding for the right amount of numbers - max := m.Viewport.Height // relativ - abs := m.visibleOffset+m.Viewport.Height-1 // absolute + max := m.Viewport.Height // relativ + abs := m.visibleOffset + m.Viewport.Height - 1 // absolute if abs > max { max = abs } - padTo := runewidth.StringWidth(fmt.Sprintf("%d", max)) - sep := runewidth.StringWidth(fmt.Sprintf(m.seperator)) + sep := maxRuneWidth(m.seperator, m.seperatorWrap, m.currentSeperator) - contentWidth := width - (padTo + sep) + // Check if there is space for the content left + contentWidth := m.Viewport.Width - (padTo + sep) if contentWidth <= 0 { panic("Can't display with zero width for content") } - // set Visible lines - if len(m.listItems) <= m.Viewport.Height { - m.visibleItems = m.listItems - } else { - begin := m.visibleOffset - if begin < 0 { - begin = 0 - } - end := m.visibleOffset+m.Viewport.Height - lenght := len(m.listItems) - if end > lenght { - end = len(m.listItems) - } - m.visibleItems = m.listItems[begin:end] + // Set Visible lines + begin := m.visibleOffset + if begin < 0 { + begin = 0 + } + end := m.visibleOffset + m.Viewport.Height + lenght := len(m.listItems) + if end > lenght { + end = len(m.listItems) } + m.visibleItems = m.listItems[begin:end] p := termenv.ColorProfile() @@ -86,7 +83,7 @@ func (m *Model) View() string { out: // Handle list items for index, item := range m.visibleItems { - sep := m.seperator + sepString := m.seperator // handel highlighting of current or selected lines colored := termenv.String() if item.selected { @@ -94,14 +91,14 @@ out: } if index+m.visibleOffset == m.curIndex { colored = colored.Reverse() - sep = " ╭>" + sepString = m.currentSeperator } contentLines := strings.Split(wordwrap.String(colored.Styled(item.content), contentWidth), "\n") var firstPad string // if set prepend firstline with linenumber if m.absoluteNumber || m.relativeNumber { - firstPad = colored.Styled(fmt.Sprintf("%"+fmt.Sprint(padTo)+"d"+sep, m.visibleOffset+index)) + firstPad = colored.Styled(fmt.Sprintf("%"+fmt.Sprint(padTo)+"d%"+fmt.Sprint(sep)+"s", m.visibleOffset+index, sepString)) } // Only handel lines that are visible if visLines+len(contentLines) >= m.Viewport.Height { @@ -182,14 +179,14 @@ func (m *Model) SetSeperator(sep string) { // Down moves the "cursor" or current line down. // If the end is allready reached err is not nil. func (m *Model) Down() error { - length := len(m.listItems)-1 + length := len(m.listItems) - 1 if m.curIndex >= length { m.curIndex = length return fmt.Errorf("Can't go beyond last item") } m.curIndex++ // move visible part of list if Curser is going beyond border. - lowerBorder := m.Viewport.Height+m.visibleOffset-m.lineCurserOffset + lowerBorder := m.Viewport.Height + m.visibleOffset - m.lineCurserOffset if m.curIndex >= lowerBorder { m.visibleOffset++ } @@ -205,7 +202,7 @@ func (m *Model) Up() error { } m.curIndex-- // move visible part of list if Curser is going beyond border. - upperBorder := m.visibleOffset+m.lineCurserOffset + upperBorder := m.visibleOffset + m.lineCurserOffset if m.visibleOffset > 0 && m.curIndex <= upperBorder { m.visibleOffset-- } @@ -224,9 +221,10 @@ func NewModel() Model { wrap: true, - seperator: " ╭ ", - seperatorWrap: " │ ", - absoluteNumber: true, + seperator: " ╭ ", + seperatorWrap: " │ ", + currentSeperator: " ╭>", + absoluteNumber: true, SelectedBackGroundColor: "#ff0000", } @@ -241,9 +239,22 @@ func (m *Model) Top() { // Bottom moves the cursor to the first line func (m *Model) Bottom() { - visLines := m.Viewport.Height-m.lineCurserOffset - start :=len(m.listItems)-visLines// FIXME acount for wraped lines + visLines := m.Viewport.Height - m.lineCurserOffset + start := len(m.listItems) - visLines // FIXME acount for wraped lines m.visibleOffset = start - m.visibleItems = m.listItems[start:start+visLines] - m.curIndex = len(m.listItems)-1 + m.visibleItems = m.listItems[start : start+visLines] + m.curIndex = len(m.listItems) - 1 +} + +// maxRuneWidth returns the maximal lenght of occupied space +// frome the given strings +func maxRuneWidth(words ...string) int { + var max int + for _, w := range words { + width := runewidth.StringWidth(w) + if width > max { + max = width + } + } + return max } From bc6de77fbad0680ffca251c7a9aecade1b4d5cbf Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Wed, 4 Nov 2020 10:31:39 +0100 Subject: [PATCH 06/73] added methode for returning the selected items within a list --- list/list.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/list/list.go b/list/list.go index 032963d59..17b89fd66 100644 --- a/list/list.go +++ b/list/list.go @@ -258,3 +258,16 @@ func maxRuneWidth(words ...string) int { } return max } + +// GetSelected returns you a orderd list of all items +// that are selected +func (m *Model) GetSelected() []string { + var selected []string + for _, item := range m.listItems { + if item.selected { + selected = append(selected, item.content) + } + } + return selected +} + From 07b56d923dba1f240cff6086f3af6903a6c810f7 Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Wed, 4 Nov 2020 10:31:40 +0100 Subject: [PATCH 07/73] formated code --- list/list.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/list/list.go b/list/list.go index 17b89fd66..c2b400b2c 100644 --- a/list/list.go +++ b/list/list.go @@ -261,7 +261,7 @@ func maxRuneWidth(words ...string) int { // GetSelected returns you a orderd list of all items // that are selected -func (m *Model) GetSelected() []string { +func (m *Model) GetSelected() []string { var selected []string for _, item := range m.listItems { if item.selected { @@ -270,4 +270,3 @@ func (m *Model) GetSelected() []string { } return selected } - From 7fc7a27f7160c76176d8619063da53fadac81384 Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Wed, 4 Nov 2020 10:31:40 +0100 Subject: [PATCH 08/73] turned wrap back on and deleted done todo "test sep.. Width" --- list/list.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/list/list.go b/list/list.go index c2b400b2c..5dd6a9af3 100644 --- a/list/list.go +++ b/list/list.go @@ -110,13 +110,13 @@ out: holeString.WriteString("\n") visLines++ - if len(contentLines) == 1 || m.wrap { + if len(contentLines) == 1 || !m.wrap { continue } // Write wraped lines for _, line := range contentLines[1:] { - holeString.WriteString(strings.Repeat(" ", padTo) + m.seperatorWrap) // Pad line // TODO test seperator width + holeString.WriteString(strings.Repeat(" ", padTo) + m.seperatorWrap) // Pad line holeString.WriteString(line) // write line holeString.WriteString("\n") // Write end of line visLines++ From 51fec9fcd96823335c6c0646f7691d772be5ecc5 Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Wed, 4 Nov 2020 10:31:41 +0100 Subject: [PATCH 09/73] simplefied line writing into buffer for better readability --- list/list.go | 44 ++++++++++++++++++++++++++++---------------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/list/list.go b/list/list.go index 5dd6a9af3..b9fd3b660 100644 --- a/list/list.go +++ b/list/list.go @@ -30,7 +30,7 @@ type Model struct { relativeNumber bool absoluteNumber bool - jump int + jump int // maybe buffer for jumping multiple lines LineForeGroundColor string LineBackGroundColor string @@ -84,48 +84,60 @@ out: // Handle list items for index, item := range m.visibleItems { sepString := m.seperator - // handel highlighting of current or selected lines + + // handel highlighting of selected lines colored := termenv.String() if item.selected { colored = colored.Background(p.Color(m.SelectedBackGroundColor)) } + + // handel highlighting of current line if index+m.visibleOffset == m.curIndex { colored = colored.Reverse() sepString = m.currentSeperator } - contentLines := strings.Split(wordwrap.String(colored.Styled(item.content), contentWidth), "\n") - var firstPad string + // Get wraplines + contentLines := strings.Split(wordwrap.String((item.content), contentWidth), "\n") + // if set prepend firstline with linenumber + var firstPad string if m.absoluteNumber || m.relativeNumber { - firstPad = colored.Styled(fmt.Sprintf("%"+fmt.Sprint(padTo)+"d%"+fmt.Sprint(sep)+"s", m.visibleOffset+index, sepString)) + firstPad = fmt.Sprintf("%"+fmt.Sprint(padTo)+"d%"+fmt.Sprint(sep)+"s", m.visibleOffset+index, sepString) } + // Only handel lines that are visible if visLines+len(contentLines) >= m.Viewport.Height { break out } - // Write first line - holeString.WriteString(firstPad) - holeString.WriteString(contentLines[0]) - holeString.WriteString("\n") + // join pad and line content + line := fmt.Sprintf("%s%s\n", firstPad, contentLines[0]) + + // Highlight and write first line + holeString.WriteString(colored.Styled(line)) + + // Dont write wraped lines if not set visLines++ - if len(contentLines) == 1 || !m.wrap { + if !m.wrap { continue } // Write wraped lines for _, line := range contentLines[1:] { - holeString.WriteString(strings.Repeat(" ", padTo) + m.seperatorWrap) // Pad line - holeString.WriteString(line) // write line - holeString.WriteString("\n") // Write end of line - visLines++ + // Pad line + pad := strings.Repeat(" ", padTo) + m.seperatorWrap + padLine := fmt.Sprintf("%s%s\n", pad, line) + + // Highlight and write wrap lines + holeString.WriteString(colored.Styled(padLine)) + // Only write lines that are visible + visLines++ if visLines >= m.Viewport.Height { break out } } - } return holeString.String() } @@ -138,7 +150,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "down": if m.jump > 0 { m.curIndex -= m.jump - m.jump = 0 // TODO check if this realy resets jump (if m is a pointer) likely pointer + m.jump = 0 } else { m.curIndex-- } From eb0a94009fd23784d11e341e72ca7e684b3c202d Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Wed, 4 Nov 2020 10:31:42 +0100 Subject: [PATCH 10/73] Fixed Hightlighting bug Linebreak was inside the ansi-highlighting string and coused wired behavier... --- list/list.go | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/list/list.go b/list/list.go index b9fd3b660..6b51e5081 100644 --- a/list/list.go +++ b/list/list.go @@ -98,6 +98,8 @@ out: } // Get wraplines + // NOTE Highlighting is not done here because wordwrap, while Ansi aware, + // does not ende and starts Ansi-sequenses at linebreak as of now contentLines := strings.Split(wordwrap.String((item.content), contentWidth), "\n") // if set prepend firstline with linenumber @@ -112,10 +114,12 @@ out: } // join pad and line content - line := fmt.Sprintf("%s%s\n", firstPad, contentLines[0]) + // NOTE linebreak is not added here because it would mess with the highlighting + line := fmt.Sprintf("%s%s", firstPad, contentLines[0]) // Highlight and write first line holeString.WriteString(colored.Styled(line)) + holeString.WriteString("\n") // Dont write wraped lines if not set visLines++ @@ -125,12 +129,14 @@ out: // Write wraped lines for _, line := range contentLines[1:] { - // Pad line + // Pad left of line pad := strings.Repeat(" ", padTo) + m.seperatorWrap - padLine := fmt.Sprintf("%s%s\n", pad, line) + // NOTE linebreak is not added here because it would mess with the highlighting + padLine := fmt.Sprintf("%s%s", pad, line) - // Highlight and write wrap lines + // Highlight and write wraped line holeString.WriteString(colored.Styled(padLine)) + holeString.WriteString("\n") // Only write lines that are visible visLines++ From ac40b51ea32c4d2ebf393b264a440a1929e14e10 Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Wed, 4 Nov 2020 10:31:43 +0100 Subject: [PATCH 11/73] Added tests, changed item-line-wraping, made configs-public added simple tests for comparing View() output against a known (golden) sample changed item wraping back to no hardwrap changed config-Model-struct-fields to be public --- list/list.go | 156 ++++++++++++++++++++++++++-------------------- list/list_test.go | 130 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 218 insertions(+), 68 deletions(-) create mode 100644 list/list_test.go diff --git a/list/list.go b/list/list.go index 6b51e5081..56368a9ab 100644 --- a/list/list.go +++ b/list/list.go @@ -17,18 +17,17 @@ type Model struct { listItems []item curIndex int - visibleItems []item visibleOffset int lineCurserOffset int Viewport viewport.Model - wrap bool + Wrap bool - seperator string - seperatorWrap string - currentSeperator string - relativeNumber bool - absoluteNumber bool + Seperator string + SeperatorWrap string + CurrentSeperator string + RelativeNumber bool + AbsoluteNumber bool jump int // maybe buffer for jumping multiple lines @@ -41,49 +40,81 @@ type Model struct { // Item are Items used in the list Model // to hold the Content representat as a string type item struct { - selected bool - content string + selected bool + content string + wrapedLines []string + wrapedLenght int + wrapedto int +} + +// genVisLines renews the wrap of the content into wraplines +func (i item) genVisLines(wrapTo int) item { + i.wrapedLines = strings.Split(wordwrap.String(i.content, wrapTo), "\n") + //TODO hardwrap lines/words + i.wrapedLenght = len(i.wrapedLines) + i.wrapedto = wrapTo + return i } // View renders the Lst to a (displayable) string func (m *Model) View() string { width := m.Viewport.Width - // padding for the right amount of numbers - max := m.Viewport.Height // relativ - abs := m.visibleOffset + m.Viewport.Height - 1 // absolute - if abs > max { - max = abs + // check visible area + height := m.Viewport.Height + width := m.Viewport.Width + if height*width <= 0 { + panic("Can't display with zero width or hight of Viewport") + } + + // if there is something to pad + var relWidth, absWidth, padWidth int + + if m.RelativeNumber { + relWidth = len(fmt.Sprintf("%d", height)) + } + + if m.AbsoluteNumber { + absWidth = len(fmt.Sprintf("%d", len(m.listItems))) } - padTo := runewidth.StringWidth(fmt.Sprintf("%d", max)) - sep := maxRuneWidth(m.seperator, m.seperatorWrap, m.currentSeperator) + + // get widest number to pad + padWidth = relWidth + if padWidth < absWidth { + padWidth = absWidth + } + + // Get max seperator width + sepWidth := maxRuneWidth(m.Seperator, m.SeperatorWrap, m.CurrentSeperator) + + // Get actual content width + contentWidth := width - (sepWidth + padWidth + 1) // Check if there is space for the content left - contentWidth := m.Viewport.Width - (padTo + sep) if contentWidth <= 0 { panic("Can't display with zero width for content") } - // Set Visible lines - begin := m.visibleOffset - if begin < 0 { - begin = 0 - } - end := m.visibleOffset + m.Viewport.Height - lenght := len(m.listItems) - if end > lenght { - end = len(m.listItems) + // renew wrap of all items TODO check if to slow + for i := range m.listItems { + m.listItems[i] = m.listItems[i].genVisLines(contentWidth) + } - m.visibleItems = m.listItems[begin:end] p := termenv.ColorProfile() var visLines int var holeString bytes.Buffer out: - // Handle list items - for index, item := range m.visibleItems { - sepString := m.seperator + // Handle list items, start at first visible and go till end of list or visible (break) + for index := m.visibleOffset; index < len(m.listItems); index++ { + if index >= len(m.listItems) || index < 0 { + break + } + + item := m.listItems[index] + + sepString := m.Seperator // handel highlighting of selected lines colored := termenv.String() @@ -94,53 +125,55 @@ out: // handel highlighting of current line if index+m.visibleOffset == m.curIndex { colored = colored.Reverse() - sepString = m.currentSeperator + sepString = m.CurrentSeperator } - // Get wraplines - // NOTE Highlighting is not done here because wordwrap, while Ansi aware, - // does not ende and starts Ansi-sequenses at linebreak as of now - contentLines := strings.Split(wordwrap.String((item.content), contentWidth), "\n") - // if set prepend firstline with linenumber var firstPad string - if m.absoluteNumber || m.relativeNumber { - firstPad = fmt.Sprintf("%"+fmt.Sprint(padTo)+"d%"+fmt.Sprint(sep)+"s", m.visibleOffset+index, sepString) + if m.AbsoluteNumber || m.RelativeNumber { + lineOffset := m.visibleOffset + index + firstPad = fmt.Sprintf("%"+fmt.Sprint(padWidth)+"d%"+fmt.Sprint(sepWidth)+"s", lineOffset, sepString) } - // Only handel lines that are visible - if visLines+len(contentLines) >= m.Viewport.Height { - break out + // join pad and line content + if item.wrapedLenght == 0 { + panic("cant display item with no visible content") } - // join pad and line content + lineContent := item.wrapedLines[0] // NOTE linebreak is not added here because it would mess with the highlighting - line := fmt.Sprintf("%s%s", firstPad, contentLines[0]) + line := fmt.Sprintf("%s%s", firstPad, lineContent) // Highlight and write first line - holeString.WriteString(colored.Styled(line)) + coloredLine := colored.Styled(line) + holeString.WriteString(coloredLine) holeString.WriteString("\n") + visLines++ // Dont write wraped lines if not set - visLines++ - if !m.wrap { + if !m.Wrap || item.wrapedLenght < 1 { continue } + // Only write lines that are visible + if visLines >= height { + break out + } + // Write wraped lines - for _, line := range contentLines[1:] { + for _, line := range item.wrapedLines[1:] { // Pad left of line - pad := strings.Repeat(" ", padTo) + m.seperatorWrap + pad := strings.Repeat(" ", padWidth) + m.SeperatorWrap // NOTE linebreak is not added here because it would mess with the highlighting padLine := fmt.Sprintf("%s%s", pad, line) // Highlight and write wraped line holeString.WriteString(colored.Styled(padLine)) holeString.WriteString("\n") + visLines++ // Only write lines that are visible - visLines++ - if visLines >= m.Viewport.Height { + if visLines >= height { break out } } @@ -183,17 +216,6 @@ func (m *Model) AddItems(itemList []string) { } } -// SetAbsNumber sets if absolute Linenumbers should be displayed -func (m *Model) SetAbsNumber(setTo bool) { - m.absoluteNumber = setTo -} - -// SetSeperator sets the seperator string -// between left border and the content of the line -func (m *Model) SetSeperator(sep string) { - m.seperator = sep -} - // Down moves the "cursor" or current line down. // If the end is allready reached err is not nil. func (m *Model) Down() error { @@ -237,12 +259,12 @@ func NewModel() Model { return Model{ lineCurserOffset: 5, - wrap: true, + Wrap: true, - seperator: " ╭ ", - seperatorWrap: " │ ", - currentSeperator: " ╭>", - absoluteNumber: true, + Seperator: " ╭ ", + SeperatorWrap: " │ ", + CurrentSeperator: " ╭>", + AbsoluteNumber: true, SelectedBackGroundColor: "#ff0000", } @@ -251,7 +273,6 @@ func NewModel() Model { // Top moves the cursor to the first line func (m *Model) Top() { m.visibleOffset = 0 - m.visibleItems = m.listItems[0:m.Viewport.Height] m.curIndex = 0 } @@ -260,7 +281,6 @@ func (m *Model) Bottom() { visLines := m.Viewport.Height - m.lineCurserOffset start := len(m.listItems) - visLines // FIXME acount for wraped lines m.visibleOffset = start - m.visibleItems = m.listItems[start : start+visLines] m.curIndex = len(m.listItems) - 1 } diff --git a/list/list_test.go b/list/list_test.go new file mode 100644 index 000000000..b4c0dd8bf --- /dev/null +++ b/list/list_test.go @@ -0,0 +1,130 @@ +package list + +import ( + "testing" + "github.com/muesli/reflow/ansi" + "strings" +) + +// test is a shorthand and will be converted to proper testModels +// with embedModels +type test struct { + vWidth int + vHeight int + items []string + shouldBe string +} + +type testModel struct { + model Model + shouldBe string +} + +// TestViewBounds is use to make sure that the Renderer String +// NEVER leaves the bounds since then it could mess with the layout. +func TestViewBounds(t *testing.T) { + for _, testM := range embedModels(genTestModels()) { + for i, line := range strings.Split(View(testM.model), "\n") { + lineWidth := ansi.PrintableRuneWidth(line) + width := testM.model.Viewport.Width + if lineWidth > width { + t.Errorf("The line:\n\n%s\n%s^\n\n is %d chars longer than the Viewport width.", line, strings.Repeat(" ", width-1), lineWidth-width) + } + if i > testM.model.Viewport.Height { + t.Error("There are more lines produced from the View() than the Viewport height") + } + } + } +} + +// TestGoldenSamples checks the View's string result against a knowen string (golden sample) +// Because there is no margin for diviations, if the test fails, lock also if the "golden sample" is sane. +func TestGoldenSamples(t *testing.T) { + for _, testM := range embedModels(genTestModels()) { + actual := View(testM.model) + expected := testM.shouldBe + if actual != expected { + t.Errorf("expected Output:\n\n%s\n\nactual Output:\n\n%s\n\n", expected, actual) + } + } +} + +// TestPanic is also a golden sampling, but for cases that should panic. +func TestPanic(t *testing.T) { + for _, testM := range embedModels(genPanicTests()) { + View(testM) + actual := recover() + expected := testM.shouldBe + if actual != expected { + t.Errorf("expected panic Output:\n\n%s\n\nactual Output:\n\n%s\n\n", expected, actual) + } + } +} + +// small helper function to generate simple test cases. +// for more elaborate ones append them afterwards. +func genTestModels() []test { + return []test{ + // The default has abs linenumber and this seperator enabled + // so that even if the terminal does not support colors + // all propertys are still distinguishable. + { + 240, + 80, + []string{ + "", + }, + "\x1b[7m0 ╭>\x1b[0m\n", + }, + // if exceding the boards and softwrap (at word bounderys are possible + // wrap there. Dont increment the item number because its still the same item. + { + 10, + 2, + []string{ + "robert frost", + }, + "\x1b[7m0 ╭>robert\x1b[0m\n\x1b[7m │ frost\x1b[0m\n", + }, + } +} + +func embedModels(rawLists []test) []testModel { + processedList := make([]testModel, len(rawLists)) + for i, list := range rawLists { + m := NewModel() + m.Viewport.Height = list.vHeight + m.Viewport.Width = list.vWidth + m.AddItems(list.items) + newItem := testModel{model: m, shouldBe: list.shouldBe} + processedList[i] = newItem + } + return processedList +} + +// +func genPanicTests() []test { + return []test{ + // no width to display -> panic + { + 0, + 1, + []string{""}, + "Can't display with zero width or hight of Viewport", + }, + // no height to display -> panic + { + 1, + 0, + []string{""}, + "Can't display with zero width or hight of Viewport", + }, + // no item to display -> panic + { + 1, + 1, + []string{}, + "", + }, + } +} From 6c091f060784b0ac66fba9549a0fbf26e8560ef8 Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Wed, 4 Nov 2020 10:31:43 +0100 Subject: [PATCH 12/73] changed hightlighting from string to termenv.Style --- list/list.go | 43 ++++++++++++++++++++++--------------------- list/list_test.go | 2 +- 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/list/list.go b/list/list.go index 56368a9ab..9ec06fb2c 100644 --- a/list/list.go +++ b/list/list.go @@ -20,21 +20,22 @@ type Model struct { visibleOffset int lineCurserOffset int + jump int // maybe buffer for jumping multiple lines + Viewport viewport.Model Wrap bool - Seperator string - SeperatorWrap string - CurrentSeperator string - RelativeNumber bool - AbsoluteNumber bool - - jump int // maybe buffer for jumping multiple lines - - LineForeGroundColor string - LineBackGroundColor string - SelectedForeGroundColor string - SelectedBackGroundColor string + Seperator string + SeperatorWrap string + SeperatorSelected string + CurrentSeperator string + RelativeNumber bool + AbsoluteNumber bool + + LineForeGroundStyle termenv.Style + LineBackGroundStyle termenv.Style + SelectedForeGroundStyle termenv.Style + SelectedBackGroundStyle termenv.Style } // Item are Items used in the list Model @@ -45,6 +46,7 @@ type item struct { wrapedLines []string wrapedLenght int wrapedto int + userValue interface{} } // genVisLines renews the wrap of the content into wraplines @@ -101,8 +103,6 @@ func (m *Model) View() string { } - p := termenv.ColorProfile() - var visLines int var holeString bytes.Buffer out: @@ -117,14 +117,14 @@ out: sepString := m.Seperator // handel highlighting of selected lines - colored := termenv.String() + style := termenv.String() if item.selected { - colored = colored.Background(p.Color(m.SelectedBackGroundColor)) + style = m.SelectedBackGroundStyle } // handel highlighting of current line if index+m.visibleOffset == m.curIndex { - colored = colored.Reverse() + style = style.Reverse() sepString = m.CurrentSeperator } @@ -145,8 +145,7 @@ out: line := fmt.Sprintf("%s%s", firstPad, lineContent) // Highlight and write first line - coloredLine := colored.Styled(line) - holeString.WriteString(coloredLine) + holeString.WriteString(style.Styled(line)) holeString.WriteString("\n") visLines++ @@ -168,7 +167,7 @@ out: padLine := fmt.Sprintf("%s%s", pad, line) // Highlight and write wraped line - holeString.WriteString(colored.Styled(padLine)) + holeString.WriteString(style.Styled(padLine)) holeString.WriteString("\n") visLines++ @@ -256,6 +255,8 @@ func (m *Model) ToggleSelect() { // NewModel returns a Model with some save/sane defaults func NewModel() Model { + p := termenv.ColorProfile() + style := termenv.Style{}.Background(p.Color("#ff0000")) return Model{ lineCurserOffset: 5, @@ -266,7 +267,7 @@ func NewModel() Model { CurrentSeperator: " ╭>", AbsoluteNumber: true, - SelectedBackGroundColor: "#ff0000", + SelectedBackGroundStyle: style, } } diff --git a/list/list_test.go b/list/list_test.go index b4c0dd8bf..41cf8b511 100644 --- a/list/list_test.go +++ b/list/list_test.go @@ -1,9 +1,9 @@ package list import ( - "testing" "github.com/muesli/reflow/ansi" "strings" + "testing" ) // test is a shorthand and will be converted to proper testModels From 5ea572dfbb3457350164e0ef1cd93877175ed469 Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Wed, 4 Nov 2020 10:31:44 +0100 Subject: [PATCH 13/73] Implementet a prefix for selected line so that a selected line is still distinguishable wenn color is off --- list/list.go | 38 ++++++++++++++++++++++++-------------- list/list_test.go | 14 +++++++------- 2 files changed, 31 insertions(+), 21 deletions(-) diff --git a/list/list.go b/list/list.go index 9ec06fb2c..8f24e34dc 100644 --- a/list/list.go +++ b/list/list.go @@ -25,12 +25,12 @@ type Model struct { Viewport viewport.Model Wrap bool - Seperator string - SeperatorWrap string - SeperatorSelected string - CurrentSeperator string - RelativeNumber bool - AbsoluteNumber bool + Seperator string + SeperatorWrap string + SeperatorCurrent string + SelectedPrefix string + RelativeNumber bool + AbsoluteNumber bool LineForeGroundStyle termenv.Style LineBackGroundStyle termenv.Style @@ -87,10 +87,13 @@ func (m *Model) View() string { } // Get max seperator width - sepWidth := maxRuneWidth(m.Seperator, m.SeperatorWrap, m.CurrentSeperator) + sepWidth := maxRuneWidth(m.Seperator, m.SeperatorWrap, m.SeperatorCurrent) + runewidth.StringWidth(m.SelectedPrefix) + + //Get hole Width + holeWidth := sepWidth + padWidth // Get actual content width - contentWidth := width - (sepWidth + padWidth + 1) + contentWidth := width - (holeWidth + 1) // Check if there is space for the content left if contentWidth <= 0 { @@ -115,32 +118,37 @@ out: item := m.listItems[index] sepString := m.Seperator + wrapString := m.SeperatorWrap - // handel highlighting of selected lines + // handel highlighting and prefixing of selected lines style := termenv.String() if item.selected { style = m.SelectedBackGroundStyle + sepString = m.SelectedPrefix + sepString + wrapString = m.SelectedPrefix + wrapString } // handel highlighting of current line if index+m.visibleOffset == m.curIndex { style = style.Reverse() - sepString = m.CurrentSeperator + sepString = m.SeperatorCurrent } - // if set prepend firstline with linenumber + // if set, prepend firstline with enough space for linenumber and seperator + // This while first create a string like: "%3d%4s" + // Which will be than filled with linenumber and seperator string var firstPad string if m.AbsoluteNumber || m.RelativeNumber { lineOffset := m.visibleOffset + index firstPad = fmt.Sprintf("%"+fmt.Sprint(padWidth)+"d%"+fmt.Sprint(sepWidth)+"s", lineOffset, sepString) } - // join pad and line content if item.wrapedLenght == 0 { panic("cant display item with no visible content") } lineContent := item.wrapedLines[0] + // join pad and line content // NOTE linebreak is not added here because it would mess with the highlighting line := fmt.Sprintf("%s%s", firstPad, lineContent) @@ -162,7 +170,8 @@ out: // Write wraped lines for _, line := range item.wrapedLines[1:] { // Pad left of line - pad := strings.Repeat(" ", padWidth) + m.SeperatorWrap + // TODO performance: do stringlength and prepending befor loop + pad := strings.Repeat(" ", holeWidth-runewidth.StringWidth(wrapString)) + wrapString // NOTE linebreak is not added here because it would mess with the highlighting padLine := fmt.Sprintf("%s%s", pad, line) @@ -264,7 +273,8 @@ func NewModel() Model { Seperator: " ╭ ", SeperatorWrap: " │ ", - CurrentSeperator: " ╭>", + SeperatorCurrent: " ╭>", + SelectedPrefix: "*", AbsoluteNumber: true, SelectedBackGroundStyle: style, diff --git a/list/list_test.go b/list/list_test.go index 41cf8b511..79643f32e 100644 --- a/list/list_test.go +++ b/list/list_test.go @@ -7,7 +7,7 @@ import ( ) // test is a shorthand and will be converted to proper testModels -// with embedModels +// with genModels type test struct { vWidth int vHeight int @@ -23,7 +23,7 @@ type testModel struct { // TestViewBounds is use to make sure that the Renderer String // NEVER leaves the bounds since then it could mess with the layout. func TestViewBounds(t *testing.T) { - for _, testM := range embedModels(genTestModels()) { + for _, testM := range genModels(genTestModels()) { for i, line := range strings.Split(View(testM.model), "\n") { lineWidth := ansi.PrintableRuneWidth(line) width := testM.model.Viewport.Width @@ -40,7 +40,7 @@ func TestViewBounds(t *testing.T) { // TestGoldenSamples checks the View's string result against a knowen string (golden sample) // Because there is no margin for diviations, if the test fails, lock also if the "golden sample" is sane. func TestGoldenSamples(t *testing.T) { - for _, testM := range embedModels(genTestModels()) { + for _, testM := range genModels(genTestModels()) { actual := View(testM.model) expected := testM.shouldBe if actual != expected { @@ -51,7 +51,7 @@ func TestGoldenSamples(t *testing.T) { // TestPanic is also a golden sampling, but for cases that should panic. func TestPanic(t *testing.T) { - for _, testM := range embedModels(genPanicTests()) { + for _, testM := range genModels(genPanicTests()) { View(testM) actual := recover() expected := testM.shouldBe @@ -74,7 +74,7 @@ func genTestModels() []test { []string{ "", }, - "\x1b[7m0 ╭>\x1b[0m\n", + "\x1b[7m0 ╭>\x1b[0m\n", }, // if exceding the boards and softwrap (at word bounderys are possible // wrap there. Dont increment the item number because its still the same item. @@ -84,12 +84,12 @@ func genTestModels() []test { []string{ "robert frost", }, - "\x1b[7m0 ╭>robert\x1b[0m\n\x1b[7m │ frost\x1b[0m\n", + "\x1b[7m0 ╭>robert\x1b[0m\n\x1b[7m │ frost\x1b[0m\n", }, } } -func embedModels(rawLists []test) []testModel { +func genModels(rawLists []test) []testModel { processedList := make([]testModel, len(rawLists)) for i, list := range rawLists { m := NewModel() From 3208db51820dfe3dd5509096ad0a8cb55ea7f6f2 Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Wed, 4 Nov 2020 10:31:44 +0100 Subject: [PATCH 14/73] made the List Model satisfy the Sort interface and added Sort methode to apply sorting to the items of the List --- list/list.go | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/list/list.go b/list/list.go index 8f24e34dc..86a32d23a 100644 --- a/list/list.go +++ b/list/list.go @@ -9,6 +9,7 @@ import ( "github.com/muesli/reflow/wordwrap" "github.com/muesli/termenv" "strings" + "sort" ) // Model is a bubbletea List of strings @@ -19,6 +20,7 @@ type Model struct { curIndex int visibleOffset int lineCurserOffset int + less func (k, l string) bool jump int // maybe buffer for jumping multiple lines @@ -276,6 +278,11 @@ func NewModel() Model { SeperatorCurrent: " ╭>", SelectedPrefix: "*", AbsoluteNumber: true, + less: func(k, l string) bool { + return k < l + }, + + SelectedBackGroundStyle: style, } @@ -319,3 +326,29 @@ func (m *Model) GetSelected() []string { } return selected } + +// Less is a Proxy to the less function, set from the user. +// Swap is used to fullfill the Sort-interface +func (m *Model) Less(i ,j int) bool { + return m.less(m.listItems[i].content, m.listItems[j].content) +} + +// Swap is used to fullfill the Sort-interface +func (m *Model) Swap(i, j int) { + m.listItems[i], m.listItems[j] = m.listItems[j], m.listItems[i] +} + +// Len is used to fullfill the Sort-interface +func (m *Model) Len() int { + return len(m.listItems) +} + +// SetLess sets the internal less function used for sorting the list items +func (m *Model) SetLess(less func(string, string) bool ) { + m.less = less +} + +// Sort sorts the listitems acording to the set less function +func (m *Model) Sort() { + sort.Sort(m) +} From 59d748b1e3586f0ac05b52afab90050655603025 Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Wed, 4 Nov 2020 10:31:45 +0100 Subject: [PATCH 15/73] found bug: when piping into this example: error: EOF --- list/example/main.go | 140 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 list/example/main.go diff --git a/list/example/main.go b/list/example/main.go new file mode 100644 index 000000000..50a17adf4 --- /dev/null +++ b/list/example/main.go @@ -0,0 +1,140 @@ +package main + +import ( + "bufio" + "fmt" + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "os" + "strings" +) + +/* +Reads from StdIn, opens lines as bubbles-list. +When closed print, with space, selected lines to StdOut +*/ + +type model struct { + ready bool + list list.Model + finished bool + endResult chan<- string +} + +func main() { + scanner := bufio.NewScanner(os.Stdin) + lineList := make([]string, 0, 1000) + for scanner.Scan() { + lineList = append(lineList, scanner.Text()) + } + + if err := scanner.Err(); err != nil { + fmt.Fprintln(os.Stderr, "Error reading from stdin:", err) + os.Exit(1) + } + endResult := make(chan string, 1) + + p := tea.NewProgram(initialize(lineList, endResult), update, view) + + // Use the full size of the terminal in its "alternate screen buffer" + p.EnterAltScreen() + + if err := p.Start(); err != nil { + fmt.Println("could not run program:", err) + os.Exit(1) + } + p.ExitAltScreen() + + fmt.Println(<-endResult) +} + +func initialize(lineList []string, endResult chan<- string) func() (tea.Model, tea.Cmd) { + l := list.NewModel() + l.AddItems(lineList) + + return func() (tea.Model, tea.Cmd) { return model{list: l, endResult: endResult}, nil } +} + +func view(mdl tea.Model) string { + m, _ := mdl.(model) + if !m.ready { + return "\n Initalizing..." + } + + return list.View(m.list) +} + +type confirmation struct{} + +func update(msg tea.Msg, mdl tea.Model) (tea.Model, tea.Cmd) { + m, _ := mdl.(model) + + var cmd tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + // Ctrl+c exits + if msg.Type == tea.KeyCtrlC { + result := strings.Join(m.list.GetSelected(), "\n") + m.endResult <- result + return m, tea.Quit + } + switch msg.String() { + case "q": + result := strings.Join(m.list.GetSelected(), "\n") + m.endResult <- result + return m, tea.Quit + case "j": + m.list.Down() + return m, nil + case "k": + m.list.Up() + return m, nil + case " ": + m.list.ToggleSelect() + m.list.Down() + return m, nil + case "g": + m.list.Top() + return m, nil + case "G": + m.list.Bottom() + return m, nil + case "s": + m.list.Sort() + return m, nil + } + + case tea.WindowSizeMsg: + + m.list.Viewport.Width = msg.Width + m.list.Viewport.Height = msg.Height + + if !m.ready { + // Since this program is using 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 + } + + // Because we're using the viewport's default update function (with pager- + // style navigation) it's important that the viewport's update function: + // + // * Recieves messages from the Bubble Tea runtime + // * Returns commands to the Bubble Tea runtime + // + + m.list.Viewport, cmd = viewport.Update(msg, m.list.Viewport) + + return m, cmd + case confirmation: + if !m.finished { + return m, nil + } + } + + return m, nil +} From 415b56149364477fd28cc6ca346c7f77951a9853 Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Wed, 4 Nov 2020 10:31:45 +0100 Subject: [PATCH 16/73] Changed Example from pipe to "tutorial"-lines --- list/example/main.go | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/list/example/main.go b/list/example/main.go index 50a17adf4..01f864e7d 100644 --- a/list/example/main.go +++ b/list/example/main.go @@ -1,7 +1,6 @@ package main import ( - "bufio" "fmt" "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/viewport" @@ -23,19 +22,19 @@ type model struct { } func main() { - scanner := bufio.NewScanner(os.Stdin) - lineList := make([]string, 0, 1000) - for scanner.Scan() { - lineList = append(lineList, scanner.Text()) - } + items := []string{ + "Welcome to the bubbles-list example!", + "You Can move the highlighted Item up and down with the keys 'k' and 'j'", + "The list can handel linebreaks,\nand has wordwrap enabled if the line gets to long.", + "You can Select items with the space key which will select the line and mark it as such.", + "Ones you finish this example with 'q' or 'ctrl-c' the selected lines will be printed to StdOut.", + "When you print the the items there will be a loss of information,", + "since one can not say what was a line break within an item or what is a new item", - if err := scanner.Err(); err != nil { - fmt.Fprintln(os.Stderr, "Error reading from stdin:", err) - os.Exit(1) } endResult := make(chan string, 1) - p := tea.NewProgram(initialize(lineList, endResult), update, view) + p := tea.NewProgram(initialize(items, endResult), update, view) // Use the full size of the terminal in its "alternate screen buffer" p.EnterAltScreen() From 336a0c87754537e1528c395d2ed5d2df80849917 Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Wed, 4 Nov 2020 10:31:46 +0100 Subject: [PATCH 17/73] added dynamic Test for testing View after list-methodes --- list/example/main.go | 2 +- list/list.go | 10 +++---- list/list_test.go | 66 +++++++++++++++++++++++++++++++++----------- 3 files changed, 55 insertions(+), 23 deletions(-) diff --git a/list/example/main.go b/list/example/main.go index 01f864e7d..38eb0cdc8 100644 --- a/list/example/main.go +++ b/list/example/main.go @@ -24,7 +24,7 @@ type model struct { func main() { items := []string{ "Welcome to the bubbles-list example!", - "You Can move the highlighted Item up and down with the keys 'k' and 'j'", + "You Can move the highlighted index up and down with the keys 'k' and 'j'", "The list can handel linebreaks,\nand has wordwrap enabled if the line gets to long.", "You can Select items with the space key which will select the line and mark it as such.", "Ones you finish this example with 'q' or 'ctrl-c' the selected lines will be printed to StdOut.", diff --git a/list/list.go b/list/list.go index 86a32d23a..3a2dba05f 100644 --- a/list/list.go +++ b/list/list.go @@ -8,8 +8,8 @@ import ( runewidth "github.com/mattn/go-runewidth" "github.com/muesli/reflow/wordwrap" "github.com/muesli/termenv" - "strings" "sort" + "strings" ) // Model is a bubbletea List of strings @@ -20,7 +20,7 @@ type Model struct { curIndex int visibleOffset int lineCurserOffset int - less func (k, l string) bool + less func(k, l string) bool jump int // maybe buffer for jumping multiple lines @@ -282,8 +282,6 @@ func NewModel() Model { return k < l }, - - SelectedBackGroundStyle: style, } } @@ -329,7 +327,7 @@ func (m *Model) GetSelected() []string { // Less is a Proxy to the less function, set from the user. // Swap is used to fullfill the Sort-interface -func (m *Model) Less(i ,j int) bool { +func (m *Model) Less(i, j int) bool { return m.less(m.listItems[i].content, m.listItems[j].content) } @@ -344,7 +342,7 @@ func (m *Model) Len() int { } // SetLess sets the internal less function used for sorting the list items -func (m *Model) SetLess(less func(string, string) bool ) { +func (m *Model) SetLess(less func(string, string) bool) { m.less = less } diff --git a/list/list_test.go b/list/list_test.go index 79643f32e..5f96c3555 100644 --- a/list/list_test.go +++ b/list/list_test.go @@ -16,8 +16,9 @@ type test struct { } type testModel struct { - model Model - shouldBe string + model Model + shouldBe string + afterMethode string } // TestViewBounds is use to make sure that the Renderer String @@ -61,6 +62,31 @@ func TestPanic(t *testing.T) { } } +//TestDynamic tests the view output after a movement/view-changing method +func TestDynamic(t *testing.T) { + for _, test := range genDynamicModels() { + actual := View(test.model) + expected := test.shouldBe + if actual != expected { + t.Errorf("expected Output, after Methode '%s' called:\n\n%s\n\nactual Output:\n\n%s\n\n", test.afterMethode, expected, actual) + } + } +} + +// genModels embeds the fields from the rawModels into an actual model +func genModels(rawLists []test) []testModel { + processedList := make([]testModel, len(rawLists)) + for i, list := range rawLists { + m := NewModel() + m.Viewport.Height = list.vHeight + m.Viewport.Width = list.vWidth + m.AddItems(list.items) + newItem := testModel{model: m, shouldBe: list.shouldBe} + processedList[i] = newItem + } + return processedList +} + // small helper function to generate simple test cases. // for more elaborate ones append them afterwards. func genTestModels() []test { @@ -89,20 +115,7 @@ func genTestModels() []test { } } -func genModels(rawLists []test) []testModel { - processedList := make([]testModel, len(rawLists)) - for i, list := range rawLists { - m := NewModel() - m.Viewport.Height = list.vHeight - m.Viewport.Width = list.vWidth - m.AddItems(list.items) - newItem := testModel{model: m, shouldBe: list.shouldBe} - processedList[i] = newItem - } - return processedList -} - -// +// genPanicTests generats test cases that should panic with the shouldBe string func genPanicTests() []test { return []test{ // no width to display -> panic @@ -128,3 +141,24 @@ func genPanicTests() []test { }, } } + +// genDynamicModels generats test cases for dynamic actions like movement, sorting, resizing +func genDynamicModels() []testModel { + moveBottom := NewModel() + moveBottom.Viewport.Width = 10 + moveBottom.Viewport.Height = 10 + moveBottom.AddItems([]string{ + "1", + "2", + "3", + "4", + }, + ) + moveBottom.Bottom() + return []testModel{ + {model: moveBottom, + shouldBe: "0 ╭ \n1 ╭ \n2 ╭ \n\x1b[7m3 ╭>\x1b[0m\n", + afterMethode: "Bottom", + }, + } +} From 5c8699323f952c2d9cbe0f80ebc157cdd55f783c Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Wed, 4 Nov 2020 10:31:46 +0100 Subject: [PATCH 18/73] implemented Bottom movement and fixed the example --- list/example/main.go | 73 +++++++++++++++++++++++--------------------- list/list.go | 68 +++++++++++++++++++++++++++++++++-------- 2 files changed, 94 insertions(+), 47 deletions(-) diff --git a/list/example/main.go b/list/example/main.go index 38eb0cdc8..59b2ec3d0 100644 --- a/list/example/main.go +++ b/list/example/main.go @@ -24,11 +24,14 @@ type model struct { func main() { items := []string{ "Welcome to the bubbles-list example!", - "You Can move the highlighted index up and down with the keys 'k' and 'j'", + "Use 'q' or 'ctrl-c' to quit!", + "You can move the highlighted index up and down with the keys 'k' and 'j'.", + "Move to the beginning with 'g' and to the end with 'G'.", + "Sort the entrys with 's', but be carefull you can't unsort it again.", "The list can handel linebreaks,\nand has wordwrap enabled if the line gets to long.", - "You can Select items with the space key which will select the line and mark it as such.", - "Ones you finish this example with 'q' or 'ctrl-c' the selected lines will be printed to StdOut.", - "When you print the the items there will be a loss of information,", + "You can select items with the space key which will select the line and mark it as such.", + "Ones you hit 'enter', the selected lines will be printed to StdOut and the program exits.", + "When you print the items there will be a loss of information,", "since one can not say what was a line break within an item or what is a new item", } @@ -37,17 +40,28 @@ func main() { p := tea.NewProgram(initialize(items, endResult), update, view) // Use the full size of the terminal in its "alternate screen buffer" - p.EnterAltScreen() + fullScreen := false // change to true if you want fullscreen + + if fullScreen { + p.EnterAltScreen() + } if err := p.Start(); err != nil { fmt.Println("could not run program:", err) os.Exit(1) } - p.ExitAltScreen() + if fullScreen { + p.ExitAltScreen() + } - fmt.Println(<-endResult) + res := <-endResult + if res != "" { + fmt.Println(res) + } } +// initialize sets up the model and returns it to the bubbletea runtime +// as a function result, so it can later be handed over to the update and view functions. func initialize(lineList []string, endResult chan<- string) func() (tea.Model, tea.Cmd) { l := list.NewModel() l.AddItems(lineList) @@ -55,17 +69,19 @@ func initialize(lineList []string, endResult chan<- string) func() (tea.Model, t return func() (tea.Model, tea.Cmd) { return model{list: l, endResult: endResult}, nil } } +// view waits till the terminal sizes is knowen to the model and than, +// pipes the model to the list View for rendering the list func view(mdl tea.Model) string { m, _ := mdl.(model) if !m.ready { - return "\n Initalizing..." + return "\n Initalizing...\n\n Waiting for info about window size." } return list.View(m.list) } -type confirmation struct{} +// update recives messages and the model and changes the model accordingly to the messages func update(msg tea.Msg, mdl tea.Model) (tea.Model, tea.Cmd) { m, _ := mdl.(model) @@ -75,36 +91,28 @@ func update(msg tea.Msg, mdl tea.Model) (tea.Model, tea.Cmd) { case tea.KeyMsg: // Ctrl+c exits if msg.Type == tea.KeyCtrlC { - result := strings.Join(m.list.GetSelected(), "\n") - m.endResult <- result + m.endResult <- "" return m, tea.Quit } switch msg.String() { case "q": + m.endResult <- "" + return m, tea.Quit + } + + // Enter prints the selected lines to StdOut + if msg.Type == tea.KeyEnter { result := strings.Join(m.list.GetSelected(), "\n") m.endResult <- result return m, tea.Quit - case "j": - m.list.Down() - return m, nil - case "k": - m.list.Up() - return m, nil - case " ": - m.list.ToggleSelect() - m.list.Down() - return m, nil - case "g": - m.list.Top() - return m, nil - case "G": - m.list.Bottom() - return m, nil - case "s": - m.list.Sort() - return m, nil } + // pipe all other commands to the update from the list + list, newMsg := list.Update(msg, m.list) + m.list = list + + return m, newMsg + case tea.WindowSizeMsg: m.list.Viewport.Width = msg.Width @@ -129,10 +137,7 @@ func update(msg tea.Msg, mdl tea.Model) (tea.Model, tea.Cmd) { m.list.Viewport, cmd = viewport.Update(msg, m.list.Viewport) return m, cmd - case confirmation: - if !m.finished { - return m, nil - } + } return m, nil diff --git a/list/list.go b/list/list.go index 3a2dba05f..786fa60dd 100644 --- a/list/list.go +++ b/list/list.go @@ -193,19 +193,53 @@ out: // Update changes the Model of the List according to the messages recieved func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + switch msg := msg.(type) { case tea.KeyMsg: + // Ctrl+c exits + if msg.Type == tea.KeyCtrlC { + return m, tea.Quit + } switch msg.String() { - case "down": - if m.jump > 0 { - m.curIndex -= m.jump - m.jump = 0 - } else { - m.curIndex-- - } + case "q": + return m, tea.Quit + case "down", "j": + m.Down() + return m, nil + case "up", "k": + m.Up() + return m, nil case " ": - m.listItems[m.curIndex].selected = !m.listItems[m.curIndex].selected + m.ToggleSelect() + m.Down() + return m, nil + case "g": + m.Top() + return m, nil + case "G": + m.Bottom() + return m, nil + case "s": + m.Sort() + return m, nil } + + case tea.WindowSizeMsg: + + m.Viewport.Width = msg.Width + m.Viewport.Height = msg.Height + + // Because we're using the viewport's default update function (with pager- + // style navigation) it's important that the viewport's update function: + // + // * Recieves messages from the Bubble Tea runtime + // * Returns commands to the Bubble Tea runtime + // + + m.Viewport, cmd = viewport.Update(msg, m.Viewport) + + return m, cmd } return m, nil } @@ -292,12 +326,20 @@ func (m *Model) Top() { m.curIndex = 0 } -// Bottom moves the cursor to the first line +// Bottom moves the cursor to the last line func (m *Model) Bottom() { - visLines := m.Viewport.Height - m.lineCurserOffset - start := len(m.listItems) - visLines // FIXME acount for wraped lines - m.visibleOffset = start - m.curIndex = len(m.listItems) - 1 + end := len(m.listItems) - 1 + m.curIndex = end + maxVisItems := m.Viewport.Height - m.lineCurserOffset + var visLines, smallestVisIndex int + for c := end; visLines < maxVisItems; c-- { + if c < 0 { + break + } + visLines += m.listItems[c].wrapedLenght + smallestVisIndex = c + } + m.visibleOffset = smallestVisIndex } // maxRuneWidth returns the maximal lenght of occupied space From 32e40ff5f87df40bd410ca24b9b8e00b76f79ac3 Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Wed, 4 Nov 2020 10:31:47 +0100 Subject: [PATCH 19/73] changed creation of the line and wrap prefix to be performater created befor entering the loop --- list/list.go | 133 ++++++++++++++++++++++++++++++--------------------- 1 file changed, 79 insertions(+), 54 deletions(-) diff --git a/list/list.go b/list/list.go index 786fa60dd..4425ba2b8 100644 --- a/list/list.go +++ b/list/list.go @@ -7,6 +7,7 @@ import ( tea "github.com/charmbracelet/bubbletea" runewidth "github.com/mattn/go-runewidth" "github.com/muesli/reflow/wordwrap" + "github.com/muesli/reflow/ansi" "github.com/muesli/termenv" "sort" "strings" @@ -27,12 +28,13 @@ type Model struct { Viewport viewport.Model Wrap bool + SelectedPrefix string Seperator string SeperatorWrap string - SeperatorCurrent string - SelectedPrefix string - RelativeNumber bool - AbsoluteNumber bool + CurrentMarker string + + Number bool + NumberRelative bool LineForeGroundStyle termenv.Style LineBackGroundStyle termenv.Style @@ -71,31 +73,21 @@ func (m *Model) View() string { panic("Can't display with zero width or hight of Viewport") } - // if there is something to pad - var relWidth, absWidth, padWidth int - if m.RelativeNumber { - relWidth = len(fmt.Sprintf("%d", height)) - } + // Get max seperator width + widthItem := ansi.PrintableRuneWidth(m.Seperator) + widthWrap := ansi.PrintableRuneWidth(m.SeperatorWrap) - if m.AbsoluteNumber { - absWidth = len(fmt.Sprintf("%d", len(m.listItems))) - } - // get widest number to pad - padWidth = relWidth - if padWidth < absWidth { - padWidth = absWidth + // Find max width + sepWidth := widthItem + if widthWrap > sepWidth { + sepWidth = widthWrap } - // Get max seperator width - sepWidth := maxRuneWidth(m.Seperator, m.SeperatorWrap, m.SeperatorCurrent) + runewidth.StringWidth(m.SelectedPrefix) - - //Get hole Width - holeWidth := sepWidth + padWidth - // Get actual content width - contentWidth := width - (holeWidth + 1) + numWidth := len(m.listItems) + contentWidth := width - (numWidth + sepWidth) // Check if there is space for the content left if contentWidth <= 0 { @@ -108,6 +100,19 @@ func (m *Model) View() string { } + + // pad all prefixes to the same width for easy exchange + prefix := m.SelectedPrefix + prepad := strings.Repeat(" ", ansi.PrintableRuneWidth(m.SelectedPrefix)) + + // pad all seperators to the same width for easy exchange + sepItem := strings.Repeat(" ", sepWidth-widthItem)+m.Seperator + sepWrap := strings.Repeat(" ", sepWidth-widthWrap)+m.SeperatorWrap + + // pad right of prefix, with lenght of current pointer + suffix := m.CurrentMarker + sufpad := strings.Repeat(" ", ansi.PrintableRuneWidth(suffix)) + var visLines int var holeString bytes.Buffer out: @@ -118,41 +123,51 @@ out: } item := m.listItems[index] + if item.wrapedLenght == 0 { + panic("cant display item with no visible content") + } - sepString := m.Seperator - wrapString := m.SeperatorWrap - // handel highlighting and prefixing of selected lines - style := termenv.String() + var linePrefix, wrapPrefix string + + // if a number is set, prepend firstline with number and both with enough spaceses + firstPad := strings.Repeat(" ", numWidth) + var wrapPad string + if m.Number { + lineNum := lineNumber(m.NumberRelative, m.curIndex, m.visibleOffset+index) + number := fmt.Sprintf("%d", lineNum) + // since diggets are only singel bytes len is sufficent: + firstPad = strings.Repeat(" ", numWidth-len(number)) + number + // pad wraped lines + wrapPad = strings.Repeat(" ", numWidth) + } + + + // Selecting: handel highlighting and prefixing of selected lines + selString := prepad // assume not selected + style := termenv.String() // create empty style + if item.selected { - style = m.SelectedBackGroundStyle - sepString = m.SelectedPrefix + sepString - wrapString = m.SelectedPrefix + wrapString + style = m.SelectedBackGroundStyle // fill style + selString = prefix // change if selected } - // handel highlighting of current line + + // Current: handel highlighting of current item/first-line + curPad := sufpad if index+m.visibleOffset == m.curIndex { style = style.Reverse() - sepString = m.SeperatorCurrent + curPad = suffix } - // if set, prepend firstline with enough space for linenumber and seperator - // This while first create a string like: "%3d%4s" - // Which will be than filled with linenumber and seperator string - var firstPad string - if m.AbsoluteNumber || m.RelativeNumber { - lineOffset := m.visibleOffset + index - firstPad = fmt.Sprintf("%"+fmt.Sprint(padWidth)+"d%"+fmt.Sprint(sepWidth)+"s", lineOffset, sepString) - } + // join all prefixes + linePrefix = strings.Join([]string{firstPad, linePrefix, selString, sepItem, curPad}, "") + wrapPrefix = strings.Join([]string{wrapPad, linePrefix, selString, sepWrap, sufpad}, "") - if item.wrapedLenght == 0 { - panic("cant display item with no visible content") - } - lineContent := item.wrapedLines[0] - // join pad and line content + // join pad and first line content // NOTE linebreak is not added here because it would mess with the highlighting - line := fmt.Sprintf("%s%s", firstPad, lineContent) + line := fmt.Sprintf("%s%s", linePrefix, item.wrapedLines[0]) // Highlight and write first line holeString.WriteString(style.Styled(line)) @@ -160,7 +175,7 @@ out: visLines++ // Dont write wraped lines if not set - if !m.Wrap || item.wrapedLenght < 1 { + if !m.Wrap || item.wrapedLenght <= 1 { continue } @@ -172,10 +187,8 @@ out: // Write wraped lines for _, line := range item.wrapedLines[1:] { // Pad left of line - // TODO performance: do stringlength and prepending befor loop - pad := strings.Repeat(" ", holeWidth-runewidth.StringWidth(wrapString)) + wrapString // NOTE linebreak is not added here because it would mess with the highlighting - padLine := fmt.Sprintf("%s%s", pad, line) + padLine := fmt.Sprintf("%s%s", wrapPrefix, line) // Highlight and write wraped line holeString.WriteString(style.Styled(padLine)) @@ -307,11 +320,11 @@ func NewModel() Model { Wrap: true, - Seperator: " ╭ ", - SeperatorWrap: " │ ", - SeperatorCurrent: " ╭>", + Seperator: "╭", + SeperatorWrap: "│", + CurrentMarker: ">", SelectedPrefix: "*", - AbsoluteNumber: true, + Number: true, less: func(k, l string) bool { return k < l }, @@ -392,3 +405,15 @@ func (m *Model) SetLess(less func(string, string) bool) { func (m *Model) Sort() { sort.Sort(m) } + +func lineNumber(relativ bool, curser, current int) int { + if !relativ || curser == current { + return current + } + + diff := curser - current + if diff < 0 { + diff *= -1 + } + return diff +} From 53d84f200d87785ebbadb132e45a16e9913a5fbe Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Wed, 4 Nov 2020 10:31:47 +0100 Subject: [PATCH 20/73] fixed offrunning bug, added and changed tests - added new test for down movement when next to curserborder. - applied changes to test, according to previous change of line- and wrap- prefix - changed to not fullscreen example - fixed offrunning curser bug through removing redundent adding of offset - changed Up and Down to set the curser to the right place when entering the curserborder --- list/example/main.go | 5 +- list/list.go | 144 ++++++++++++++++++++----------------------- list/list_test.go | 92 +++++++++++++++++++++++---- 3 files changed, 151 insertions(+), 90 deletions(-) diff --git a/list/example/main.go b/list/example/main.go index 59b2ec3d0..696570e10 100644 --- a/list/example/main.go +++ b/list/example/main.go @@ -33,7 +33,6 @@ func main() { "Ones you hit 'enter', the selected lines will be printed to StdOut and the program exits.", "When you print the items there will be a loss of information,", "since one can not say what was a line break within an item or what is a new item", - } endResult := make(chan string, 1) @@ -77,10 +76,10 @@ func view(mdl tea.Model) string { return "\n Initalizing...\n\n Waiting for info about window size." } - return list.View(m.list) + listString := list.View(m.list) + return listString } - // update recives messages and the model and changes the model accordingly to the messages func update(msg tea.Msg, mdl tea.Model) (tea.Model, tea.Cmd) { m, _ := mdl.(model) diff --git a/list/list.go b/list/list.go index 4425ba2b8..1ca82ef85 100644 --- a/list/list.go +++ b/list/list.go @@ -5,9 +5,8 @@ import ( "fmt" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" - runewidth "github.com/mattn/go-runewidth" - "github.com/muesli/reflow/wordwrap" "github.com/muesli/reflow/ansi" + "github.com/muesli/reflow/wordwrap" "github.com/muesli/termenv" "sort" "strings" @@ -18,23 +17,23 @@ type Model struct { focus bool listItems []item - curIndex int - visibleOffset int - lineCurserOffset int - less func(k, l string) bool + curIndex int // curser + visibleOffset int // begin of the visible lines + lineCurserOffset int // offset or margin between the cursor and the viewport(visible) border + less func(k, l string) bool // function used for sorting jump int // maybe buffer for jumping multiple lines Viewport viewport.Model Wrap bool - SelectedPrefix string - Seperator string - SeperatorWrap string - CurrentMarker string + SelectedPrefix string + Seperator string + SeperatorWrap string + CurrentMarker string - Number bool - NumberRelative bool + Number bool + NumberRelative bool LineForeGroundStyle termenv.Style LineBackGroundStyle termenv.Style @@ -67,27 +66,49 @@ func (m *Model) View() string { width := m.Viewport.Width // check visible area - height := m.Viewport.Height + height := m.Viewport.Height - 1 // TODO question: why -1, otherwise firstline gets cut of width := m.Viewport.Width + offset := m.visibleOffset if height*width <= 0 { panic("Can't display with zero width or hight of Viewport") } - // Get max seperator width widthItem := ansi.PrintableRuneWidth(m.Seperator) widthWrap := ansi.PrintableRuneWidth(m.SeperatorWrap) - // Find max width sepWidth := widthItem if widthWrap > sepWidth { sepWidth = widthWrap } + // get widest *displayed* number, for padding + numWidth := len(fmt.Sprintf("%d", len(m.listItems)-1)) + localMaxWidth := len(fmt.Sprintf("%d", offset+height-1)) + if localMaxWidth < numWidth { + numWidth = localMaxWidth + } + + // pad all prefixes to the same width for easy exchange + prefix := m.SelectedPrefix + preWidth := ansi.PrintableRuneWidth(prefix) + prepad := strings.Repeat(" ", preWidth) + + // pad all seperators to the same width for easy exchange + sepItem := strings.Repeat(" ", sepWidth-widthItem) + m.Seperator + sepWrap := strings.Repeat(" ", sepWidth-widthWrap) + m.SeperatorWrap + + // pad right of prefix, with lenght of current pointer + suffix := m.CurrentMarker + sufWidth := ansi.PrintableRuneWidth(suffix) + sufpad := strings.Repeat(" ", sufWidth) + + // Get the hole prefix width + holePrefixWidth := numWidth + preWidth + sepWidth + sufWidth + // Get actual content width - numWidth := len(m.listItems) - contentWidth := width - (numWidth + sepWidth) + contentWidth := width - holePrefixWidth // Check if there is space for the content left if contentWidth <= 0 { @@ -97,73 +118,54 @@ func (m *Model) View() string { // renew wrap of all items TODO check if to slow for i := range m.listItems { m.listItems[i] = m.listItems[i].genVisLines(contentWidth) - } - - // pad all prefixes to the same width for easy exchange - prefix := m.SelectedPrefix - prepad := strings.Repeat(" ", ansi.PrintableRuneWidth(m.SelectedPrefix)) - - // pad all seperators to the same width for easy exchange - sepItem := strings.Repeat(" ", sepWidth-widthItem)+m.Seperator - sepWrap := strings.Repeat(" ", sepWidth-widthWrap)+m.SeperatorWrap - - // pad right of prefix, with lenght of current pointer - suffix := m.CurrentMarker - sufpad := strings.Repeat(" ", ansi.PrintableRuneWidth(suffix)) - var visLines int var holeString bytes.Buffer out: // Handle list items, start at first visible and go till end of list or visible (break) - for index := m.visibleOffset; index < len(m.listItems); index++ { + for index := offset; index < len(m.listItems); index++ { if index >= len(m.listItems) || index < 0 { + // TODO log error break } item := m.listItems[index] - if item.wrapedLenght == 0 { + if item.wrapedLenght <= 0 { panic("cant display item with no visible content") } - - var linePrefix, wrapPrefix string - - // if a number is set, prepend firstline with number and both with enough spaceses + // if a number is set, prepend firstline with number and both with enough spaces firstPad := strings.Repeat(" ", numWidth) var wrapPad string if m.Number { - lineNum := lineNumber(m.NumberRelative, m.curIndex, m.visibleOffset+index) + lineNum := lineNumber(m.NumberRelative, m.curIndex, index) number := fmt.Sprintf("%d", lineNum) - // since diggets are only singel bytes len is sufficent: + // since diggets are only singel bytes, len is sufficent: firstPad = strings.Repeat(" ", numWidth-len(number)) + number // pad wraped lines wrapPad = strings.Repeat(" ", numWidth) } - // Selecting: handel highlighting and prefixing of selected lines - selString := prepad // assume not selected + selString := prepad // assume not selected style := termenv.String() // create empty style if item.selected { style = m.SelectedBackGroundStyle // fill style - selString = prefix // change if selected + selString = prefix // change if selected } - // Current: handel highlighting of current item/first-line curPad := sufpad - if index+m.visibleOffset == m.curIndex { + if index == m.curIndex { style = style.Reverse() curPad = suffix } // join all prefixes - linePrefix = strings.Join([]string{firstPad, linePrefix, selString, sepItem, curPad}, "") - wrapPrefix = strings.Join([]string{wrapPad, linePrefix, selString, sepWrap, sufpad}, "") - + linePrefix := strings.Join([]string{firstPad, selString, sepItem, curPad}, "") + wrapPrefix := strings.Join([]string{wrapPad, selString, sepWrap, sufpad}, "") // dont prefix wrap lines with CurrentMarker (suffix) // join pad and first line content // NOTE linebreak is not added here because it would mess with the highlighting @@ -174,16 +176,16 @@ out: holeString.WriteString("\n") visLines++ - // Dont write wraped lines if not set - if !m.Wrap || item.wrapedLenght <= 1 { - continue - } - // Only write lines that are visible if visLines >= height { break out } + // Dont write wraped lines if not set + if !m.Wrap || item.wrapedLenght <= 1 { + continue + } + // Write wraped lines for _, line := range item.wrapedLines[1:] { // Pad left of line @@ -284,8 +286,8 @@ func (m *Model) Down() error { m.curIndex++ // move visible part of list if Curser is going beyond border. lowerBorder := m.Viewport.Height + m.visibleOffset - m.lineCurserOffset - if m.curIndex >= lowerBorder { - m.visibleOffset++ + if m.curIndex > lowerBorder { + m.visibleOffset = m.curIndex - (m.Viewport.Height - m.lineCurserOffset) } return nil } @@ -301,7 +303,7 @@ func (m *Model) Up() error { // move visible part of list if Curser is going beyond border. upperBorder := m.visibleOffset + m.lineCurserOffset if m.visibleOffset > 0 && m.curIndex <= upperBorder { - m.visibleOffset-- + m.visibleOffset = m.curIndex - m.lineCurserOffset } return nil } @@ -320,11 +322,12 @@ func NewModel() Model { Wrap: true, - Seperator: "╭", - SeperatorWrap: "│", - CurrentMarker: ">", - SelectedPrefix: "*", - Number: true, + Seperator: "╭", + SeperatorWrap: "│", + CurrentMarker: ">", + SelectedPrefix: "*", + Number: true, + less: func(k, l string) bool { return k < l }, @@ -355,21 +358,8 @@ func (m *Model) Bottom() { m.visibleOffset = smallestVisIndex } -// maxRuneWidth returns the maximal lenght of occupied space -// frome the given strings -func maxRuneWidth(words ...string) int { - var max int - for _, w := range words { - width := runewidth.StringWidth(w) - if width > max { - max = width - } - } - return max -} - -// GetSelected returns you a orderd list of all items -// that are selected +// GetSelected returns you a list of all items +// that are selected in current (displayed) order func (m *Model) GetSelected() []string { var selected []string for _, item := range m.listItems { @@ -406,12 +396,14 @@ func (m *Model) Sort() { sort.Sort(m) } +// lineNumber returns line number of the given index +// and if relative is true the absolute difference to the curser func lineNumber(relativ bool, curser, current int) int { if !relativ || curser == current { return current } - diff := curser - current + diff := curser - current if diff < 0 { diff *= -1 } diff --git a/list/list_test.go b/list/list_test.go index 5f96c3555..0bb541650 100644 --- a/list/list_test.go +++ b/list/list_test.go @@ -62,7 +62,7 @@ func TestPanic(t *testing.T) { } } -//TestDynamic tests the view output after a movement/view-changing method +// TestDynamic tests the view output after a movement/view-changing method func TestDynamic(t *testing.T) { for _, test := range genDynamicModels() { actual := View(test.model) @@ -100,7 +100,7 @@ func genTestModels() []test { []string{ "", }, - "\x1b[7m0 ╭>\x1b[0m\n", + "\x1b[7m0 ╭>\x1b[0m\n", }, // if exceding the boards and softwrap (at word bounderys are possible // wrap there. Dont increment the item number because its still the same item. @@ -110,7 +110,23 @@ func genTestModels() []test { []string{ "robert frost", }, - "\x1b[7m0 ╭>robert\x1b[0m\n\x1b[7m │ frost\x1b[0m\n", + "\x1b[7m0 ╭>robert\x1b[0m\n\x1b[7m │ frost\x1b[0m\n", + }, + { + 10, + 10, + []string{"", "", "", "", "", "", "", "", "", "", "", "", "", "", ""}, + "\x1b[7m0 ╭>\x1b[0m\n" + + `1 ╭ +2 ╭ +3 ╭ +4 ╭ +5 ╭ +6 ╭ +7 ╭ +8 ╭ +9 ╭ +`, }, } } @@ -147,18 +163,72 @@ func genDynamicModels() []testModel { moveBottom := NewModel() moveBottom.Viewport.Width = 10 moveBottom.Viewport.Height = 10 - moveBottom.AddItems([]string{ - "1", - "2", - "3", - "4", - }, - ) + moveBottom.AddItems([]string{"", "", "", ""}) moveBottom.Bottom() + moveDown := NewModel() + moveDown.Viewport.Height = 50 + moveDown.Viewport.Width = 80 + moveDown.AddItems([]string{"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""}) + moveDown.curIndex = 45 // set cursor next to line Offset Border so that the down move, should move the hole visible area. + moveDown.Down() return []testModel{ {model: moveBottom, - shouldBe: "0 ╭ \n1 ╭ \n2 ╭ \n\x1b[7m3 ╭>\x1b[0m\n", + shouldBe: "0 ╭ \n1 ╭ \n2 ╭ \n\x1b[7m3 ╭>\x1b[0m\n", afterMethode: "Bottom", }, + {model: moveDown, + shouldBe: ` 1 ╭ + 2 ╭ + 3 ╭ + 4 ╭ + 5 ╭ + 6 ╭ + 7 ╭ + 8 ╭ + 9 ╭ +10 ╭ +11 ╭ +12 ╭ +13 ╭ +14 ╭ +15 ╭ +16 ╭ +17 ╭ +18 ╭ +19 ╭ +20 ╭ +21 ╭ +22 ╭ +23 ╭ +24 ╭ +25 ╭ +26 ╭ +27 ╭ +28 ╭ +29 ╭ +30 ╭ +31 ╭ +32 ╭ +33 ╭ +34 ╭ +35 ╭ +36 ╭ +37 ╭ +38 ╭ +39 ╭ +40 ╭ +41 ╭ +42 ╭ +43 ╭ +44 ╭ +45 ╭ ` + + "\n\x1b[7m46 ╭>\x1b[0m\n" + + `47 ╭ +48 ╭ +49 ╭ +50 ╭ +`, + afterMethode: "Down", + }, } } From 88e9fae3fdea41417b4975bca1e9217966189583 Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Wed, 4 Nov 2020 10:31:48 +0100 Subject: [PATCH 21/73] tryed to get TestPanic to work and updated question and: - reordert lineNumber() be be closer to the View - out commented panic test because case is not handled/thought about --- list/list.go | 30 +++++++++++++++--------------- list/list_test.go | 22 +++++++++++++--------- 2 files changed, 28 insertions(+), 24 deletions(-) diff --git a/list/list.go b/list/list.go index 1ca82ef85..d3f90e71b 100644 --- a/list/list.go +++ b/list/list.go @@ -66,7 +66,7 @@ func (m *Model) View() string { width := m.Viewport.Width // check visible area - height := m.Viewport.Height - 1 // TODO question: why -1, otherwise firstline gets cut of + height := m.Viewport.Height - 1 // TODO question: why does the first line get cut of, if i ommit the -1? width := m.Viewport.Width offset := m.visibleOffset if height*width <= 0 { @@ -206,6 +206,20 @@ out: return holeString.String() } +// lineNumber returns line number of the given index +// and if relative is true the absolute difference to the curser +func lineNumber(relativ bool, curser, current int) int { + if !relativ || curser == current { + return current + } + + diff := curser - current + if diff < 0 { + diff *= -1 + } + return diff +} + // Update changes the Model of the List according to the messages recieved func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd @@ -395,17 +409,3 @@ func (m *Model) SetLess(less func(string, string) bool) { func (m *Model) Sort() { sort.Sort(m) } - -// lineNumber returns line number of the given index -// and if relative is true the absolute difference to the curser -func lineNumber(relativ bool, curser, current int) int { - if !relativ || curser == current { - return current - } - - diff := curser - current - if diff < 0 { - diff *= -1 - } - return diff -} diff --git a/list/list_test.go b/list/list_test.go index 0bb541650..00b0a10f5 100644 --- a/list/list_test.go +++ b/list/list_test.go @@ -53,8 +53,12 @@ func TestGoldenSamples(t *testing.T) { // TestPanic is also a golden sampling, but for cases that should panic. func TestPanic(t *testing.T) { for _, testM := range genModels(genPanicTests()) { - View(testM) - actual := recover() + panicRes := make(chan interface{}) + go func(resChan chan<- interface{}) { + defer func() { resChan <- recover() }() // Why does this Yield "%!s()"? + View(testM) // does this not Panic? + }(panicRes) + actual := <-panicRes expected := testM.shouldBe if actual != expected { t.Errorf("expected panic Output:\n\n%s\n\nactual Output:\n\n%s\n\n", expected, actual) @@ -148,13 +152,13 @@ func genPanicTests() []test { []string{""}, "Can't display with zero width or hight of Viewport", }, - // no item to display -> panic - { - 1, - 1, - []string{}, - "", - }, + // no item to display -> panic TODO handel/think-about this case + //{ + // 1, + // 1, + // []string{}, + // "", + //}, } } From 826f05d691ec9f8fdb489477ee4bf1ce1c664fb0 Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Wed, 4 Nov 2020 10:31:48 +0100 Subject: [PATCH 22/73] Droped Viewport from list Model, implemented more methodes on list - implemented Methodes on list with parameter for amount of changing - because of that the jump field gets removed --- list/example/main.go | 27 ++---- list/list.go | 192 ++++++++++++++++++++++++++++++++----------- list/list_test.go | 33 ++++++-- 3 files changed, 177 insertions(+), 75 deletions(-) diff --git a/list/example/main.go b/list/example/main.go index 696570e10..b614fdb53 100644 --- a/list/example/main.go +++ b/list/example/main.go @@ -3,17 +3,11 @@ package main import ( "fmt" "github.com/charmbracelet/bubbles/list" - "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "os" "strings" ) -/* -Reads from StdIn, opens lines as bubbles-list. -When closed print, with space, selected lines to StdOut -*/ - type model struct { ready bool list list.Model @@ -25,7 +19,7 @@ func main() { items := []string{ "Welcome to the bubbles-list example!", "Use 'q' or 'ctrl-c' to quit!", - "You can move the highlighted index up and down with the keys 'k' and 'j'.", + "You can move the highlighted index up and down with the (arrow) keys 'k' and 'j'.", "Move to the beginning with 'g' and to the end with 'G'.", "Sort the entrys with 's', but be carefull you can't unsort it again.", "The list can handel linebreaks,\nand has wordwrap enabled if the line gets to long.", @@ -33,6 +27,8 @@ func main() { "Ones you hit 'enter', the selected lines will be printed to StdOut and the program exits.", "When you print the items there will be a loss of information,", "since one can not say what was a line break within an item or what is a new item", + "Use '+' or '-' to move the item under the curser up and down.", + "The 'v' inverts the selected state of each item.", } endResult := make(chan string, 1) @@ -84,8 +80,6 @@ func view(mdl tea.Model) string { func update(msg tea.Msg, mdl tea.Model) (tea.Model, tea.Cmd) { m, _ := mdl.(model) - var cmd tea.Cmd - switch msg := msg.(type) { case tea.KeyMsg: // Ctrl+c exits @@ -114,8 +108,8 @@ func update(msg tea.Msg, mdl tea.Model) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: - m.list.Viewport.Width = msg.Width - m.list.Viewport.Height = msg.Height + m.list.Width = msg.Width + m.list.Height = msg.Height if !m.ready { // Since this program is using the full size of the viewport we need @@ -126,16 +120,7 @@ func update(msg tea.Msg, mdl tea.Model) (tea.Model, tea.Cmd) { m.ready = true } - // Because we're using the viewport's default update function (with pager- - // style navigation) it's important that the viewport's update function: - // - // * Recieves messages from the Bubble Tea runtime - // * Returns commands to the Bubble Tea runtime - // - - m.list.Viewport, cmd = viewport.Update(msg, m.list.Viewport) - - return m, cmd + return m, nil } diff --git a/list/list.go b/list/list.go index d3f90e71b..42d4a50ba 100644 --- a/list/list.go +++ b/list/list.go @@ -3,7 +3,6 @@ package list import ( "bytes" "fmt" - "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/muesli/reflow/ansi" "github.com/muesli/reflow/wordwrap" @@ -22,10 +21,10 @@ type Model struct { lineCurserOffset int // offset or margin between the cursor and the viewport(visible) border less func(k, l string) bool // function used for sorting - jump int // maybe buffer for jumping multiple lines + Width int + Height int - Viewport viewport.Model - Wrap bool + Wrap bool SelectedPrefix string Seperator string @@ -66,8 +65,8 @@ func (m *Model) View() string { width := m.Viewport.Width // check visible area - height := m.Viewport.Height - 1 // TODO question: why does the first line get cut of, if i ommit the -1? - width := m.Viewport.Width + height := m.Height - 1 // TODO question: why does the first line get cut of, if i ommit the -1? + width := m.Width offset := m.visibleOffset if height*width <= 0 { panic("Can't display with zero width or hight of Viewport") @@ -234,13 +233,13 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "q": return m, tea.Quit case "down", "j": - m.Down() + m.Move(1) return m, nil case "up", "k": - m.Up() + m.Move(-1) return m, nil case " ": - m.ToggleSelect() + m.ToggleSelect(1) m.Down() return m, nil case "g": @@ -252,23 +251,38 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "s": m.Sort() return m, nil + case "+": + m.MoveItem(-1) + return m, nil + case "-": + m.MoveItem(1) + return m, nil + case "v": // inVert + m.ToggleAllSelected() + return m, nil + case "m": // mark + m.MarkSelected(1, true) + return m, nil + case "M": // mark False + m.MarkSelected(1, false) + return m, nil } case tea.WindowSizeMsg: - m.Viewport.Width = msg.Width - m.Viewport.Height = msg.Height + m.Width = msg.Width + m.Height = msg.Height - // Because we're using the viewport's default update function (with pager- - // style navigation) it's important that the viewport's update function: - // - // * Recieves messages from the Bubble Tea runtime - // * Returns commands to the Bubble Tea runtime - // + return m, cmd - m.Viewport, cmd = viewport.Update(msg, m.Viewport) + case tea.MouseMsg: + switch msg.Button { + case tea.MouseWheelUp: + m.Up() - return m, cmd + case tea.MouseWheelDown: + m.Down() + } } return m, nil } @@ -292,41 +306,29 @@ func (m *Model) AddItems(itemList []string) { // Down moves the "cursor" or current line down. // If the end is allready reached err is not nil. func (m *Model) Down() error { - length := len(m.listItems) - 1 - if m.curIndex >= length { - m.curIndex = length - return fmt.Errorf("Can't go beyond last item") - } - m.curIndex++ - // move visible part of list if Curser is going beyond border. - lowerBorder := m.Viewport.Height + m.visibleOffset - m.lineCurserOffset - if m.curIndex > lowerBorder { - m.visibleOffset = m.curIndex - (m.Viewport.Height - m.lineCurserOffset) - } - return nil + return m.Move(1) } // Up moves the "cursor" or current line up. -// If the start is allready reached err is not nil. +// If the start is already reached, err is not nil. func (m *Model) Up() error { - if m.curIndex <= 0 { - m.curIndex = 0 - return fmt.Errorf("Can't go infront of first item") + return m.Move(-1) +} + +// Move moves the cursor by amount, does nothing if amount is 0 +// and returns error != nil if amount gos beyond list borders +func (m *Model) Move(amount int) error { + if amount == 0 { + return nil } - m.curIndex-- - // move visible part of list if Curser is going beyond border. - upperBorder := m.visibleOffset + m.lineCurserOffset - if m.visibleOffset > 0 && m.curIndex <= upperBorder { - m.visibleOffset = m.curIndex - m.lineCurserOffset + target := m.curIndex + amount + if !m.CheckWithinBorder(target) { + return fmt.Errorf("Cant move outside the list: %d", target) } + m.curIndex = target return nil } -// ToggleSelect toggles the selected status of the current Index -func (m *Model) ToggleSelect() { - m.listItems[m.curIndex].selected = !m.listItems[m.curIndex].selected -} - // NewModel returns a Model with some save/sane defaults func NewModel() Model { p := termenv.ColorProfile() @@ -350,6 +352,68 @@ func NewModel() Model { } } +// ToggleSelect toggles the selected status +// of the current Index if amount is 0 +// returns err != nil when amount lands outside list and savely does nothing +// else if amount is not 0 toggels selected amount items +// excluding the item on which the curser lands +func (m *Model) ToggleSelect(amount int) error { + if amount == 0 { + m.listItems[m.curIndex].selected = !m.listItems[m.curIndex].selected + } + + direction := 1 + if amount < 0 { + direction = -1 + } + + cur := m.curIndex + target := cur + amount - direction + if !m.CheckWithinBorder(target) { + return fmt.Errorf("Cant go beyond list borders: %d", target) + } + for c := 0; c < amount*direction; c++ { + m.listItems[cur+c].selected = !m.listItems[cur+c].selected + } + m.curIndex = target - direction + m.Move(direction) + return nil +} + +// MarkSelected selects or unselects depending on 'mark' +// amount = 0 changes the current item but does not move the curser +// if amount would be outside the list error is not nil +// else all items till but excluding the end curser position +func (m *Model) MarkSelected(amount int, mark bool) error { + cur := m.curIndex + if amount == 0 { + m.listItems[cur].selected = mark + return nil + } + direction := 1 + if amount < 0 { + direction = -1 + } + + target := cur + amount - direction + if !m.CheckWithinBorder(target) { + return fmt.Errorf("Cant go beyond list borders: %d", target) + } + for c := 0; c < amount*direction; c++ { + m.listItems[cur+c].selected = mark + } + m.curIndex = target + m.Move(direction) + return nil +} + +// ToggleAllSelected inverts the select state of ALL items +func (m *Model) ToggleAllSelected() { + for i := range m.listItems { + m.listItems[i].selected = !m.listItems[i].selected + } +} + // Top moves the cursor to the first line func (m *Model) Top() { m.visibleOffset = 0 @@ -360,7 +424,7 @@ func (m *Model) Top() { func (m *Model) Bottom() { end := len(m.listItems) - 1 m.curIndex = end - maxVisItems := m.Viewport.Height - m.lineCurserOffset + maxVisItems := m.Height - m.lineCurserOffset var visLines, smallestVisIndex int for c := end; visLines < maxVisItems; c-- { if c < 0 { @@ -406,6 +470,42 @@ func (m *Model) SetLess(less func(string, string) bool) { } // Sort sorts the listitems acording to the set less function +// The current Item will maybe change! +// Since the index of the current pointer does not change func (m *Model) Sort() { sort.Sort(m) } + +// MoveItem moves the current item by amount to the end +// So: MoveItem(1) Moves the Item towards the end by one +// and MoveItem(-1) Moves the Item towards the beginning +// MoveItem(0) savely does nothing +// and a amount that would result outside the list returns a error != nil +func (m *Model) MoveItem(amount int) error { + if amount == 0 { + return nil + } + cur := m.curIndex + target := cur + amount + if !m.CheckWithinBorder(target) { + return fmt.Errorf("Cant move outside the list: %d", target) + } + m.Swap(cur, target) + m.curIndex = target + return nil +} + +// CheckWithinBorder returns true if the give index is within the list borders +func (m *Model) CheckWithinBorder(index int) bool { + length := len(m.listItems) + if index >= length || index < 0 { + return false + } + return true +} + +// AddDataItem adds a Item with the given interface{} value added to the List item +// So that when sorting, the connection between the string and the interfave{} value stays. +//func (m *Model) AddDataItem(content string, data interface{}) { +// m.listItems = append(m.listItems, item{content: content, userValue: data}) +//} diff --git a/list/list_test.go b/list/list_test.go index 00b0a10f5..660784a73 100644 --- a/list/list_test.go +++ b/list/list_test.go @@ -27,11 +27,11 @@ func TestViewBounds(t *testing.T) { for _, testM := range genModels(genTestModels()) { for i, line := range strings.Split(View(testM.model), "\n") { lineWidth := ansi.PrintableRuneWidth(line) - width := testM.model.Viewport.Width + width := testM.model.Width if lineWidth > width { t.Errorf("The line:\n\n%s\n%s^\n\n is %d chars longer than the Viewport width.", line, strings.Repeat(" ", width-1), lineWidth-width) } - if i > testM.model.Viewport.Height { + if i > testM.model.Height { t.Error("There are more lines produced from the View() than the Viewport height") } } @@ -82,8 +82,8 @@ func genModels(rawLists []test) []testModel { processedList := make([]testModel, len(rawLists)) for i, list := range rawLists { m := NewModel() - m.Viewport.Height = list.vHeight - m.Viewport.Width = list.vWidth + m.Height = list.vHeight + m.Width = list.vWidth m.AddItems(list.items) newItem := testModel{model: m, shouldBe: list.shouldBe} processedList[i] = newItem @@ -165,13 +165,13 @@ func genPanicTests() []test { // genDynamicModels generats test cases for dynamic actions like movement, sorting, resizing func genDynamicModels() []testModel { moveBottom := NewModel() - moveBottom.Viewport.Width = 10 - moveBottom.Viewport.Height = 10 + moveBottom.Width = 10 + moveBottom.Height = 10 moveBottom.AddItems([]string{"", "", "", ""}) moveBottom.Bottom() moveDown := NewModel() - moveDown.Viewport.Height = 50 - moveDown.Viewport.Width = 80 + moveDown.Height = 50 + moveDown.Width = 80 moveDown.AddItems([]string{"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""}) moveDown.curIndex = 45 // set cursor next to line Offset Border so that the down move, should move the hole visible area. moveDown.Down() @@ -236,3 +236,20 @@ func genDynamicModels() []testModel { }, } } + +func TestCheckBorder(t *testing.T) { + m := NewModel() + m.AddItems([]string{"", "", "", ""}) + if !m.CheckWithinBorder(0) { + t.Errorf("zero is not out of border") + } + if !m.CheckWithinBorder(len(m.listItems) - 1) { + t.Errorf("lasitem is not out of border") + } + if m.CheckWithinBorder(-1) { + t.Errorf("-1 is out of border") + } + if m.CheckWithinBorder(len(m.listItems)) { + t.Errorf("len(list) is out of border") + } +} From 5aebe7de8bdbb3482a9513c2f03b6a04b64929a4 Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Wed, 4 Nov 2020 10:31:49 +0100 Subject: [PATCH 23/73] Implemented Lines(), fixed Move methode - made CurserOffset public because its config - Implemented Lines and changed View to use Lines - fixed Move methode to honore the Cursor-borders --- list/list.go | 71 +++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 53 insertions(+), 18 deletions(-) diff --git a/list/list.go b/list/list.go index 42d4a50ba..28bf62920 100644 --- a/list/list.go +++ b/list/list.go @@ -1,7 +1,6 @@ package list import ( - "bytes" "fmt" tea "github.com/charmbracelet/bubbletea" "github.com/muesli/reflow/ansi" @@ -15,11 +14,12 @@ import ( type Model struct { focus bool - listItems []item - curIndex int // curser - visibleOffset int // begin of the visible lines - lineCurserOffset int // offset or margin between the cursor and the viewport(visible) border - less func(k, l string) bool // function used for sorting + listItems []item + curIndex int // curser + visibleOffset int // begin of the visible lines + less func(k, l string) bool // function used for sorting + + CurserOffset int // offset or margin between the cursor and the viewport(visible) border Width int Height int @@ -62,10 +62,14 @@ func (i item) genVisLines(wrapTo int) item { // View renders the Lst to a (displayable) string func (m *Model) View() string { - width := m.Viewport.Width + return strings.Join(m.Lines(), "\n") +} +// Lines returns the Visible lines of the list items +// used to display the current user interface +func (m *Model) Lines() []string { // check visible area - height := m.Height - 1 // TODO question: why does the first line get cut of, if i ommit the -1? + height := m.Height width := m.Width offset := m.visibleOffset if height*width <= 0 { @@ -120,7 +124,7 @@ func (m *Model) View() string { } var visLines int - var holeString bytes.Buffer + stringLines := make([]string, 0, height) out: // Handle list items, start at first visible and go till end of list or visible (break) for index := offset; index < len(m.listItems); index++ { @@ -171,8 +175,7 @@ out: line := fmt.Sprintf("%s%s", linePrefix, item.wrapedLines[0]) // Highlight and write first line - holeString.WriteString(style.Styled(line)) - holeString.WriteString("\n") + stringLines = append(stringLines, style.Styled(line)) visLines++ // Only write lines that are visible @@ -192,17 +195,16 @@ out: padLine := fmt.Sprintf("%s%s", wrapPrefix, line) // Highlight and write wraped line - holeString.WriteString(style.Styled(padLine)) - holeString.WriteString("\n") + stringLines = append(stringLines, style.Styled(padLine)) visLines++ // Only write lines that are visible - if visLines >= height { + if visLines > height { break out } } } - return holeString.String() + return stringLines } // lineNumber returns line number of the given index @@ -317,16 +319,49 @@ func (m *Model) Up() error { // Move moves the cursor by amount, does nothing if amount is 0 // and returns error != nil if amount gos beyond list borders +// or if the CurserOffset is greater than half of the display height func (m *Model) Move(amount int) error { + // do nothing if amount == 0 { return nil } + var err error + curOff := m.CurserOffset + visOff := m.visibleOffset + height := m.Height + if curOff >= height/2 { + curOff = 0 + err = fmt.Errorf("cursor offset must be less than halfe of the display height: setting it to zero") + } + target := m.curIndex + amount if !m.CheckWithinBorder(target) { return fmt.Errorf("Cant move outside the list: %d", target) } + // move visible part of list if Curser is going beyond border. + lowerBorder := height + visOff - curOff + upperBorder := visOff + curOff + + direction := 1 + if amount < 0 { + direction = -1 + } + + // visible Down movement + if direction > 0 && target > lowerBorder { + visOff = target - (height - curOff) + } + // visible Up movement + if direction < 0 && target < upperBorder { + visOff = target - curOff + } + // dont go infront of list begin + if visOff < 0 { + visOff = 0 + } m.curIndex = target - return nil + m.visibleOffset = visOff + return err } // NewModel returns a Model with some save/sane defaults @@ -334,7 +369,7 @@ func NewModel() Model { p := termenv.ColorProfile() style := termenv.Style{}.Background(p.Color("#ff0000")) return Model{ - lineCurserOffset: 5, + CurserOffset: 5, Wrap: true, @@ -424,7 +459,7 @@ func (m *Model) Top() { func (m *Model) Bottom() { end := len(m.listItems) - 1 m.curIndex = end - maxVisItems := m.Height - m.lineCurserOffset + maxVisItems := m.Height - m.CurserOffset var visLines, smallestVisIndex int for c := end; visLines < maxVisItems; c-- { if c < 0 { From e8bc6b5fdeeb0f021deaaca05589b8d0a82731d8 Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Wed, 4 Nov 2020 10:31:49 +0100 Subject: [PATCH 24/73] changed Style handling and implemented jump - Changed Style fields in list Model struct - added jump to example movement --- list/example/main.go | 29 +++++++++++++++++++++++++++-- list/list.go | 23 ++++++++++++----------- 2 files changed, 39 insertions(+), 13 deletions(-) diff --git a/list/example/main.go b/list/example/main.go index b614fdb53..7ff7369fb 100644 --- a/list/example/main.go +++ b/list/example/main.go @@ -5,6 +5,7 @@ import ( "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "os" + "strconv" "strings" ) @@ -13,6 +14,7 @@ type model struct { list list.Model finished bool endResult chan<- string + jump string } func main() { @@ -28,7 +30,8 @@ func main() { "When you print the items there will be a loss of information,", "since one can not say what was a line break within an item or what is a new item", "Use '+' or '-' to move the item under the curser up and down.", - "The 'v' inverts the selected state of each item.", + "The key 'v' inverts the selected state of each item.", + "To toggle betwen only absolute itemnumbers and also relativ numbers, the 'r' key is your friend.", } endResult := make(chan string, 1) @@ -87,10 +90,32 @@ func update(msg tea.Msg, mdl tea.Model) (tea.Model, tea.Cmd) { m.endResult <- "" return m, tea.Quit } - switch msg.String() { + switch keyString := msg.String(); keyString { case "q": m.endResult <- "" 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.Move(j) + return m, nil + case "up", "k": + j := 1 + if m.jump != "" { + j, _ = strconv.Atoi(m.jump) + m.jump = "" + } + m.list.Move(-j) + return m, nil + case "r": + m.list.NumberRelative = !m.list.NumberRelative + return m, nil } // Enter prints the selected lines to StdOut diff --git a/list/list.go b/list/list.go index 28bf62920..26a4f2df9 100644 --- a/list/list.go +++ b/list/list.go @@ -34,10 +34,9 @@ type Model struct { Number bool NumberRelative bool - LineForeGroundStyle termenv.Style - LineBackGroundStyle termenv.Style - SelectedForeGroundStyle termenv.Style - SelectedBackGroundStyle termenv.Style + LineStyle termenv.Style + SelectedStyle termenv.Style + CurrentStyle termenv.Style } // Item are Items used in the list Model @@ -151,18 +150,18 @@ out: } // Selecting: handel highlighting and prefixing of selected lines - selString := prepad // assume not selected - style := termenv.String() // create empty style + selString := prepad + style := m.LineStyle if item.selected { - style = m.SelectedBackGroundStyle // fill style - selString = prefix // change if selected + style = m.SelectedStyle + selString = prefix } // Current: handel highlighting of current item/first-line curPad := sufpad if index == m.curIndex { - style = style.Reverse() + style = m.CurrentStyle curPad = suffix } @@ -367,7 +366,8 @@ func (m *Model) Move(amount int) error { // NewModel returns a Model with some save/sane defaults func NewModel() Model { p := termenv.ColorProfile() - style := termenv.Style{}.Background(p.Color("#ff0000")) + selStyle := termenv.Style{}.Background(p.Color("#ff0000")) + curStyle := termenv.Style{}.Reverse() return Model{ CurserOffset: 5, @@ -383,7 +383,8 @@ func NewModel() Model { return k < l }, - SelectedBackGroundStyle: style, + SelectedStyle: selStyle, + CurrentStyle: curStyle, } } From da31b5fe02ae26e210f9717a4442dc54b06eca06 Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Wed, 4 Nov 2020 10:31:50 +0100 Subject: [PATCH 25/73] renaming local variables, add configurability, changing test samples - added field for costumising the unselected marker - added WrapPrefix to turn of the selected marker by wraped lines - renamed some varibales to (hopefully) better names - turned of wraping of lines when wrap is not enabled - added Focus to be able to ignore keypresses Warning: You can still change the state by surpassing the Update and use the Methodes directly! this raises the question if focus should also affect the Methodes and if false return a error.? - removed newlines from the ends of the (golden) test samples, since it would subtract a line from the View's height --- list/example/main.go | 3 ++ list/list.go | 98 ++++++++++++++++++++++++++++++++++---------- list/list_test.go | 12 +++--- 3 files changed, 84 insertions(+), 29 deletions(-) diff --git a/list/example/main.go b/list/example/main.go index 7ff7369fb..6c10ba69e 100644 --- a/list/example/main.go +++ b/list/example/main.go @@ -63,6 +63,9 @@ func main() { func initialize(lineList []string, endResult chan<- string) func() (tea.Model, tea.Cmd) { l := list.NewModel() l.AddItems(lineList) + // l.WrapPrefix = false // uncomment for fancy check (selected) box :-) + // l.SelectedPrefix = " [x]" + // l.UnSelectedPrefix = "[ ]" return func() (tea.Model, tea.Cmd) { return model{list: l, endResult: endResult}, nil } } diff --git a/list/list.go b/list/list.go index 26a4f2df9..897280868 100644 --- a/list/list.go +++ b/list/list.go @@ -26,10 +26,13 @@ type Model struct { Wrap bool - SelectedPrefix string - Seperator string - SeperatorWrap string - CurrentMarker string + SelectedPrefix string + UnSelectedPrefix string + Seperator string + SeperatorWrap string + CurrentMarker string + + WrapPrefix bool Number bool NumberRelative bool @@ -94,20 +97,34 @@ func (m *Model) Lines() []string { // pad all prefixes to the same width for easy exchange prefix := m.SelectedPrefix - preWidth := ansi.PrintableRuneWidth(prefix) - prepad := strings.Repeat(" ", preWidth) + prepad := m.UnSelectedPrefix + preWid := ansi.PrintableRuneWidth(prefix) + tmpWid := ansi.PrintableRuneWidth(prepad) + + preWidth := preWid + if tmpWid > preWidth { + preWidth = tmpWid + } + prefix = strings.Repeat(" ", preWidth-preWid) + prefix + + wrapPrePad := prepad + if !m.WrapPrefix { + wrapPrePad = strings.Repeat(" ", preWidth) + } + + prepad = strings.Repeat(" ", preWidth-tmpWid) + prepad // pad all seperators to the same width for easy exchange sepItem := strings.Repeat(" ", sepWidth-widthItem) + m.Seperator sepWrap := strings.Repeat(" ", sepWidth-widthWrap) + m.SeperatorWrap // pad right of prefix, with lenght of current pointer - suffix := m.CurrentMarker - sufWidth := ansi.PrintableRuneWidth(suffix) - sufpad := strings.Repeat(" ", sufWidth) + mark := m.CurrentMarker + markWidth := ansi.PrintableRuneWidth(mark) + unmark := strings.Repeat(" ", markWidth) // Get the hole prefix width - holePrefixWidth := numWidth + preWidth + sepWidth + sufWidth + holePrefixWidth := numWidth + preWidth + sepWidth + markWidth // Get actual content width contentWidth := width - holePrefixWidth @@ -117,9 +134,13 @@ func (m *Model) Lines() []string { panic("Can't display with zero width for content") } - // renew wrap of all items TODO check if to slow - for i := range m.listItems { - m.listItems[i] = m.listItems[i].genVisLines(contentWidth) + // If set + wrap := m.Wrap + if wrap { + // renew wrap of all items + for i := range m.listItems { + m.listItems[i] = m.listItems[i].genVisLines(contentWidth) + } } var visLines int @@ -133,7 +154,7 @@ out: } item := m.listItems[index] - if item.wrapedLenght <= 0 { + if wrap && item.wrapedLenght <= 0 { panic("cant display item with no visible content") } @@ -159,15 +180,19 @@ out: } // Current: handel highlighting of current item/first-line - curPad := sufpad + curPad := unmark if index == m.curIndex { style = m.CurrentStyle - curPad = suffix + curPad = mark } // join all prefixes - linePrefix := strings.Join([]string{firstPad, selString, sepItem, curPad}, "") - wrapPrefix := strings.Join([]string{wrapPad, selString, sepWrap, sufpad}, "") // dont prefix wrap lines with CurrentMarker (suffix) + var wrapPrefix, linePrefix string + + linePrefix = strings.Join([]string{firstPad, selString, sepItem, curPad}, "") + if wrap { + wrapPrefix = strings.Join([]string{wrapPad, wrapPrePad, sepWrap, unmark}, "") // dont prefix wrap lines with CurrentMarker (suffix) + } // join pad and first line content // NOTE linebreak is not added here because it would mess with the highlighting @@ -183,7 +208,7 @@ out: } // Dont write wraped lines if not set - if !m.Wrap || item.wrapedLenght <= 1 { + if !wrap || item.wrapedLenght <= 1 { continue } @@ -222,6 +247,9 @@ func lineNumber(relativ bool, curser, current int) int { // Update changes the Model of the List according to the messages recieved func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if !m.focus { + return m, nil + } var cmd tea.Cmd switch msg := msg.(type) { @@ -367,17 +395,28 @@ func (m *Model) Move(amount int) error { func NewModel() Model { p := termenv.ColorProfile() selStyle := termenv.Style{}.Background(p.Color("#ff0000")) + // just reverse colors to keep there information curStyle := termenv.Style{}.Reverse() return Model{ + // Accept keypresses + focus: true, + + // Try to keep $CurserOffset lines between Cursor and screen Border CurserOffset: 5, + // Wrap lines to have no loss of information Wrap: true, - Seperator: "╭", - SeperatorWrap: "│", + // Make clear where a item begins and where it ends + Seperator: "╭", + SeperatorWrap: "│", + + // Mark it so that even without color support all is explicit CurrentMarker: ">", SelectedPrefix: "*", - Number: true, + + // enable Linenumber + Number: true, less: func(k, l string) bool { return k < l @@ -545,3 +584,18 @@ func (m *Model) CheckWithinBorder(index int) bool { //func (m *Model) AddDataItem(content string, data interface{}) { // m.listItems = append(m.listItems, item{content: content, userValue: data}) //} + +// Focus sets the list Model focus so it accepts keyinput and responds to them +func (m *Model) Focus() { + m.focus = true +} + +// UnFocus removes the focus so that the list Model does NOT responed to key presses +func (m *Model) UnFocus() { + m.focus = false +} + +// Focused returns if the list Model is focused and acccepts keypresses +func (m *Model) Focused() bool { + return m.focus +} diff --git a/list/list_test.go b/list/list_test.go index 660784a73..d94592508 100644 --- a/list/list_test.go +++ b/list/list_test.go @@ -104,7 +104,7 @@ func genTestModels() []test { []string{ "", }, - "\x1b[7m0 ╭>\x1b[0m\n", + "\x1b[7m0 ╭>\x1b[0m", }, // if exceding the boards and softwrap (at word bounderys are possible // wrap there. Dont increment the item number because its still the same item. @@ -114,7 +114,7 @@ func genTestModels() []test { []string{ "robert frost", }, - "\x1b[7m0 ╭>robert\x1b[0m\n\x1b[7m │ frost\x1b[0m\n", + "\x1b[7m0 ╭>robert\x1b[0m\n\x1b[7m │ frost\x1b[0m", }, { 10, @@ -129,8 +129,7 @@ func genTestModels() []test { 6 ╭ 7 ╭ 8 ╭ -9 ╭ -`, +9 ╭ `, }, } } @@ -177,7 +176,7 @@ func genDynamicModels() []testModel { moveDown.Down() return []testModel{ {model: moveBottom, - shouldBe: "0 ╭ \n1 ╭ \n2 ╭ \n\x1b[7m3 ╭>\x1b[0m\n", + shouldBe: "0 ╭ \n1 ╭ \n2 ╭ \n\x1b[7m3 ╭>\x1b[0m", afterMethode: "Bottom", }, {model: moveDown, @@ -230,8 +229,7 @@ func genDynamicModels() []testModel { `47 ╭ 48 ╭ 49 ╭ -50 ╭ -`, +50 ╭ `, afterMethode: "Down", }, } From f283493f34955f193e29c96ed6c7d78c33b846ca Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Wed, 4 Nov 2020 10:31:50 +0100 Subject: [PATCH 26/73] changed to interface fmt.Stringer within item struct - changed List implementation from operating on string values, within of stuct item, to work with interface: fmt.Stringer To allow *inherent* relation safety betwen user structs and displayed string! - added StringItem Struct to allow still for ease of use. - changed test and example accordingly - renamed some variables --- list/example/main.go | 26 +++++-- list/list.go | 161 ++++++++++++++++++++++++------------------- list/list_test.go | 8 +-- 3 files changed, 115 insertions(+), 80 deletions(-) diff --git a/list/example/main.go b/list/example/main.go index 6c10ba69e..ed6b3c979 100644 --- a/list/example/main.go +++ b/list/example/main.go @@ -1,12 +1,12 @@ package main import ( + "bytes" "fmt" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "os" "strconv" - "strings" ) type model struct { @@ -17,11 +17,17 @@ type model struct { jump string } +type stringItem string + +func (s stringItem) String() string { + return string(s) +} + func main() { - items := []string{ + itemList := []string{ "Welcome to the bubbles-list example!", "Use 'q' or 'ctrl-c' to quit!", - "You can move the highlighted index up and down with the (arrow) keys 'k' and 'j'.", + "You can move the highlighted index up and down with the (arrow keys 'k' and 'j'.", "Move to the beginning with 'g' and to the end with 'G'.", "Sort the entrys with 's', but be carefull you can't unsort it again.", "The list can handel linebreaks,\nand has wordwrap enabled if the line gets to long.", @@ -33,9 +39,11 @@ func main() { "The key 'v' inverts the selected state of each item.", "To toggle betwen only absolute itemnumbers and also relativ numbers, the 'r' key is your friend.", } + stringerList := list.MakeStringerList(itemList) + endResult := make(chan string, 1) - p := tea.NewProgram(initialize(items, endResult), update, view) + p := tea.NewProgram(initialize(stringerList, endResult), update, view) // Use the full size of the terminal in its "alternate screen buffer" fullScreen := false // change to true if you want fullscreen @@ -60,7 +68,7 @@ func main() { // initialize sets up the model and returns it to the bubbletea runtime // as a function result, so it can later be handed over to the update and view functions. -func initialize(lineList []string, endResult chan<- string) func() (tea.Model, tea.Cmd) { +func initialize(lineList []fmt.Stringer, endResult chan<- string) func() (tea.Model, tea.Cmd) { l := list.NewModel() l.AddItems(lineList) // l.WrapPrefix = false // uncomment for fancy check (selected) box :-) @@ -123,8 +131,12 @@ func update(msg tea.Msg, mdl tea.Model) (tea.Model, tea.Cmd) { // Enter prints the selected lines to StdOut if msg.Type == tea.KeyEnter { - result := strings.Join(m.list.GetSelected(), "\n") - m.endResult <- result + var result bytes.Buffer + for _, item := range m.list.GetSelected() { + result.WriteString(item.String()) + result.WriteString("\n") + } + m.endResult <- result.String() return m, tea.Quit } diff --git a/list/list.go b/list/list.go index 897280868..7069ff668 100644 --- a/list/list.go +++ b/list/list.go @@ -15,11 +15,11 @@ type Model struct { focus bool listItems []item - curIndex int // curser + curIndex int // cursor visibleOffset int // begin of the visible lines less func(k, l string) bool // function used for sorting - CurserOffset int // offset or margin between the cursor and the viewport(visible) border + CursorOffset int // offset or margin between the cursor and the viewport(visible) border Width int Height int @@ -43,20 +43,36 @@ type Model struct { } // Item are Items used in the list Model -// to hold the Content representat as a string +// to hold the Content represented as a string type item struct { selected bool - content string wrapedLines []string wrapedLenght int wrapedto int - userValue interface{} + value fmt.Stringer } -// genVisLines renews the wrap of the content into wraplines +// StringItem is just a convenience to have fast a version +// 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 +} + +// genVisLines renews the wrap of the content into wrapedLines func (i item) genVisLines(wrapTo int) item { - i.wrapedLines = strings.Split(wordwrap.String(i.content, wrapTo), "\n") - //TODO hardwrap lines/words + i.wrapedLines = strings.Split(wordwrap.String(i.value.String(), wrapTo), "\n") + //TODO hard wrap lines/words i.wrapedLenght = len(i.wrapedLines) i.wrapedto = wrapTo return i @@ -70,15 +86,16 @@ func (m *Model) View() string { // Lines returns the Visible lines of the list items // used to display the current user interface func (m *Model) Lines() []string { + // get public variables as locals so they can't change while using // check visible area height := m.Height width := m.Width - offset := m.visibleOffset if height*width <= 0 { panic("Can't display with zero width or hight of Viewport") } + offset := m.visibleOffset - // Get max seperator width + // Get separators width widthItem := ansi.PrintableRuneWidth(m.Seperator) widthWrap := ansi.PrintableRuneWidth(m.SeperatorWrap) @@ -96,35 +113,35 @@ func (m *Model) Lines() []string { } // pad all prefixes to the same width for easy exchange - prefix := m.SelectedPrefix - prepad := m.UnSelectedPrefix - preWid := ansi.PrintableRuneWidth(prefix) - tmpWid := ansi.PrintableRuneWidth(prepad) + selected := m.SelectedPrefix + unselect := m.UnSelectedPrefix + selWid := ansi.PrintableRuneWidth(selected) + tmpWid := ansi.PrintableRuneWidth(unselect) - preWidth := preWid - if tmpWid > preWidth { - preWidth = tmpWid + selectWidth := selWid + if tmpWid > selectWidth { + selectWidth = tmpWid } - prefix = strings.Repeat(" ", preWidth-preWid) + prefix + selected = strings.Repeat(" ", selectWidth-selWid) + selected - wrapPrePad := prepad + wrapPrePad := unselect if !m.WrapPrefix { - wrapPrePad = strings.Repeat(" ", preWidth) + wrapPrePad = strings.Repeat(" ", selectWidth) } - prepad = strings.Repeat(" ", preWidth-tmpWid) + prepad + unselect = strings.Repeat(" ", selectWidth-tmpWid) + unselect - // pad all seperators to the same width for easy exchange + // pad all separators to the same width for easy exchange sepItem := strings.Repeat(" ", sepWidth-widthItem) + m.Seperator sepWrap := strings.Repeat(" ", sepWidth-widthWrap) + m.SeperatorWrap - // pad right of prefix, with lenght of current pointer + // pad right of prefix, with length of current pointer mark := m.CurrentMarker markWidth := ansi.PrintableRuneWidth(mark) unmark := strings.Repeat(" ", markWidth) // Get the hole prefix width - holePrefixWidth := numWidth + preWidth + sepWidth + markWidth + holePrefixWidth := numWidth + selectWidth + sepWidth + markWidth // Get actual content width contentWidth := width - holePrefixWidth @@ -158,28 +175,28 @@ out: panic("cant display item with no visible content") } - // if a number is set, prepend firstline with number and both with enough spaces + // if a number is set, prepend first line with number and both with enough spaces firstPad := strings.Repeat(" ", numWidth) var wrapPad string if m.Number { lineNum := lineNumber(m.NumberRelative, m.curIndex, index) number := fmt.Sprintf("%d", lineNum) - // since diggets are only singel bytes, len is sufficent: + // since digits are only single bytes, len is sufficient: firstPad = strings.Repeat(" ", numWidth-len(number)) + number - // pad wraped lines + // pad wrapped lines wrapPad = strings.Repeat(" ", numWidth) } - // Selecting: handel highlighting and prefixing of selected lines - selString := prepad + // Selecting: handle highlighting and prefixing of selected lines + selString := unselect style := m.LineStyle if item.selected { style = m.SelectedStyle - selString = prefix + selString = selected } - // Current: handel highlighting of current item/first-line + // Current: handle highlighting of current item/first-line curPad := unmark if index == m.curIndex { style = m.CurrentStyle @@ -191,12 +208,18 @@ out: linePrefix = strings.Join([]string{firstPad, selString, sepItem, curPad}, "") if wrap { - wrapPrefix = strings.Join([]string{wrapPad, wrapPrePad, sepWrap, unmark}, "") // dont prefix wrap lines with CurrentMarker (suffix) + wrapPrefix = strings.Join([]string{wrapPad, wrapPrePad, sepWrap, unmark}, "") // don't prefix wrap lines with CurrentMarker (unmark) + } + + content := item.wrapedLines[0] + if !wrap { + content = item.value.String() + // TODO hard limit the string length } // join pad and first line content - // NOTE linebreak is not added here because it would mess with the highlighting - line := fmt.Sprintf("%s%s", linePrefix, item.wrapedLines[0]) + // NOTE line break is not added here because it would mess with the highlighting + line := fmt.Sprintf("%s%s", linePrefix, content) // Highlight and write first line stringLines = append(stringLines, style.Styled(line)) @@ -207,18 +230,18 @@ out: break out } - // Dont write wraped lines if not set + // Don't write wrapped lines if not set if !wrap || item.wrapedLenght <= 1 { continue } - // Write wraped lines + // Write wrapped lines for _, line := range item.wrapedLines[1:] { // Pad left of line - // NOTE linebreak is not added here because it would mess with the highlighting + // NOTE line break is not added here because it would mess with the highlighting padLine := fmt.Sprintf("%s%s", wrapPrefix, line) - // Highlight and write wraped line + // Highlight and write wrapped line stringLines = append(stringLines, style.Styled(padLine)) visLines++ @@ -232,7 +255,7 @@ out: } // lineNumber returns line number of the given index -// and if relative is true the absolute difference to the curser +// and if relative is true the absolute difference to the cursor func lineNumber(relativ bool, curser, current int) int { if !relativ || curser == current { return current @@ -321,19 +344,19 @@ func (m Model) Init() tea.Cmd { return nil } -// AddItems addes the given Items to the list Model +// AddItems adds the given Items to the list Model // Without performing updating the View TODO -func (m *Model) AddItems(itemList []string) { +func (m *Model) AddItems(itemList []fmt.Stringer) { for _, i := range itemList { m.listItems = append(m.listItems, item{ selected: false, - content: i}, + value: i}, ) } } // Down moves the "cursor" or current line down. -// If the end is allready reached err is not nil. +// If the end is already reached err is not nil. func (m *Model) Down() error { return m.Move(1) } @@ -345,15 +368,15 @@ func (m *Model) Up() error { } // Move moves the cursor by amount, does nothing if amount is 0 -// and returns error != nil if amount gos beyond list borders -// or if the CurserOffset is greater than half of the display height +// and returns error != nil if amount go's beyond list borders +// or if the CursorOffset is greater than half of the display height func (m *Model) Move(amount int) error { // do nothing if amount == 0 { return nil } var err error - curOff := m.CurserOffset + curOff := m.CursorOffset visOff := m.visibleOffset height := m.Height if curOff >= height/2 { @@ -365,7 +388,7 @@ func (m *Model) Move(amount int) error { if !m.CheckWithinBorder(target) { return fmt.Errorf("Cant move outside the list: %d", target) } - // move visible part of list if Curser is going beyond border. + // move visible part of list if Cursor is going beyond border. lowerBorder := height + visOff - curOff upperBorder := visOff + curOff @@ -382,7 +405,7 @@ func (m *Model) Move(amount int) error { if direction < 0 && target < upperBorder { visOff = target - curOff } - // dont go infront of list begin + // don't go in front of list begin if visOff < 0 { visOff = 0 } @@ -398,11 +421,11 @@ func NewModel() Model { // just reverse colors to keep there information curStyle := termenv.Style{}.Reverse() return Model{ - // Accept keypresses + // Accept key presses focus: true, - // Try to keep $CurserOffset lines between Cursor and screen Border - CurserOffset: 5, + // Try to keep $CursorOffset lines between Cursor and screen Border + CursorOffset: 5, // Wrap lines to have no loss of information Wrap: true, @@ -429,9 +452,9 @@ func NewModel() Model { // ToggleSelect toggles the selected status // of the current Index if amount is 0 -// returns err != nil when amount lands outside list and savely does nothing -// else if amount is not 0 toggels selected amount items -// excluding the item on which the curser lands +// returns err != nil when amount lands outside list and safely does nothing +// else if amount is not 0 toggles selected amount items +// excluding the item on which the cursor lands func (m *Model) ToggleSelect(amount int) error { if amount == 0 { m.listItems[m.curIndex].selected = !m.listItems[m.curIndex].selected @@ -456,9 +479,9 @@ func (m *Model) ToggleSelect(amount int) error { } // MarkSelected selects or unselects depending on 'mark' -// amount = 0 changes the current item but does not move the curser +// amount = 0 changes the current item but does not move the cursor // if amount would be outside the list error is not nil -// else all items till but excluding the end curser position +// else all items till but excluding the end cursor position func (m *Model) MarkSelected(amount int, mark bool) error { cur := m.curIndex if amount == 0 { @@ -499,7 +522,7 @@ func (m *Model) Top() { func (m *Model) Bottom() { end := len(m.listItems) - 1 m.curIndex = end - maxVisItems := m.Height - m.CurserOffset + maxVisItems := m.Height - m.CursorOffset var visLines, smallestVisIndex int for c := end; visLines < maxVisItems; c-- { if c < 0 { @@ -513,28 +536,28 @@ func (m *Model) Bottom() { // GetSelected returns you a list of all items // that are selected in current (displayed) order -func (m *Model) GetSelected() []string { - var selected []string +func (m *Model) GetSelected() []fmt.Stringer { + var selected []fmt.Stringer for _, item := range m.listItems { if item.selected { - selected = append(selected, item.content) + selected = append(selected, item.value) } } return selected } // Less is a Proxy to the less function, set from the user. -// Swap is used to fullfill the Sort-interface +// Swap is used to fulfill the Sort-interface func (m *Model) Less(i, j int) bool { - return m.less(m.listItems[i].content, m.listItems[j].content) + return m.less(m.listItems[i].value.String(), m.listItems[j].value.String()) } -// Swap is used to fullfill the Sort-interface +// Swap is used to fulfill the Sort-interface func (m *Model) Swap(i, j int) { m.listItems[i], m.listItems[j] = m.listItems[j], m.listItems[i] } -// Len is used to fullfill the Sort-interface +// Len is used to fulfill the Sort-interface func (m *Model) Len() int { return len(m.listItems) } @@ -544,7 +567,7 @@ func (m *Model) SetLess(less func(string, string) bool) { m.less = less } -// Sort sorts the listitems acording to the set less function +// Sort sorts the listitems according to the set less function // The current Item will maybe change! // Since the index of the current pointer does not change func (m *Model) Sort() { @@ -554,7 +577,7 @@ func (m *Model) Sort() { // MoveItem moves the current item by amount to the end // So: MoveItem(1) Moves the Item towards the end by one // and MoveItem(-1) Moves the Item towards the beginning -// MoveItem(0) savely does nothing +// MoveItem(0) safely does nothing // and a amount that would result outside the list returns a error != nil func (m *Model) MoveItem(amount int) error { if amount == 0 { @@ -585,17 +608,17 @@ func (m *Model) CheckWithinBorder(index int) bool { // m.listItems = append(m.listItems, item{content: content, userValue: data}) //} -// Focus sets the list Model focus so it accepts keyinput and responds to them +// Focus sets the list Model focus so it accepts key input and responds to them func (m *Model) Focus() { m.focus = true } -// UnFocus removes the focus so that the list Model does NOT responed to key presses +// UnFocus removes the focus so that the list Model does NOT respond to key presses func (m *Model) UnFocus() { m.focus = false } -// Focused returns if the list Model is focused and acccepts keypresses +// Focused returns if the list Model is focused and accepts key presses func (m *Model) Focused() bool { return m.focus } diff --git a/list/list_test.go b/list/list_test.go index d94592508..b6bedb0f3 100644 --- a/list/list_test.go +++ b/list/list_test.go @@ -84,7 +84,7 @@ func genModels(rawLists []test) []testModel { m := NewModel() m.Height = list.vHeight m.Width = list.vWidth - m.AddItems(list.items) + m.AddItems(MakeStringerList(list.items)) newItem := testModel{model: m, shouldBe: list.shouldBe} processedList[i] = newItem } @@ -166,12 +166,12 @@ func genDynamicModels() []testModel { moveBottom := NewModel() moveBottom.Width = 10 moveBottom.Height = 10 - moveBottom.AddItems([]string{"", "", "", ""}) + moveBottom.AddItems(MakeStringerList([]string{"", "", "", ""})) moveBottom.Bottom() moveDown := NewModel() moveDown.Height = 50 moveDown.Width = 80 - moveDown.AddItems([]string{"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""}) + moveDown.AddItems(MakeStringerList([]string{"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""})) moveDown.curIndex = 45 // set cursor next to line Offset Border so that the down move, should move the hole visible area. moveDown.Down() return []testModel{ @@ -237,7 +237,7 @@ func genDynamicModels() []testModel { func TestCheckBorder(t *testing.T) { m := NewModel() - m.AddItems([]string{"", "", "", ""}) + m.AddItems(MakeStringerList([]string{"", "", "", ""})) if !m.CheckWithinBorder(0) { t.Errorf("zero is not out of border") } From 9affa83d276a92aa1b8a002109cc92c2cbebb971 Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Wed, 4 Nov 2020 10:31:51 +0100 Subject: [PATCH 27/73] Added "Equals" handling and Error types - added SetEquals methode to allow user to add a equals function so that after sorting the cursor is again at the same item. Changed Sort Methode accordingly and added some more related methodes - added Error typs for better handling of errors on user side - added outcommented example of SelectedPrefix - Outcommented Up and Down Methods since the are redundant with Move(-1/1). - Updated some Doc-strings - fixed bug within selected prefix - added jump cases to example for marking actions --- list/example/main.go | 60 +++++++++++++----- list/list.go | 142 ++++++++++++++++++++++++++++++++----------- list/list_test.go | 2 +- 3 files changed, 153 insertions(+), 51 deletions(-) diff --git a/list/example/main.go b/list/example/main.go index ed6b3c979..7e6738fc0 100644 --- a/list/example/main.go +++ b/list/example/main.go @@ -61,9 +61,8 @@ func main() { } res := <-endResult - if res != "" { - fmt.Println(res) - } + // allways print a newline even on empty string result + fmt.Println(res) } // initialize sets up the model and returns it to the bubbletea runtime @@ -71,10 +70,16 @@ func main() { func initialize(lineList []fmt.Stringer, endResult chan<- string) func() (tea.Model, tea.Cmd) { l := list.NewModel() l.AddItems(lineList) - // l.WrapPrefix = false // uncomment for fancy check (selected) box :-) + // uncomment the following lines for fancy check (selected) box :-) + // l.WrapPrefix = false // l.SelectedPrefix = " [x]" // l.UnSelectedPrefix = "[ ]" + // Since in this example we only use UNIQUE string items we can use a String Comparison for the equals methode + // but be aware that different items in your case can have the same string -> false-positiv + // Better: Assert back to your struct and test on something unique within it! + l.SetEquals(func(first, second fmt.Stringer) bool { return first.String() == second.String() }) + return func() (tea.Model, tea.Cmd) { return model{list: l, endResult: endResult}, nil } } @@ -127,10 +132,33 @@ func update(msg tea.Msg, mdl tea.Model) (tea.Model, tea.Cmd) { case "r": m.list.NumberRelative = !m.list.NumberRelative return m, nil - } - - // Enter prints the selected lines to StdOut - if msg.Type == tea.KeyEnter { + case "m": + j := 1 + if m.jump != "" { + j, _ = strconv.Atoi(m.jump) + m.jump = "" + } + m.list.MarkSelected(j, true) + return m, nil + case "M": + j := 1 + if m.jump != "" { + j, _ = strconv.Atoi(m.jump) + m.jump = "" + } + m.list.MarkSelected(j, false) + return m, nil + case " ": + j := 1 + if m.jump != "" { + j, _ = strconv.Atoi(m.jump) + m.jump = "" + } + m.list.ToggleSelect(j) + m.list.Move(1) + return m, nil + case "enter": + // Enter prints the selected lines to StdOut var result bytes.Buffer for _, item := range m.list.GetSelected() { result.WriteString(item.String()) @@ -138,21 +166,23 @@ func update(msg tea.Msg, mdl tea.Model) (tea.Model, tea.Cmd) { } m.endResult <- result.String() return m, tea.Quit + default: + // resets jump buffer to prevent confusion + m.jump = "" + + // pipe all other commands to the update from the list + list, newMsg := list.Update(msg, m.list) + m.list = list + return m, newMsg } - // pipe all other commands to the update from the list - list, newMsg := list.Update(msg, m.list) - m.list = list - - return m, newMsg - case tea.WindowSizeMsg: m.list.Width = msg.Width m.list.Height = msg.Height if !m.ready { - // Since this program is using the full size of the viewport we need + // 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 diff --git a/list/list.go b/list/list.go index 7069ff668..005cf3687 100644 --- a/list/list.go +++ b/list/list.go @@ -10,14 +10,27 @@ import ( "strings" ) +// NotFound gets return if the search does not yield a result +type NotFound error + +// OutOfBounds is return if and index is outside the list bounderys +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 Modul +type ConfigError error + // Model is a bubbletea List of strings type Model struct { focus bool listItems []item - curIndex int // cursor - visibleOffset int // begin of the visible lines - less func(k, l string) bool // function used for sorting + curIndex int // cursor + visibleOffset int // begin of the visible lines + less func(string, string) bool // function used for sorting + equals func(fmt.Stringer, fmt.Stringer) bool // to be set from the user CursorOffset int // offset or margin between the cursor and the viewport(visible) border @@ -32,7 +45,7 @@ type Model struct { SeperatorWrap string CurrentMarker string - WrapPrefix bool + PrefixWrap bool Number bool NumberRelative bool @@ -124,9 +137,11 @@ func (m *Model) Lines() []string { } selected = strings.Repeat(" ", selectWidth-selWid) + selected - wrapPrePad := unselect - if !m.WrapPrefix { - wrapPrePad = strings.Repeat(" ", selectWidth) + wrapSelectPad := strings.Repeat(" ", selectWidth) + wrapUnSelePad := strings.Repeat(" ", selectWidth) + if m.PrefixWrap { + wrapSelectPad = strings.Repeat(" ", selectWidth-selWid) + selected + wrapUnSelePad = strings.Repeat(" ", selectWidth-tmpWid) + unselect } unselect = strings.Repeat(" ", selectWidth-tmpWid) + unselect @@ -191,9 +206,11 @@ out: selString := unselect style := m.LineStyle + wrapPrePad := wrapUnSelePad if item.selected { style = m.SelectedStyle selString = selected + wrapPrePad = wrapSelectPad } // Current: handle highlighting of current item/first-line @@ -256,6 +273,7 @@ out: // 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 @@ -292,7 +310,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case " ": m.ToggleSelect(1) - m.Down() + m.Move(1) return m, nil case "g": m.Top() @@ -330,10 +348,10 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.MouseMsg: switch msg.Button { case tea.MouseWheelUp: - m.Up() + m.Move(-1) case tea.MouseWheelDown: - m.Down() + m.Move(1) } } return m, nil @@ -357,19 +375,19 @@ func (m *Model) AddItems(itemList []fmt.Stringer) { // Down moves the "cursor" or current line down. // If the end is already reached err is not nil. -func (m *Model) Down() error { - return m.Move(1) -} +//func (m *Model) Down() error { +// return m.Move(1) +//} // Up moves the "cursor" or current line up. // If the start is already reached, err is not nil. -func (m *Model) Up() error { - return m.Move(-1) -} +//func (m *Model) Up() error { +// return m.Move(-1) +//} // Move moves the cursor by amount, does nothing if amount is 0 -// and returns error != nil if amount go's beyond list borders -// or if the CursorOffset is greater than half of the display height +// and returns OutOfBounds error if amount go's beyond list borders +// or if the CursorOffset is greater than half of the display height returns ConfigError func (m *Model) Move(amount int) error { // do nothing if amount == 0 { @@ -381,12 +399,12 @@ func (m *Model) Move(amount int) error { height := m.Height if curOff >= height/2 { curOff = 0 - err = fmt.Errorf("cursor offset must be less than halfe of the display height: setting it to zero") + err = ConfigError(fmt.Errorf("cursor offset must be less than halfe of the display height: setting it to zero")) } target := m.curIndex + amount if !m.CheckWithinBorder(target) { - return fmt.Errorf("Cant move outside the list: %d", target) + return OutOfBounds(fmt.Errorf("Cant move outside the list: %d", target)) } // move visible part of list if Cursor is going beyond border. lowerBorder := height + visOff - curOff @@ -415,6 +433,7 @@ func (m *Model) Move(amount int) error { } // NewModel returns a Model with some save/sane defaults +// design to transfer as much internal information to the user func NewModel() Model { p := termenv.ColorProfile() selStyle := termenv.Style{}.Background(p.Color("#ff0000")) @@ -428,7 +447,8 @@ func NewModel() Model { CursorOffset: 5, // Wrap lines to have no loss of information - Wrap: true, + Wrap: true, + PrefixWrap: true, // Make clear where a item begins and where it ends Seperator: "╭", @@ -468,7 +488,7 @@ func (m *Model) ToggleSelect(amount int) error { cur := m.curIndex target := cur + amount - direction if !m.CheckWithinBorder(target) { - return fmt.Errorf("Cant go beyond list borders: %d", target) + return OutOfBounds(fmt.Errorf("Cant go beyond list borders: %d", target)) } for c := 0; c < amount*direction; c++ { m.listItems[cur+c].selected = !m.listItems[cur+c].selected @@ -480,8 +500,8 @@ func (m *Model) ToggleSelect(amount int) error { // MarkSelected selects or unselects depending on 'mark' // amount = 0 changes the current item but does not move the cursor -// if amount would be outside the list error is not nil -// else all items till but excluding the end cursor position +// if amount would be outside the list error is from type OutOfBounds +// else all items till but excluding the end cursor position gets (un-)marked func (m *Model) MarkSelected(amount int, mark bool) error { cur := m.curIndex if amount == 0 { @@ -495,7 +515,7 @@ func (m *Model) MarkSelected(amount int, mark bool) error { target := cur + amount - direction if !m.CheckWithinBorder(target) { - return fmt.Errorf("Cant go beyond list borders: %d", target) + return OutOfBounds(fmt.Errorf("Cant go beyond list borders: %d", target)) } for c := 0; c < amount*direction; c++ { m.listItems[cur+c].selected = mark @@ -547,7 +567,6 @@ func (m *Model) GetSelected() []fmt.Stringer { } // Less is a Proxy to the less function, set from the user. -// Swap is used to fulfill the Sort-interface func (m *Model) Less(i, j int) bool { return m.less(m.listItems[i].value.String(), m.listItems[j].value.String()) } @@ -567,11 +586,26 @@ func (m *Model) SetLess(less func(string, string) bool) { m.less = less } -// Sort sorts the listitems according to the set less function -// The current Item will maybe change! +// Sort sorts the list items according to the set less-function +// If there is no Equals-function set (with SetEquals), the current Item will maybe change! // Since the index of the current pointer does not change func (m *Model) Sort() { + equ := m.equals + var tmp item + if equ != nil { + tmp = m.listItems[m.curIndex] + } sort.Sort(m) + if equ == nil { + return + } + for i, item := range m.listItems { + if is := equ(item.value, tmp.value); is { + m.curIndex = i + break // Stop when first (and hopefully only one) is found + } + } + } // MoveItem moves the current item by amount to the end @@ -586,7 +620,7 @@ func (m *Model) MoveItem(amount int) error { cur := m.curIndex target := cur + amount if !m.CheckWithinBorder(target) { - return fmt.Errorf("Cant move outside the list: %d", target) + return OutOfBounds(fmt.Errorf("Cant move outside the list: %d", target)) } m.Swap(cur, target) m.curIndex = target @@ -602,12 +636,6 @@ func (m *Model) CheckWithinBorder(index int) bool { return true } -// AddDataItem adds a Item with the given interface{} value added to the List item -// So that when sorting, the connection between the string and the interfave{} value stays. -//func (m *Model) AddDataItem(content string, data interface{}) { -// m.listItems = append(m.listItems, item{content: content, userValue: data}) -//} - // Focus sets the list Model focus so it accepts key input and responds to them func (m *Model) Focus() { m.focus = true @@ -622,3 +650,47 @@ func (m *Model) UnFocus() { func (m *Model) Focused() bool { return m.focus } + +// SetEquals sets the internal equals methode used if provided to set the cursor again on the same item after sorting +func (m *Model) SetEquals(equ func(first, second fmt.Stringer) bool) { + m.equals = equ +} + +// GetEquals returns the internal equals methode +// used to set the curser after sorting on the same item again +func (m *Model) GetEquals() func(first, second fmt.Stringer) bool { + return m.equals +} + +// GetIndex returns NotFound error if the Equals Methode is not set (SetEquals) +// or multiple items match the returns MultipleMatches error +// else it returns the index of the found found item +func (m *Model) GetIndex(toSearch fmt.Stringer) (int, error) { + if m.equals == nil { + return -1, 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 { + return -c, MultipleMatches(fmt.Errorf("The provided equals function yields multiple matches betwen one and other fmt.Stringer's")) + } + return lastIndex, nil + +} diff --git a/list/list_test.go b/list/list_test.go index b6bedb0f3..30ee5ea8a 100644 --- a/list/list_test.go +++ b/list/list_test.go @@ -173,7 +173,7 @@ func genDynamicModels() []testModel { moveDown.Width = 80 moveDown.AddItems(MakeStringerList([]string{"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""})) moveDown.curIndex = 45 // set cursor next to line Offset Border so that the down move, should move the hole visible area. - moveDown.Down() + moveDown.Move(1) return []testModel{ {model: moveBottom, shouldBe: "0 ╭ \n1 ╭ \n2 ╭ \n\x1b[7m3 ╭>\x1b[0m", From a1cd761b918e6455a3bb0deb64f61434c9545046 Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Sun, 15 Nov 2020 21:42:42 +0100 Subject: [PATCH 28/73] udapted to breaking API-change changed Methode or functions calls do be new api compatible --- go.mod | 3 ++- go.sum | 6 ++++-- list/example/main.go | 49 +++++++++++++++++++++++--------------------- list/list.go | 15 +++++--------- list/list_test.go | 8 ++++---- 5 files changed, 41 insertions(+), 40 deletions(-) diff --git a/go.mod b/go.mod index 6cf5f8761..b3339494c 100644 --- a/go.mod +++ b/go.mod @@ -6,8 +6,9 @@ require ( github.com/atotto/clipboard v0.1.2 github.com/charmbracelet/bubbletea v0.12.2 github.com/mattn/go-runewidth v0.0.9 - github.com/muesli/reflow v0.2.0 + 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 d16a38795..449ff8154 100644 --- a/go.sum +++ b/go.sum @@ -10,10 +10,12 @@ github.com/lucasb-eyer/go-colorful v1.0.3 h1:QIbQXiugsb+q10B+MI+7DI1oQLdmnep86tW 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.2.0 h1:2o0UBJPHHH4fa2GCXU4Rg4DwOtWPMekCeyc5EWbAQp0= -github.com/muesli/reflow v0.2.0/go.mod h1:qT22vjVmM9MIUeLgsVYe/Ye7eZlbv9dZjL3dVhUqLX8= +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.0/go.mod h1:SohX91w6swWA4AYU+QmPx+aSgXhWO0juiyID9UZmbpA= 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= diff --git a/list/example/main.go b/list/example/main.go index 7e6738fc0..ac49fc66e 100644 --- a/list/example/main.go +++ b/list/example/main.go @@ -38,12 +38,30 @@ func main() { "Use '+' or '-' to move the item under the curser up and down.", "The key 'v' inverts the selected state of each item.", "To toggle betwen only absolute itemnumbers and also relativ numbers, the 'r' key is your friend.", + "","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","", } stringerList := list.MakeStringerList(itemList) endResult := make(chan string, 1) + list := list.NewModel() + list.AddItems(stringerList) + // uncomment the following lines for fancy check (selected) box :-) + // l.WrapPrefix = false + // l.SelectedPrefix = " [x]" + // l.UnSelectedPrefix = "[ ]" + + // Since in this example we only use UNIQUE string items we can use a String Comparison for the equals methode + // but be aware that different items in your case can have the same string -> false-positiv + // Better: Assert back to your struct and test on something unique within it! + list.SetEquals(func(first, second fmt.Stringer) bool { return first.String() == second.String() }) + m := model{} + m.list = list + + + m.endResult = endResult - p := tea.NewProgram(initialize(stringerList, endResult), update, view) + + p := tea.NewProgram(m) // Use the full size of the terminal in its "alternate screen buffer" fullScreen := false // change to true if you want fullscreen @@ -65,39 +83,24 @@ func main() { fmt.Println(res) } -// initialize sets up the model and returns it to the bubbletea runtime -// as a function result, so it can later be handed over to the update and view functions. -func initialize(lineList []fmt.Stringer, endResult chan<- string) func() (tea.Model, tea.Cmd) { - l := list.NewModel() - l.AddItems(lineList) - // uncomment the following lines for fancy check (selected) box :-) - // l.WrapPrefix = false - // l.SelectedPrefix = " [x]" - // l.UnSelectedPrefix = "[ ]" - - // Since in this example we only use UNIQUE string items we can use a String Comparison for the equals methode - // but be aware that different items in your case can have the same string -> false-positiv - // Better: Assert back to your struct and test on something unique within it! - l.SetEquals(func(first, second fmt.Stringer) bool { return first.String() == second.String() }) - return func() (tea.Model, tea.Cmd) { return model{list: l, endResult: endResult}, nil } +func (m model) Init() tea.Cmd { + return nil } -// view waits till the terminal sizes is knowen to the model and than, +// View waits till the terminal sizes is knowen to the model and than, // pipes the model to the list View for rendering the list -func view(mdl tea.Model) string { - m, _ := mdl.(model) +func (m model) View() string { if !m.ready { - return "\n Initalizing...\n\n Waiting for info about window size." + return "\n Initalizing...\n\n Waiting for info about window size.\n" } - listString := list.View(m.list) + listString := m.list.View() return listString } // update recives messages and the model and changes the model accordingly to the messages -func update(msg tea.Msg, mdl tea.Model) (tea.Model, tea.Cmd) { - m, _ := mdl.(model) +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: diff --git a/list/list.go b/list/list.go index 005cf3687..f35af2b60 100644 --- a/list/list.go +++ b/list/list.go @@ -91,8 +91,8 @@ func (i item) genVisLines(wrapTo int) item { return i } -// View renders the Lst to a (displayable) string -func (m *Model) View() string { +// View renders the List to a (displayable) string +func (m Model) View() string { return strings.Join(m.Lines(), "\n") } @@ -286,8 +286,8 @@ func lineNumber(relativ bool, curser, current int) int { return diff } -// Update changes the Model of the List according to the messages recieved -func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +// Update changes the Model of the List according to the messages received +func Update(msg tea.Msg, m Model) (Model, tea.Cmd) { if !m.focus { return m, nil } @@ -346,7 +346,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd case tea.MouseMsg: - switch msg.Button { + switch msg.Type { case tea.MouseWheelUp: m.Move(-1) @@ -357,11 +357,6 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } -// Init does nothing -func (m Model) Init() tea.Cmd { - return nil -} - // AddItems adds the given Items to the list Model // Without performing updating the View TODO func (m *Model) AddItems(itemList []fmt.Stringer) { diff --git a/list/list_test.go b/list/list_test.go index 30ee5ea8a..9df53ae09 100644 --- a/list/list_test.go +++ b/list/list_test.go @@ -25,7 +25,7 @@ type testModel struct { // NEVER leaves the bounds since then it could mess with the layout. func TestViewBounds(t *testing.T) { for _, testM := range genModels(genTestModels()) { - for i, line := range strings.Split(View(testM.model), "\n") { + for i, line := range strings.Split(testM.model.View(), "\n") { lineWidth := ansi.PrintableRuneWidth(line) width := testM.model.Width if lineWidth > width { @@ -42,7 +42,7 @@ func TestViewBounds(t *testing.T) { // Because there is no margin for diviations, if the test fails, lock also if the "golden sample" is sane. func TestGoldenSamples(t *testing.T) { for _, testM := range genModels(genTestModels()) { - actual := View(testM.model) + actual := testM.model.View() expected := testM.shouldBe if actual != expected { t.Errorf("expected Output:\n\n%s\n\nactual Output:\n\n%s\n\n", expected, actual) @@ -56,7 +56,7 @@ func TestPanic(t *testing.T) { panicRes := make(chan interface{}) go func(resChan chan<- interface{}) { defer func() { resChan <- recover() }() // Why does this Yield "%!s()"? - View(testM) // does this not Panic? + testM.model.View() }(panicRes) actual := <-panicRes expected := testM.shouldBe @@ -69,7 +69,7 @@ func TestPanic(t *testing.T) { // TestDynamic tests the view output after a movement/view-changing method func TestDynamic(t *testing.T) { for _, test := range genDynamicModels() { - actual := View(test.model) + actual := test.model.View() expected := test.shouldBe if actual != expected { t.Errorf("expected Output, after Methode '%s' called:\n\n%s\n\nactual Output:\n\n%s\n\n", test.afterMethode, expected, actual) From eaa09c5119d71314013785bad71ab140d111d449 Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Wed, 4 Nov 2020 10:34:13 +0100 Subject: [PATCH 29/73] Added new Method's and new error type - added New Method for Updating the items by the User - and a GetCursorIndex also for the user - therefore added Error type: Notfocused --- list/list.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/list/list.go b/list/list.go index f35af2b60..452fa6b0a 100644 --- a/list/list.go +++ b/list/list.go @@ -22,6 +22,9 @@ type MultipleMatches error // ConfigError is return if there is a error with the configuration of the list Modul type ConfigError error +// NotFocused is a error return if the action can only be applied to a focused list +type NotFocused error + // Model is a bubbletea List of strings type Model struct { focus bool @@ -687,5 +690,23 @@ func (m *Model) GetIndex(toSearch fmt.Stringer) (int, error) { return -c, MultipleMatches(fmt.Errorf("The provided equals function yields multiple matches betwen one and other fmt.Stringer's")) } return lastIndex, nil +} +// UpdateAllItems takes a function and updates with it, all items in the list +func (m *Model) UpdateAllItems(processor func(fmt.Stringer) fmt.Stringer) { + for i, item := range m.listItems { + m.listItems[i].value = processor(item.value) + } +} + +// GetCursorIndex returns current cursor position within the List +func (m *Model) GetCursorIndex() (int, error) { + if !m.focus { + return m.curIndex, NotFocused(fmt.Errorf("Model is not focused")) + } + if m.CheckWithinBorder(m.curIndex) { + return m.curIndex, OutOfBounds(fmt.Errorf("Cursor isout auf bounds")) + } + // TODO handel not focused case + return m.curIndex, nil } From 1586ba17a2f50feb224e084685f64ec2b7c47ec4 Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Wed, 4 Nov 2020 10:34:15 +0100 Subject: [PATCH 30/73] tryed to fix bug, but still dont understand bug - added some methodes for Updating Items --- go.sum | 2 +- list/example/main.go | 91 ++++++++++++++++++++++++++++++++++++++- list/list.go | 100 +++++++++++++++++++++++++++++-------------- 3 files changed, 157 insertions(+), 36 deletions(-) diff --git a/go.sum b/go.sum index 449ff8154..905faafc7 100644 --- a/go.sum +++ b/go.sum @@ -15,7 +15,6 @@ github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/Qd 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.0/go.mod h1:SohX91w6swWA4AYU+QmPx+aSgXhWO0juiyID9UZmbpA= 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= @@ -34,6 +33,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/main.go b/list/example/main.go index ac49fc66e..6a5434246 100644 --- a/list/example/main.go +++ b/list/example/main.go @@ -38,7 +38,90 @@ func main() { "Use '+' or '-' to move the item under the curser up and down.", "The key 'v' inverts the selected state of each item.", "To toggle betwen only absolute itemnumbers and also relativ numbers, the 'r' key is your friend.", - "","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","", + "41", +"42", +"43", +"44", +"45", +"46", +"47", +"48", +"49", +"50", +"51", +"52", +"53", +"54", +"55", +"56", +"57", +"58", +"59", +"60", +"61", +"62", +"63", +"64", +"65", +"66", +"67", +"68", +"69", +"70", +"71", +"72", +"73", +"74", +"75", +"76", +"77", +"78", +"79", +"80", +"81", +"82", +"83", +"84", +"85", +"86", +"87", +"88", +"89", +"90", +"91", +"92", +"93", +"94", +"95", +"96", +"97", +"98", +"99", +"100", +"101", +"102", +"103", +"104", +"105", +"106", +"107", +"108", +"109", +"110", +"111", +"112", +"113", +"114", +"115", +"116", +"117", +"118", +"119", +"120", +"121", +"122", +"123", +"124", } stringerList := list.MakeStringerList(itemList) @@ -110,6 +193,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit } switch keyString := msg.String(); keyString { + case "c": + m.list.Move(1) + return m, nil case "q": m.endResult <- "" return m, tea.Quit @@ -174,7 +260,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.jump = "" // pipe all other commands to the update from the list - list, newMsg := list.Update(msg, m.list) + l, newMsg := m.list.Update(msg) + list, _ := l.(list.Model) m.list = list return m, newMsg } diff --git a/list/list.go b/list/list.go index 452fa6b0a..31062e143 100644 --- a/list/list.go +++ b/list/list.go @@ -290,7 +290,7 @@ func lineNumber(relativ bool, curser, current int) int { } // Update changes the Model of the List according to the messages received -func Update(msg tea.Msg, m Model) (Model, tea.Cmd) { +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if !m.focus { return m, nil } @@ -383,50 +383,27 @@ func (m *Model) AddItems(itemList []fmt.Stringer) { // return m.Move(-1) //} -// Move moves the cursor by amount, does nothing if amount is 0 -// and returns OutOfBounds error if amount go's beyond list borders +// Move moves the cursor by amount and returns OutOfBounds error if amount go's beyond list borders // or if the CursorOffset is greater than half of the display height returns ConfigError +// if amount is 0 the Curser is within the view bounds func (m *Model) Move(amount int) error { - // do nothing - if amount == 0 { - return nil - } var err error curOff := m.CursorOffset - visOff := m.visibleOffset height := m.Height if curOff >= height/2 { curOff = 0 err = ConfigError(fmt.Errorf("cursor offset must be less than halfe of the display height: setting it to zero")) + // still do the movement and return the error at the end if here was any } target := m.curIndex + amount if !m.CheckWithinBorder(target) { return OutOfBounds(fmt.Errorf("Cant move outside the list: %d", target)) } - // move visible part of list if Cursor is going beyond border. - lowerBorder := height + visOff - curOff - upperBorder := visOff + curOff - direction := 1 - if amount < 0 { - direction = -1 - } - - // visible Down movement - if direction > 0 && target > lowerBorder { - visOff = target - (height - curOff) - } - // visible Up movement - if direction < 0 && target < upperBorder { - visOff = target - curOff - } - // don't go in front of list begin - if visOff < 0 { - visOff = 0 - } m.curIndex = target - m.visibleOffset = visOff + // Keep the cursor within visbile bouderys + m.KeepInVis() return err } @@ -442,7 +419,7 @@ func NewModel() Model { focus: true, // Try to keep $CursorOffset lines between Cursor and screen Border - CursorOffset: 5, + CursorOffset: 0, // Wrap lines to have no loss of information Wrap: true, @@ -468,6 +445,11 @@ func NewModel() Model { } } +// Init does nothing +func (m Model) Init() tea.Cmd { + return nil +} + // ToggleSelect toggles the selected status // of the current Index if amount is 0 // returns err != nil when amount lands outside list and safely does nothing @@ -603,6 +585,7 @@ func (m *Model) Sort() { break // Stop when first (and hopefully only one) is found } } + m.Move(0) } @@ -615,6 +598,7 @@ func (m *Model) MoveItem(amount int) error { if amount == 0 { return nil } + m.Move(0) cur := m.curIndex target := cur + amount if !m.CheckWithinBorder(target) { @@ -693,9 +677,9 @@ func (m *Model) GetIndex(toSearch fmt.Stringer) (int, error) { } // UpdateAllItems takes a function and updates with it, all items in the list -func (m *Model) UpdateAllItems(processor func(fmt.Stringer) fmt.Stringer) { +func (m *Model) UpdateAllItems(updater func(fmt.Stringer) fmt.Stringer) { for i, item := range m.listItems { - m.listItems[i].value = processor(item.value) + m.listItems[i].value = updater(item.value) } } @@ -705,8 +689,58 @@ func (m *Model) GetCursorIndex() (int, error) { return m.curIndex, NotFocused(fmt.Errorf("Model is not focused")) } if m.CheckWithinBorder(m.curIndex) { - return m.curIndex, OutOfBounds(fmt.Errorf("Cursor isout auf bounds")) + return m.curIndex, OutOfBounds(fmt.Errorf("Cursor is out auf bounds")) } // TODO handel not focused case return m.curIndex, 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 +} + +// UpdateSelectedItems updates all selected items within the list with given function +func (m *Model)UpdateSelectedItems(updater func(fmt.Stringer) fmt.Stringer) { + for i, item := range m.listItems { + if item.selected { + m.listItems[i].value = updater(item.value) + } + } +} + +// KeepInVis move the visible offset so that the cursor is withn the visible border +func (m *Model) KeepInVis() { + cursor := m.curIndex + + // can't keep the cursor within border, without offsetting the first item + if cursor < 0 + m.CursorOffset { + return + } + + // numeric lower and upper border + lowerBorder := m.visibleOffset + m.CursorOffset + upperBorder := m.visibleOffset + m.Height - m.CursorOffset + + if cursor < lowerBorder-1 { + diff := lowerBorder - cursor + m.visibleOffset -= diff + } + if cursor > upperBorder+1 { + m.visibleOffset += (cursor - upperBorder) + } + +// if m.visibleOffset < 0 { +// m.visibleOffset = 0 +// } +// +// length := len(m.listItems) +// if m.visibleOffset >= length { +// m.visibleOffset = length +// } +} From 21fc43c9d75c6099cc716aa82f6bc658af333a14 Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Wed, 4 Nov 2020 10:34:16 +0100 Subject: [PATCH 31/73] reset to Move methode handeling the visible Offset and the cursor --- list/list.go | 60 +++++++++++++++++++++++----------------------------- 1 file changed, 26 insertions(+), 34 deletions(-) diff --git a/list/list.go b/list/list.go index 31062e143..2501b09f5 100644 --- a/list/list.go +++ b/list/list.go @@ -387,23 +387,46 @@ func (m *Model) AddItems(itemList []fmt.Stringer) { // or if the CursorOffset is greater than half of the display height returns ConfigError // if amount is 0 the Curser is within the view bounds func (m *Model) Move(amount int) error { + // do nothing + if amount == 0 { + return nil + } var err error curOff := m.CursorOffset + visOff := m.visibleOffset height := m.Height if curOff >= height/2 { curOff = 0 err = ConfigError(fmt.Errorf("cursor offset must be less than halfe of the display height: setting it to zero")) - // still do the movement and return the error at the end if here was any } target := m.curIndex + amount if !m.CheckWithinBorder(target) { return OutOfBounds(fmt.Errorf("Cant move outside the list: %d", target)) } + // move visible part of list if Cursor is going beyond border. + lowerBorder := visOff + height - curOff + upperBorder := visOff + curOff + direction := 1 + if amount < 0 { + direction = -1 + } + + // visible Down movement + if direction > 0 && target > lowerBorder { + visOff = target - (height - curOff) + } + // visible Up movement + if direction < 0 && target < upperBorder { + visOff = target - curOff + } + // don't go in front of list begin + if visOff < 0 { + visOff = 0 + } m.curIndex = target - // Keep the cursor within visbile bouderys - m.KeepInVis() + m.visibleOffset = visOff return err } @@ -713,34 +736,3 @@ func (m *Model)UpdateSelectedItems(updater func(fmt.Stringer) fmt.Stringer) { } } } - -// KeepInVis move the visible offset so that the cursor is withn the visible border -func (m *Model) KeepInVis() { - cursor := m.curIndex - - // can't keep the cursor within border, without offsetting the first item - if cursor < 0 + m.CursorOffset { - return - } - - // numeric lower and upper border - lowerBorder := m.visibleOffset + m.CursorOffset - upperBorder := m.visibleOffset + m.Height - m.CursorOffset - - if cursor < lowerBorder-1 { - diff := lowerBorder - cursor - m.visibleOffset -= diff - } - if cursor > upperBorder+1 { - m.visibleOffset += (cursor - upperBorder) - } - -// if m.visibleOffset < 0 { -// m.visibleOffset = 0 -// } -// -// length := len(m.listItems) -// if m.visibleOffset >= length { -// m.visibleOffset = length -// } -} From ae4cd795d6fae8224432e4de2daeff2ba72876fe Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Wed, 4 Nov 2020 10:34:17 +0100 Subject: [PATCH 32/73] tried to handel visibile offset and enabled logging - added blank test (not yet working) - set wrap off, for better testing - added some logging - tried to handel offset right when wrap is on -> failed --- list/example/main.go | 104 +++++++------------------------------------ list/list.go | 44 ++++++++++++------ list/list_test.go | 9 ++++ 3 files changed, 57 insertions(+), 100 deletions(-) diff --git a/list/example/main.go b/list/example/main.go index 6a5434246..306fc89f9 100644 --- a/list/example/main.go +++ b/list/example/main.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "log" "fmt" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" @@ -24,6 +25,12 @@ func (s stringItem) String() string { } func main() { + f, err := os.OpenFile("list.log", os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) + if err != nil { + log.Fatalf("Error opening log file: %v", err) + } + defer f.Close() + log.SetOutput(f) itemList := []string{ "Welcome to the bubbles-list example!", "Use 'q' or 'ctrl-c' to quit!", @@ -38,90 +45,7 @@ func main() { "Use '+' or '-' to move the item under the curser up and down.", "The key 'v' inverts the selected state of each item.", "To toggle betwen only absolute itemnumbers and also relativ numbers, the 'r' key is your friend.", - "41", -"42", -"43", -"44", -"45", -"46", -"47", -"48", -"49", -"50", -"51", -"52", -"53", -"54", -"55", -"56", -"57", -"58", -"59", -"60", -"61", -"62", -"63", -"64", -"65", -"66", -"67", -"68", -"69", -"70", -"71", -"72", -"73", -"74", -"75", -"76", -"77", -"78", -"79", -"80", -"81", -"82", -"83", -"84", -"85", -"86", -"87", -"88", -"89", -"90", -"91", -"92", -"93", -"94", -"95", -"96", -"97", -"98", -"99", -"100", -"101", -"102", -"103", -"104", -"105", -"106", -"107", -"108", -"109", -"110", -"111", -"112", -"113", -"114", -"115", -"116", -"117", -"118", -"119", -"120", -"121", -"122", -"123", -"124", + "41", "42", "43", "44", "45", "46", "47", "48", "49", "50", "51", "52", "53", "54", "55", "56", "57", "58", "59", "60", "61", "62", "63", "64", "65", "66", "67", "68", "69", "70", "71", "72", "73", "74", "75", "76", "77", "78", "79", "80", "81", "82", "83", "84", "85", "86", "87", "88", "89", "90", "91", "92", "93", "94", "95", "96", "97", "98", "99", "100", "101", "102", "103", "104", "105", "106", "107", "108", "109", "110", "111", "112", "113", "114", "115", "116", "117", "118", "119", "120", "121", "122", "123", "124", } stringerList := list.MakeStringerList(itemList) @@ -192,7 +116,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.endResult <- "" return m, tea.Quit } - switch keyString := msg.String(); keyString { + keyString := msg.String(); + log.Printf("received key massage: %s", keyString) + switch keyString { case "c": m.list.Move(1) return m, nil @@ -259,6 +185,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // resets jump buffer to prevent confusion m.jump = "" + log.Printf("Passing unbound key: '%#v' to list update\n", msg) // pipe all other commands to the update from the list l, newMsg := m.list.Update(msg) list, _ := l.(list.Model) @@ -268,8 +195,11 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: - m.list.Width = msg.Width - m.list.Height = msg.Height + width := msg.Width + height := msg.Height + m.list.Width = width + m.list.Height = height + log.Printf("Recieved window since message. Seting window size to width: %d, height: %d", width, height) if !m.ready { // Since this program can use the full size of the viewport we need diff --git a/list/list.go b/list/list.go index 2501b09f5..eaabbf074 100644 --- a/list/list.go +++ b/list/list.go @@ -2,6 +2,7 @@ package list import ( "fmt" + "log" tea "github.com/charmbracelet/bubbletea" "github.com/muesli/reflow/ansi" "github.com/muesli/reflow/wordwrap" @@ -231,9 +232,11 @@ out: wrapPrefix = strings.Join([]string{wrapPad, wrapPrePad, sepWrap, unmark}, "") // don't prefix wrap lines with CurrentMarker (unmark) } - content := item.wrapedLines[0] - if !wrap { - content = item.value.String() + var content string + if wrap { + content = item.wrapedLines[0] + } else { + content = strings.Split(item.value.String(), "\n")[0] // TODO SplitN // TODO hard limit the string length } @@ -271,6 +274,9 @@ out: } } } + if len(stringLines) > m.Height { + panic("can't display more lines than screen has lines.") + } return stringLines } @@ -394,10 +400,12 @@ func (m *Model) Move(amount int) error { var err error curOff := m.CursorOffset visOff := m.visibleOffset - height := m.Height + height := m.Height-1 // TODO find out why the windowsize massage reported 51 lines when there was only 50 + wrap := m.Wrap if curOff >= height/2 { curOff = 0 err = ConfigError(fmt.Errorf("cursor offset must be less than halfe of the display height: setting it to zero")) + // still do the movement and return the error at the end if here was any } target := m.curIndex + amount @@ -405,26 +413,36 @@ func (m *Model) Move(amount int) error { return OutOfBounds(fmt.Errorf("Cant move outside the list: %d", target)) } // move visible part of list if Cursor is going beyond border. - lowerBorder := visOff + height - curOff upperBorder := visOff + curOff - - direction := 1 - if amount < 0 { - direction = -1 - } + lowerBorder := visOff + height - curOff // visible Down movement - if direction > 0 && target > lowerBorder { + if wrap && amount > 0 && target > lowerBorder { + var sum int + for _, item := range m.listItems[visOff: visOff+height] { + if item.wrapedLenght > 1 { + sum += item.wrapedLenght-1 + } + } + visOff = target - (height - curOff) + log.Println("Visible down movement") + visOff += sum + } else if amount > 0 && target > lowerBorder { visOff = target - (height - curOff) + log.Println("Visible down movement") } // visible Up movement - if direction < 0 && target < upperBorder { + if amount < 0 && target < upperBorder { visOff = target - curOff + log.Println("Visible up movement") } // don't go in front of list begin if visOff < 0 { visOff = 0 + log.Println("Visible offset was infront of list begin") } + + log.Println("setting cursor and visible offset after movement") m.curIndex = target m.visibleOffset = visOff return err @@ -445,7 +463,7 @@ func NewModel() Model { CursorOffset: 0, // Wrap lines to have no loss of information - Wrap: true, + Wrap: false, //TODO PrefixWrap: true, // Make clear where a item begins and where it ends diff --git a/list/list_test.go b/list/list_test.go index 9df53ae09..276697730 100644 --- a/list/list_test.go +++ b/list/list_test.go @@ -163,6 +163,11 @@ func genPanicTests() []test { // genDynamicModels generats test cases for dynamic actions like movement, sorting, resizing func genDynamicModels() []testModel { + blankModel := Model{} + blankModel.Height = 10 + blankModel.Width = 10 + blankModel.AddItems(MakeStringerList([]string{"","","","","","","","","","","",""})) + blankModel.Move(0) moveBottom := NewModel() moveBottom.Width = 10 moveBottom.Height = 10 @@ -175,6 +180,10 @@ func genDynamicModels() []testModel { moveDown.curIndex = 45 // set cursor next to line Offset Border so that the down move, should move the hole visible area. moveDown.Move(1) return []testModel{ + {model: blankModel, + shouldBe: "\n\n\n\n\n\n\n\n", + afterMethode: "Move(0)", + }, {model: moveBottom, shouldBe: "0 ╭ \n1 ╭ \n2 ╭ \n\x1b[7m3 ╭>\x1b[0m", afterMethode: "Bottom", From aff34092ea9ec74060cdf172f8b0b0691bff9fac Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Wed, 4 Nov 2020 10:34:17 +0100 Subject: [PATCH 33/73] New try to implement a dedicated methode for cursor movement --- list/list.go | 173 ++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 123 insertions(+), 50 deletions(-) diff --git a/list/list.go b/list/list.go index eaabbf074..5a02cd6a5 100644 --- a/list/list.go +++ b/list/list.go @@ -393,59 +393,63 @@ func (m *Model) AddItems(itemList []fmt.Stringer) { // or if the CursorOffset is greater than half of the display height returns ConfigError // if amount is 0 the Curser is within the view bounds func (m *Model) Move(amount int) error { - // do nothing - if amount == 0 { - return nil - } - var err error - curOff := m.CursorOffset - visOff := m.visibleOffset - height := m.Height-1 // TODO find out why the windowsize massage reported 51 lines when there was only 50 - wrap := m.Wrap - if curOff >= height/2 { - curOff = 0 - err = ConfigError(fmt.Errorf("cursor offset must be less than halfe of the display height: setting it to zero")) - // still do the movement and return the error at the end if here was any - } +// // do nothing +// if amount == 0 { +// return nil +// } +// var err error +// curOff := m.CursorOffset +// visOff := m.visibleOffset +// height := m.Height-1 // TODO find out why the windowsize massage reported 51 lines when there was only 50 +// wrap := m.Wrap +// if curOff >= height/2 { +// curOff = 0 +// err = ConfigError(fmt.Errorf("cursor offset must be less than halfe of the display height: setting it to zero")) +// // still do the movement and return the error at the end if here was any +// } target := m.curIndex + amount - if !m.CheckWithinBorder(target) { - return OutOfBounds(fmt.Errorf("Cant move outside the list: %d", target)) - } - // move visible part of list if Cursor is going beyond border. - upperBorder := visOff + curOff - lowerBorder := visOff + height - curOff - - // visible Down movement - if wrap && amount > 0 && target > lowerBorder { - var sum int - for _, item := range m.listItems[visOff: visOff+height] { - if item.wrapedLenght > 1 { - sum += item.wrapedLenght-1 - } - } - visOff = target - (height - curOff) - log.Println("Visible down movement") - visOff += sum - } else if amount > 0 && target > lowerBorder { - visOff = target - (height - curOff) - log.Println("Visible down movement") - } - // visible Up movement - if amount < 0 && target < upperBorder { - visOff = target - curOff - log.Println("Visible up movement") - } - // don't go in front of list begin - if visOff < 0 { - visOff = 0 - log.Println("Visible offset was infront of list begin") - } - - log.Println("setting cursor and visible offset after movement") - m.curIndex = target - m.visibleOffset = visOff + newCursor, err := m.KeepVisible(target) + m.curIndex = newCursor // TODO + log.Printf("Requesting cursor position: %d, setting to: %d", target, newCursor) return err +// if !m.CheckWithinBorder(target) { +// return OutOfBounds(fmt.Errorf("Cant move outside the list: %d", target)) +// } +// // move visible part of list if Cursor is going beyond border. +// upperBorder := visOff + curOff +// lowerBorder := visOff + height - curOff +// +// // visible Down movement +// if wrap && amount > 0 && target > lowerBorder { +// var sum int +// for _, item := range m.listItems[visOff: visOff+height] { +// if item.wrapedLenght > 1 { +// sum += item.wrapedLenght-1 +// } +// } +// visOff = target - (height - curOff) +// log.Println("Visible down movement") +// visOff += sum +// } else if amount > 0 && target > lowerBorder { +// visOff = target - (height - curOff) +// log.Println("Visible down movement") +// } +// // visible Up movement +// if amount < 0 && target < upperBorder { +// visOff = target - curOff +// log.Println("Visible up movement") +// } +// // don't go in front of list begin +// if visOff < 0 { +// visOff = 0 +// log.Println("Visible offset was infront of list begin") +// } +// +// log.Println("setting cursor and visible offset after movement") +// m.curIndex = target +// m.visibleOffset = visOff +// return err } // NewModel returns a Model with some save/sane defaults @@ -754,3 +758,72 @@ func (m *Model)UpdateSelectedItems(updater func(fmt.Stringer) fmt.Stringer) { } } } + +// KeepVisible will set the Cursor within the visible area of the list +// and if CursorOffset is != 0 will set it within this bounderys +// if CursorOffset is bigger than half the screen hight error will be of type ConfigError +// If the cursor would be outside of the list, it will be set to the according nearest value +// and error will be of type OutOfBounds. The return int is the absolut item number on which the cursor gets set +func (m *Model) KeepVisible(cursor int) (int, error) { + var newCursor int + //defer func(m *Model, setTo *int) { + // m.curIndex = *setTo + //}(m, &newCursor) + + var visItemsBeforCursor int + beforCursor := cursor - m.visibleOffset + + var lineCount[][2]int + // calculate how much Lines/Items are infront of the cursor + if m.Wrap { + var lineSum int + for c := m.curIndex; c > 0; c-- { + + if lineSum > beforCursor { + visItemsBeforCursor = c + break + } + lineSum += m.listItems[c].wrapedLenght + lineCount = append(lineCount, [2]int{lineSum, c}) + } + } else { + visItemsBeforCursor = cursor - m.visibleOffset + } + + var err error + // Check if Cursor would be beyond list + if length := len(m.listItems); cursor >= length { + newCursor = length-1 + m.curIndex = newCursor + err = OutOfBounds(fmt.Errorf("requested cursor position was behind of the list")) + } + + // Check if Cursor would be infront of list + if cursor < 0 { + newCursor = 0 + m.curIndex = newCursor + err = OutOfBounds(fmt.Errorf("requested cursor position was infront of the list")) + } + + + // Visible Area and Cursor are at beginning of List -> cant move further up. + if visItemsBeforCursor <= m.CursorOffset && m.visibleOffset <= 0 { + m.visibleOffset = 0 + return newCursor, err + } + + // Cursor is infront of Boundry -> move visible Area up + if visItemsBeforCursor <= m.CursorOffset { + m.visibleOffset = m.curIndex - visItemsBeforCursor + return newCursor, err + } + + // Cursor Position is within bounds -> all good + if visItemsBeforCursor > m.CursorOffset && visItemsBeforCursor < m.Height-m.CursorOffset { + return newCursor, err + } + + // Cursor is beyond boundry -> move visibel Area down + m.visibleOffset = m.Height - m.CursorOffset - visItemsBeforCursor + return newCursor, err +} From 8dea4b532086f1282d3f00334e26c64e9d94a0f8 Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Wed, 4 Nov 2020 10:34:17 +0100 Subject: [PATCH 34/73] Implemented keepVisibleWrap it does not move the cursor, but the offsets -> should be changed to only returning values and dont change things --- list/list.go | 229 +++++++++++++++++++++++++++++++--------------- list/list_test.go | 4 +- 2 files changed, 156 insertions(+), 77 deletions(-) diff --git a/list/list.go b/list/list.go index 5a02cd6a5..80e39e66b 100644 --- a/list/list.go +++ b/list/list.go @@ -2,11 +2,11 @@ package list import ( "fmt" - "log" tea "github.com/charmbracelet/bubbletea" "github.com/muesli/reflow/ansi" "github.com/muesli/reflow/wordwrap" "github.com/muesli/termenv" + "log" "sort" "strings" ) @@ -33,6 +33,7 @@ type Model struct { listItems []item curIndex int // cursor visibleOffset int // begin of the visible lines + lineOffset int // if wrap is enabled start at line "lineOffset" from "visibleOffset" less func(string, string) bool // function used for sorting equals func(fmt.Stringer, fmt.Stringer) bool // to be set from the user @@ -393,63 +394,63 @@ func (m *Model) AddItems(itemList []fmt.Stringer) { // or if the CursorOffset is greater than half of the display height returns ConfigError // if amount is 0 the Curser is within the view bounds func (m *Model) Move(amount int) error { -// // do nothing -// if amount == 0 { -// return nil -// } -// var err error -// curOff := m.CursorOffset -// visOff := m.visibleOffset -// height := m.Height-1 // TODO find out why the windowsize massage reported 51 lines when there was only 50 -// wrap := m.Wrap -// if curOff >= height/2 { -// curOff = 0 -// err = ConfigError(fmt.Errorf("cursor offset must be less than halfe of the display height: setting it to zero")) -// // still do the movement and return the error at the end if here was any -// } + // // do nothing + // if amount == 0 { + // return nil + // } + // var err error + // curOff := m.CursorOffset + // visOff := m.visibleOffset + // height := m.Height-1 // TODO find out why the windowsize massage reported 51 lines when there was only 50 + // wrap := m.Wrap + // if curOff >= height/2 { + // curOff = 0 + // err = ConfigError(fmt.Errorf("cursor offset must be less than halfe of the display height: setting it to zero")) + // // still do the movement and return the error at the end if here was any + // } target := m.curIndex + amount newCursor, err := m.KeepVisible(target) m.curIndex = newCursor // TODO log.Printf("Requesting cursor position: %d, setting to: %d", target, newCursor) return err -// if !m.CheckWithinBorder(target) { -// return OutOfBounds(fmt.Errorf("Cant move outside the list: %d", target)) -// } -// // move visible part of list if Cursor is going beyond border. -// upperBorder := visOff + curOff -// lowerBorder := visOff + height - curOff -// -// // visible Down movement -// if wrap && amount > 0 && target > lowerBorder { -// var sum int -// for _, item := range m.listItems[visOff: visOff+height] { -// if item.wrapedLenght > 1 { -// sum += item.wrapedLenght-1 -// } -// } -// visOff = target - (height - curOff) -// log.Println("Visible down movement") -// visOff += sum -// } else if amount > 0 && target > lowerBorder { -// visOff = target - (height - curOff) -// log.Println("Visible down movement") -// } -// // visible Up movement -// if amount < 0 && target < upperBorder { -// visOff = target - curOff -// log.Println("Visible up movement") -// } -// // don't go in front of list begin -// if visOff < 0 { -// visOff = 0 -// log.Println("Visible offset was infront of list begin") -// } -// -// log.Println("setting cursor and visible offset after movement") -// m.curIndex = target -// m.visibleOffset = visOff -// return err + // if !m.CheckWithinBorder(target) { + // return OutOfBounds(fmt.Errorf("Cant move outside the list: %d", target)) + // } + // // move visible part of list if Cursor is going beyond border. + // upperBorder := visOff + curOff + // lowerBorder := visOff + height - curOff + // + // // visible Down movement + // if wrap && amount > 0 && target > lowerBorder { + // var sum int + // for _, item := range m.listItems[visOff: visOff+height] { + // if item.wrapedLenght > 1 { + // sum += item.wrapedLenght-1 + // } + // } + // visOff = target - (height - curOff) + // log.Println("Visible down movement") + // visOff += sum + // } else if amount > 0 && target > lowerBorder { + // visOff = target - (height - curOff) + // log.Println("Visible down movement") + // } + // // visible Up movement + // if amount < 0 && target < upperBorder { + // visOff = target - curOff + // log.Println("Visible up movement") + // } + // // don't go in front of list begin + // if visOff < 0 { + // visOff = 0 + // log.Println("Visible offset was infront of list begin") + // } + // + // log.Println("setting cursor and visible offset after movement") + // m.curIndex = target + // m.visibleOffset = visOff + // return err } // NewModel returns a Model with some save/sane defaults @@ -751,7 +752,7 @@ func (m *Model) GetAllItems() []fmt.Stringer { } // UpdateSelectedItems updates all selected items within the list with given function -func (m *Model)UpdateSelectedItems(updater func(fmt.Stringer) fmt.Stringer) { +func (m *Model) UpdateSelectedItems(updater func(fmt.Stringer) fmt.Stringer) { for i, item := range m.listItems { if item.selected { m.listItems[i].value = updater(item.value) @@ -769,31 +770,10 @@ func (m *Model) KeepVisible(cursor int) (int, error) { //defer func(m *Model, setTo *int) { // m.curIndex = *setTo //}(m, &newCursor) - - var visItemsBeforCursor int - beforCursor := cursor - m.visibleOffset - - var lineCount[][2]int - // calculate how much Lines/Items are infront of the cursor - if m.Wrap { - var lineSum int - for c := m.curIndex; c > 0; c-- { - - if lineSum > beforCursor { - visItemsBeforCursor = c - break - } - lineSum += m.listItems[c].wrapedLenght - lineCount = append(lineCount, [2]int{lineSum, c}) - } - } else { - visItemsBeforCursor = cursor - m.visibleOffset - } - var err error // Check if Cursor would be beyond list if length := len(m.listItems); cursor >= length { - newCursor = length-1 + newCursor = length - 1 m.curIndex = newCursor err = OutOfBounds(fmt.Errorf("requested cursor position was behind of the list")) } @@ -805,6 +785,11 @@ func (m *Model) KeepVisible(cursor int) (int, error) { err = OutOfBounds(fmt.Errorf("requested cursor position was infront of the list")) } + if m.Wrap { + return m.keepVisibleWrap(newCursor) + } + + visItemsBeforCursor := cursor - m.visibleOffset // Visible Area and Cursor are at beginning of List -> cant move further up. if visItemsBeforCursor <= m.CursorOffset && m.visibleOffset <= 0 { @@ -827,3 +812,97 @@ func (m *Model) KeepVisible(cursor int) (int, error) { m.visibleOffset = m.Height - m.CursorOffset - visItemsBeforCursor return newCursor, err } + +func (m *Model) keepVisibleWrap(cursor int) (int, error) { + + var lower, upper bool // Visible lower/upper + var lineSum int + var lineCount [][2]int + + // Reset all Offsets + m.visibleOffset = 0 + m.lineOffset = 0 + + // Nothing to do + if cursor == 0 { + return 0, nil + } + + // calculate how much space(lines) the items take + for c := cursor - 1; c > 0 && c > cursor-m.Height; c-- { + lineSum += m.listItems[c].wrapedLenght + lineCount = append(lineCount, [2]int{lineSum, c}) + if lineSum >= m.CursorOffset { + upper = true + } + if lineSum >= m.Height-m.CursorOffset { + lower = true + } + } + + direction := 1 + if m.curIndex-cursor < 0 { + direction = -1 + } + + // Can't Move infront of list begin + if direction < 0 && lineCount[len(lineCount)-1][0] < m.CursorOffset && m.visibleOffset <= 0 && m.lineOffset <= 0 { + m.visibleOffset = 0 + m.lineOffset = 0 + return cursor, nil + } + // can't Move beyond list end, setting offsets accordingly + if direction >= 0 && cursor >= len(m.listItems)-1 { + var lastOffset, lineOffset int + lowerBorder := m.Height - m.CursorOffset + for _, item := range lineCount { + lastOffset = item[1] // Visible Offset + if item[0] > lowerBorder { + lineOffset = item[0] - lowerBorder + break + } + } + m.visibleOffset = lastOffset + m.lineOffset = lineOffset + cursor = len(m.listItems) - 1 + return cursor, nil + } + + // Within bounds + if upper && !lower { + return cursor, nil + } + + // infront upper border -> Move up + if direction < 0 && !upper { + var lastOffset, lineOffset int + upperBorder := m.CursorOffset + for _, item := range lineCount { + lastOffset = item[1] // Visible Offset + if item[0] > upperBorder { + lineOffset = item[0] - upperBorder + break + } + } + m.visibleOffset = lastOffset + m.lineOffset = lineOffset + return cursor, nil + } + + // beyond lower border -> Moving Down + if direction >= 0 && lower { + var lastOffset, lineOffset int + lowerBorder := m.Height - m.CursorOffset + for _, item := range lineCount { + lastOffset = item[1] // Visible Offset + if item[0] > lowerBorder { + lineOffset = item[0] - lowerBorder + break + } + } + m.visibleOffset = lastOffset + m.lineOffset = lineOffset + return cursor, nil + } + +} diff --git a/list/list_test.go b/list/list_test.go index 276697730..7e344a472 100644 --- a/list/list_test.go +++ b/list/list_test.go @@ -166,7 +166,7 @@ func genDynamicModels() []testModel { blankModel := Model{} blankModel.Height = 10 blankModel.Width = 10 - blankModel.AddItems(MakeStringerList([]string{"","","","","","","","","","","",""})) + blankModel.AddItems(MakeStringerList([]string{"", "", "", "", "", "", "", "", "", "", "", ""})) blankModel.Move(0) moveBottom := NewModel() moveBottom.Width = 10 @@ -181,7 +181,7 @@ func genDynamicModels() []testModel { moveDown.Move(1) return []testModel{ {model: blankModel, - shouldBe: "\n\n\n\n\n\n\n\n", + shouldBe: "\n\n\n\n\n\n\n\n", afterMethode: "Move(0)", }, {model: moveBottom, From 9fce8416699249c60160c73981743b7a4de18fef Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Wed, 4 Nov 2020 10:34:18 +0100 Subject: [PATCH 35/73] working Movement without wrap - added lineOffset for scrolling with wrap - reimplemented Bottom and MoveItem to use the Move Methode - added some logging --- list/example/main.go | 7 +- list/list.go | 167 +++++++++++++++---------------------------- 2 files changed, 59 insertions(+), 115 deletions(-) diff --git a/list/example/main.go b/list/example/main.go index 306fc89f9..252774be6 100644 --- a/list/example/main.go +++ b/list/example/main.go @@ -2,10 +2,10 @@ package main import ( "bytes" - "log" "fmt" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" + "log" "os" "strconv" ) @@ -64,10 +64,8 @@ func main() { m := model{} m.list = list - m.endResult = endResult - p := tea.NewProgram(m) // Use the full size of the terminal in its "alternate screen buffer" @@ -90,7 +88,6 @@ func main() { fmt.Println(res) } - func (m model) Init() tea.Cmd { return nil } @@ -116,7 +113,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.endResult <- "" return m, tea.Quit } - keyString := msg.String(); + keyString := msg.String() log.Printf("received key massage: %s", keyString) switch keyString { case "c": diff --git a/list/list.go b/list/list.go index 80e39e66b..489c9e8a2 100644 --- a/list/list.go +++ b/list/list.go @@ -180,6 +180,8 @@ func (m *Model) Lines() []string { } } + lineOffset := m.lineOffset + var visLines int stringLines := make([]string, 0, height) out: @@ -190,6 +192,11 @@ out: break } + var ignoreLines bool + if wrap && lineOffset != 0 && index == offset { + ignoreLines = true + } + item := m.listItems[index] if wrap && item.wrapedLenght <= 0 { panic("cant display item with no visible content") @@ -245,9 +252,11 @@ out: // NOTE line break is not added here because it would mess with the highlighting line := fmt.Sprintf("%s%s", linePrefix, content) - // Highlight and write first line - stringLines = append(stringLines, style.Styled(line)) - visLines++ + if !ignoreLines { + // Highlight and write first line + stringLines = append(stringLines, style.Styled(line)) + visLines++ + } // Only write lines that are visible if visLines >= height { @@ -265,9 +274,13 @@ out: // NOTE line break is not added here because it would mess with the highlighting padLine := fmt.Sprintf("%s%s", wrapPrefix, line) - // Highlight and write wrapped line - stringLines = append(stringLines, style.Styled(padLine)) - visLines++ + if lineOffset < 0 { + // Highlight and write wrapped line + stringLines = append(stringLines, style.Styled(padLine)) + visLines++ + } else { + lineOffset-- + } // Only write lines that are visible if visLines > height { @@ -378,79 +391,15 @@ func (m *Model) AddItems(itemList []fmt.Stringer) { } } -// Down moves the "cursor" or current line down. -// If the end is already reached err is not nil. -//func (m *Model) Down() error { -// return m.Move(1) -//} - -// Up moves the "cursor" or current line up. -// If the start is already reached, err is not nil. -//func (m *Model) Up() error { -// return m.Move(-1) -//} - // Move moves the cursor by amount and returns OutOfBounds error if amount go's beyond list borders // or if the CursorOffset is greater than half of the display height returns ConfigError -// if amount is 0 the Curser is within the view bounds -func (m *Model) Move(amount int) error { - // // do nothing - // if amount == 0 { - // return nil - // } - // var err error - // curOff := m.CursorOffset - // visOff := m.visibleOffset - // height := m.Height-1 // TODO find out why the windowsize massage reported 51 lines when there was only 50 - // wrap := m.Wrap - // if curOff >= height/2 { - // curOff = 0 - // err = ConfigError(fmt.Errorf("cursor offset must be less than halfe of the display height: setting it to zero")) - // // still do the movement and return the error at the end if here was any - // } - +// if amount is 0 the Curser will get set within the view bounds +func (m *Model) Move(amount int) (int, error) { target := m.curIndex + amount newCursor, err := m.KeepVisible(target) m.curIndex = newCursor // TODO log.Printf("Requesting cursor position: %d, setting to: %d", target, newCursor) - return err - // if !m.CheckWithinBorder(target) { - // return OutOfBounds(fmt.Errorf("Cant move outside the list: %d", target)) - // } - // // move visible part of list if Cursor is going beyond border. - // upperBorder := visOff + curOff - // lowerBorder := visOff + height - curOff - // - // // visible Down movement - // if wrap && amount > 0 && target > lowerBorder { - // var sum int - // for _, item := range m.listItems[visOff: visOff+height] { - // if item.wrapedLenght > 1 { - // sum += item.wrapedLenght-1 - // } - // } - // visOff = target - (height - curOff) - // log.Println("Visible down movement") - // visOff += sum - // } else if amount > 0 && target > lowerBorder { - // visOff = target - (height - curOff) - // log.Println("Visible down movement") - // } - // // visible Up movement - // if amount < 0 && target < upperBorder { - // visOff = target - curOff - // log.Println("Visible up movement") - // } - // // don't go in front of list begin - // if visOff < 0 { - // visOff = 0 - // log.Println("Visible offset was infront of list begin") - // } - // - // log.Println("setting cursor and visible offset after movement") - // m.curIndex = target - // m.visibleOffset = visOff - // return err + return newCursor, err } // NewModel returns a Model with some save/sane defaults @@ -560,24 +509,15 @@ func (m *Model) ToggleAllSelected() { // Top moves the cursor to the first line func (m *Model) Top() { - m.visibleOffset = 0 m.curIndex = 0 + m.visibleOffset = 0 + m.lineOffset = 0 } // Bottom moves the cursor to the last line func (m *Model) Bottom() { end := len(m.listItems) - 1 - m.curIndex = end - maxVisItems := m.Height - m.CursorOffset - var visLines, smallestVisIndex int - for c := end; visLines < maxVisItems; c-- { - if c < 0 { - break - } - visLines += m.listItems[c].wrapedLenght - smallestVisIndex = c - } - m.visibleOffset = smallestVisIndex + m.Move(end) } // GetSelected returns you a list of all items @@ -644,14 +584,12 @@ func (m *Model) MoveItem(amount int) error { if amount == 0 { return nil } - m.Move(0) cur := m.curIndex - target := cur + amount - if !m.CheckWithinBorder(target) { - return OutOfBounds(fmt.Errorf("Cant move outside the list: %d", target)) + target, err := m.Move(amount) + if err != nil { + return err } m.Swap(cur, target) - m.curIndex = target return nil } @@ -766,51 +704,59 @@ func (m *Model) UpdateSelectedItems(updater func(fmt.Stringer) fmt.Stringer) { // If the cursor would be outside of the list, it will be set to the according nearest value // and error will be of type OutOfBounds. The return int is the absolut item number on which the cursor gets set func (m *Model) KeepVisible(cursor int) (int, error) { - var newCursor int - //defer func(m *Model, setTo *int) { - // m.curIndex = *setTo - //}(m, &newCursor) var err error // Check if Cursor would be beyond list if length := len(m.listItems); cursor >= length { - newCursor = length - 1 - m.curIndex = newCursor - err = OutOfBounds(fmt.Errorf("requested cursor position was behind of the list")) + cursor = length - 1 + errMsg := "requested cursor position was behind of the list" + err = OutOfBounds(fmt.Errorf(errMsg)) + log.Println(errMsg) } // Check if Cursor would be infront of list if cursor < 0 { - newCursor = 0 - m.curIndex = newCursor - err = OutOfBounds(fmt.Errorf("requested cursor position was infront of the list")) + cursor = 0 + errMsg := "requested cursor position was infront of the list" + err = OutOfBounds(fmt.Errorf(errMsg)) + log.Println(errMsg) + } + + if cursor == 0 { + m.visibleOffset = 0 + m.lineOffset = 0 + return 0, nil } if m.Wrap { - return m.keepVisibleWrap(newCursor) + return m.keepVisibleWrap(cursor) } visItemsBeforCursor := cursor - m.visibleOffset // Visible Area and Cursor are at beginning of List -> cant move further up. - if visItemsBeforCursor <= m.CursorOffset && m.visibleOffset <= 0 { + if m.visibleOffset <= 0 && visItemsBeforCursor <= m.CursorOffset { m.visibleOffset = 0 - return newCursor, err + log.Println("beginning") //debug + return cursor, err } // Cursor is infront of Boundry -> move visible Area up - if visItemsBeforCursor <= m.CursorOffset { - m.visibleOffset = m.curIndex - visItemsBeforCursor - return newCursor, err + if visItemsBeforCursor < m.CursorOffset { + m.visibleOffset = m.curIndex + visItemsBeforCursor + log.Println("moving up") //debug + return cursor, err } // Cursor Position is within bounds -> all good - if visItemsBeforCursor > m.CursorOffset && visItemsBeforCursor < m.Height-m.CursorOffset { - return newCursor, err + if visItemsBeforCursor >= m.CursorOffset && visItemsBeforCursor < m.Height-m.CursorOffset { + log.Println("middel") //debug + return cursor, err } // Cursor is beyond boundry -> move visibel Area down - m.visibleOffset = m.Height - m.CursorOffset - visItemsBeforCursor - return newCursor, err + m.visibleOffset = m.visibleOffset - (m.Height - m.CursorOffset - visItemsBeforCursor - 1) + log.Printf("end, vis: %d", m.visibleOffset) //debug + return cursor, err } func (m *Model) keepVisibleWrap(cursor int) (int, error) { @@ -904,5 +850,6 @@ func (m *Model) keepVisibleWrap(cursor int) (int, error) { m.lineOffset = lineOffset return cursor, nil } + return cursor, fmt.Errorf("unhandelt case") } From 550bbb2e101bf9529bda4d41ac501b8bb1fb026b Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Wed, 4 Nov 2020 10:34:19 +0100 Subject: [PATCH 36/73] Up and Down Movement with wrap are working - fixed bug within line ignoration - reset Wrap to true within NewModel - fixed keepVisibleWrap --- list/list.go | 69 ++++++++++++++++++++++++++++++++++------------------ 1 file changed, 45 insertions(+), 24 deletions(-) diff --git a/list/list.go b/list/list.go index 489c9e8a2..22dfa18e5 100644 --- a/list/list.go +++ b/list/list.go @@ -176,7 +176,8 @@ func (m *Model) Lines() []string { if wrap { // renew wrap of all items for i := range m.listItems { - m.listItems[i] = m.listItems[i].genVisLines(contentWidth) + lineAm := m.listItems[i].genVisLines(contentWidth) + m.listItems[i] = lineAm } } @@ -274,14 +275,15 @@ out: // NOTE line break is not added here because it would mess with the highlighting padLine := fmt.Sprintf("%s%s", wrapPrefix, line) - if lineOffset < 0 { - // Highlight and write wrapped line - stringLines = append(stringLines, style.Styled(padLine)) - visLines++ - } else { + if ignoreLines && lineOffset >= 0 { lineOffset-- + continue } + // Highlight and write wrapped line + stringLines = append(stringLines, style.Styled(padLine)) + visLines++ + // Only write lines that are visible if visLines > height { break out @@ -417,7 +419,7 @@ func NewModel() Model { CursorOffset: 0, // Wrap lines to have no loss of information - Wrap: false, //TODO + Wrap: true, PrefixWrap: true, // Make clear where a item begins and where it ends @@ -760,41 +762,55 @@ func (m *Model) KeepVisible(cursor int) (int, error) { } func (m *Model) keepVisibleWrap(cursor int) (int, error) { - var lower, upper bool // Visible lower/upper - var lineSum int var lineCount [][2]int - // Reset all Offsets - m.visibleOffset = 0 - m.lineOffset = 0 + if !m.CheckWithinBorder(cursor) { + return 0, OutOfBounds(fmt.Errorf("can't move beyond list bonderys, with requested cursor position: %d", cursor)) + } + // Nothing to do if cursor == 0 { + // Reset all Offsets + m.visibleOffset = 0 + m.lineOffset = 0 return 0, nil } + direction := 1 + if cursor- m.curIndex < 0 { + direction = -1 + } + + lineSum := 1 // Cursorline is not counted in the following loop, so do it here // calculate how much space(lines) the items take - for c := cursor - 1; c > 0 && c > cursor-m.Height; c-- { - lineSum += m.listItems[c].wrapedLenght + for c := cursor - 1; c >= 0 && c > cursor-m.Height; c-- { + lineAm := m.listItems[c].wrapedLenght + lineSum += lineAm lineCount = append(lineCount, [2]int{lineSum, c}) - if lineSum >= m.CursorOffset { + + // if new cursor infront of old visible offset dont mark borders + if cursor-1 < m.visibleOffset { + continue + } + + // mark the pass of a border + if lineSum > m.CursorOffset { upper = true } - if lineSum >= m.Height-m.CursorOffset { + lowerBorder := m.Height-m.CursorOffset + if lineSum >= lowerBorder { + log.Println("setting lower border to true") //debug lower = true } } - direction := 1 - if m.curIndex-cursor < 0 { - direction = -1 - } - // Can't Move infront of list begin - if direction < 0 && lineCount[len(lineCount)-1][0] < m.CursorOffset && m.visibleOffset <= 0 && m.lineOffset <= 0 { + if direction < 0 && len(lineCount) > 0 && lineCount[len(lineCount)-1][0] < m.CursorOffset && m.visibleOffset <= 0 && m.lineOffset <= 0 { m.visibleOffset = 0 m.lineOffset = 0 + log.Println("before") return cursor, nil } // can't Move beyond list end, setting offsets accordingly @@ -811,11 +827,13 @@ func (m *Model) keepVisibleWrap(cursor int) (int, error) { m.visibleOffset = lastOffset m.lineOffset = lineOffset cursor = len(m.listItems) - 1 + log.Println("after") return cursor, nil } // Within bounds if upper && !lower { + log.Println("middle") return cursor, nil } @@ -832,6 +850,7 @@ func (m *Model) keepVisibleWrap(cursor int) (int, error) { } m.visibleOffset = lastOffset m.lineOffset = lineOffset + log.Println("up") return cursor, nil } @@ -840,16 +859,18 @@ func (m *Model) keepVisibleWrap(cursor int) (int, error) { var lastOffset, lineOffset int lowerBorder := m.Height - m.CursorOffset for _, item := range lineCount { - lastOffset = item[1] // Visible Offset - if item[0] > lowerBorder { + if item[0] >= lowerBorder { + lastOffset = item[1] // Visible Offset lineOffset = item[0] - lowerBorder break } } m.visibleOffset = lastOffset m.lineOffset = lineOffset + log.Println("down") return cursor, nil } + log.Printf("unhandelt case, direction: %d, lower: %t", direction, lower) return cursor, fmt.Errorf("unhandelt case") } From 8509ad14fc2e54f58db77d5c51d7c398618c130b Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Wed, 4 Nov 2020 10:34:19 +0100 Subject: [PATCH 37/73] Working Movement with zero offset, but not with more --- list/list.go | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/list/list.go b/list/list.go index 22dfa18e5..36a3d0e1a 100644 --- a/list/list.go +++ b/list/list.go @@ -783,7 +783,10 @@ func (m *Model) keepVisibleWrap(cursor int) (int, error) { direction = -1 } - lineSum := 1 // Cursorline is not counted in the following loop, so do it here + var lineSum int + if direction >= 0 { + lineSum = 1 // Cursorline is not counted in the following loop, so do it here + } // calculate how much space(lines) the items take for c := cursor - 1; c >= 0 && c > cursor-m.Height; c-- { lineAm := m.listItems[c].wrapedLenght @@ -800,8 +803,7 @@ func (m *Model) keepVisibleWrap(cursor int) (int, error) { upper = true } lowerBorder := m.Height-m.CursorOffset - if lineSum >= lowerBorder { - log.Println("setting lower border to true") //debug + if !lower && c >= m.CursorOffset && lineSum >= lowerBorder { lower = true } } @@ -831,12 +833,6 @@ func (m *Model) keepVisibleWrap(cursor int) (int, error) { return cursor, nil } - // Within bounds - if upper && !lower { - log.Println("middle") - return cursor, nil - } - // infront upper border -> Move up if direction < 0 && !upper { var lastOffset, lineOffset int @@ -870,7 +866,7 @@ func (m *Model) keepVisibleWrap(cursor int) (int, error) { log.Println("down") return cursor, nil } - log.Printf("unhandelt case, direction: %d, lower: %t", direction, lower) - return cursor, fmt.Errorf("unhandelt case") - + // Within bounds + log.Println("middle") + return cursor, nil } From 60bbc8aa5d40a175e48c161e3d560fdf68e0d39a Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Wed, 4 Nov 2020 10:34:20 +0100 Subject: [PATCH 38/73] fixed visible Offset within up movement - changed if statement to account for Cursor Offset betwen cursor and visible border - removed debug log statements - removed 2D-array in favor for struct --- list/list.go | 48 +++++++++++++++++++++++------------------------- 1 file changed, 23 insertions(+), 25 deletions(-) diff --git a/list/list.go b/list/list.go index 36a3d0e1a..e775210e8 100644 --- a/list/list.go +++ b/list/list.go @@ -416,7 +416,7 @@ func NewModel() Model { focus: true, // Try to keep $CursorOffset lines between Cursor and screen Border - CursorOffset: 0, + CursorOffset: 5, // Wrap lines to have no loss of information Wrap: true, @@ -738,32 +738,27 @@ func (m *Model) KeepVisible(cursor int) (int, error) { // Visible Area and Cursor are at beginning of List -> cant move further up. if m.visibleOffset <= 0 && visItemsBeforCursor <= m.CursorOffset { m.visibleOffset = 0 - log.Println("beginning") //debug return cursor, err } // Cursor is infront of Boundry -> move visible Area up if visItemsBeforCursor < m.CursorOffset { m.visibleOffset = m.curIndex + visItemsBeforCursor - log.Println("moving up") //debug return cursor, err } // Cursor Position is within bounds -> all good if visItemsBeforCursor >= m.CursorOffset && visItemsBeforCursor < m.Height-m.CursorOffset { - log.Println("middel") //debug return cursor, err } // Cursor is beyond boundry -> move visibel Area down m.visibleOffset = m.visibleOffset - (m.Height - m.CursorOffset - visItemsBeforCursor - 1) - log.Printf("end, vis: %d", m.visibleOffset) //debug return cursor, err } func (m *Model) keepVisibleWrap(cursor int) (int, error) { var lower, upper bool // Visible lower/upper - var lineCount [][2]int if !m.CheckWithinBorder(cursor) { return 0, OutOfBounds(fmt.Errorf("can't move beyond list bonderys, with requested cursor position: %d", cursor)) @@ -783,6 +778,13 @@ func (m *Model) keepVisibleWrap(cursor int) (int, error) { direction = -1 } + type beforCursor struct { + listIndex int + linesBefor int + } + + var lineCount []beforCursor + var lineSum int if direction >= 0 { lineSum = 1 // Cursorline is not counted in the following loop, so do it here @@ -791,28 +793,28 @@ func (m *Model) keepVisibleWrap(cursor int) (int, error) { for c := cursor - 1; c >= 0 && c > cursor-m.Height; c-- { lineAm := m.listItems[c].wrapedLenght lineSum += lineAm - lineCount = append(lineCount, [2]int{lineSum, c}) + lineCount = append(lineCount, beforCursor{c, lineSum}) // if new cursor infront of old visible offset dont mark borders - if cursor-1 < m.visibleOffset { + if cursor-1 < m.visibleOffset + m.CursorOffset { continue } // mark the pass of a border - if lineSum > m.CursorOffset { + upperBorder := m.CursorOffset + if !upper && lineSum > upperBorder { upper = true } lowerBorder := m.Height-m.CursorOffset - if !lower && c >= m.CursorOffset && lineSum >= lowerBorder { + if !lower && lineSum >= lowerBorder { lower = true } } // Can't Move infront of list begin - if direction < 0 && len(lineCount) > 0 && lineCount[len(lineCount)-1][0] < m.CursorOffset && m.visibleOffset <= 0 && m.lineOffset <= 0 { + if direction < 0 && len(lineCount) > 0 && lineCount[len(lineCount)-1].linesBefor < m.CursorOffset && m.visibleOffset <= 0 && m.lineOffset <= 0 { m.visibleOffset = 0 m.lineOffset = 0 - log.Println("before") return cursor, nil } // can't Move beyond list end, setting offsets accordingly @@ -820,16 +822,15 @@ func (m *Model) keepVisibleWrap(cursor int) (int, error) { var lastOffset, lineOffset int lowerBorder := m.Height - m.CursorOffset for _, item := range lineCount { - lastOffset = item[1] // Visible Offset - if item[0] > lowerBorder { - lineOffset = item[0] - lowerBorder + lastOffset = item.listIndex // Visible Offset + if item.linesBefor > lowerBorder { + lineOffset = item.linesBefor - lowerBorder break } } m.visibleOffset = lastOffset m.lineOffset = lineOffset cursor = len(m.listItems) - 1 - log.Println("after") return cursor, nil } @@ -838,15 +839,14 @@ func (m *Model) keepVisibleWrap(cursor int) (int, error) { var lastOffset, lineOffset int upperBorder := m.CursorOffset for _, item := range lineCount { - lastOffset = item[1] // Visible Offset - if item[0] > upperBorder { - lineOffset = item[0] - upperBorder + lastOffset = item.listIndex // Visible Offset + if item.linesBefor > upperBorder { + lineOffset = item.linesBefor - upperBorder break } } m.visibleOffset = lastOffset m.lineOffset = lineOffset - log.Println("up") return cursor, nil } @@ -855,18 +855,16 @@ func (m *Model) keepVisibleWrap(cursor int) (int, error) { var lastOffset, lineOffset int lowerBorder := m.Height - m.CursorOffset for _, item := range lineCount { - if item[0] >= lowerBorder { - lastOffset = item[1] // Visible Offset - lineOffset = item[0] - lowerBorder + if item.linesBefor >= lowerBorder { + lastOffset = item.listIndex // Visible Offset + lineOffset = item.linesBefor - lowerBorder break } } m.visibleOffset = lastOffset m.lineOffset = lineOffset - log.Println("down") return cursor, nil } // Within bounds - log.Println("middle") return cursor, nil } From 7dec032e286e5e916488bd0b3679d511c116ab42 Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Wed, 4 Nov 2020 10:34:21 +0100 Subject: [PATCH 39/73] fixed ToggleSelect --- list/example/main.go | 1 - list/list.go | 22 ++++++++++++++-------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/list/example/main.go b/list/example/main.go index 252774be6..f21346f4b 100644 --- a/list/example/main.go +++ b/list/example/main.go @@ -167,7 +167,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.jump = "" } m.list.ToggleSelect(j) - m.list.Move(1) return m, nil case "enter": // Enter prints the selected lines to StdOut diff --git a/list/list.go b/list/list.go index e775210e8..64342512a 100644 --- a/list/list.go +++ b/list/list.go @@ -463,16 +463,22 @@ func (m *Model) ToggleSelect(amount int) error { } cur := m.curIndex - target := cur + amount - direction - if !m.CheckWithinBorder(target) { - return OutOfBounds(fmt.Errorf("Cant go beyond list borders: %d", target)) + + // mark index zero when trying to move infront of list + var o int + // but not when moving on index zero + if cur+amount >= 0{ + o=1 } - for c := 0; c < amount*direction; c++ { - m.listItems[cur+c].selected = !m.listItems[cur+c].selected + target, err := m.Move(amount) + start, end := cur, target + if direction < 0 { + start, end = target+o, cur+1 } - m.curIndex = target - direction - m.Move(direction) - return nil + for c := start; c < end; c++{ + m.listItems[c].selected = !m.listItems[c].selected + } + return err } // MarkSelected selects or unselects depending on 'mark' From d5708581568c02fbac0d0a7c4d0d80614937b6b6 Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Wed, 4 Nov 2020 10:34:21 +0100 Subject: [PATCH 40/73] removed log statements and fixed extrem cases in ToggleSelect - fixed up movement with Cursor Offset and no wrap --- list/example/main.go | 11 ----------- list/list.go | 33 +++++++++++++++------------------ 2 files changed, 15 insertions(+), 29 deletions(-) diff --git a/list/example/main.go b/list/example/main.go index f21346f4b..974b300c3 100644 --- a/list/example/main.go +++ b/list/example/main.go @@ -5,7 +5,6 @@ import ( "fmt" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" - "log" "os" "strconv" ) @@ -25,12 +24,6 @@ func (s stringItem) String() string { } func main() { - f, err := os.OpenFile("list.log", os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) - if err != nil { - log.Fatalf("Error opening log file: %v", err) - } - defer f.Close() - log.SetOutput(f) itemList := []string{ "Welcome to the bubbles-list example!", "Use 'q' or 'ctrl-c' to quit!", @@ -45,7 +38,6 @@ func main() { "Use '+' or '-' to move the item under the curser up and down.", "The key 'v' inverts the selected state of each item.", "To toggle betwen only absolute itemnumbers and also relativ numbers, the 'r' key is your friend.", - "41", "42", "43", "44", "45", "46", "47", "48", "49", "50", "51", "52", "53", "54", "55", "56", "57", "58", "59", "60", "61", "62", "63", "64", "65", "66", "67", "68", "69", "70", "71", "72", "73", "74", "75", "76", "77", "78", "79", "80", "81", "82", "83", "84", "85", "86", "87", "88", "89", "90", "91", "92", "93", "94", "95", "96", "97", "98", "99", "100", "101", "102", "103", "104", "105", "106", "107", "108", "109", "110", "111", "112", "113", "114", "115", "116", "117", "118", "119", "120", "121", "122", "123", "124", } stringerList := list.MakeStringerList(itemList) @@ -114,7 +106,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit } keyString := msg.String() - log.Printf("received key massage: %s", keyString) switch keyString { case "c": m.list.Move(1) @@ -181,7 +172,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // resets jump buffer to prevent confusion m.jump = "" - log.Printf("Passing unbound key: '%#v' to list update\n", msg) // pipe all other commands to the update from the list l, newMsg := m.list.Update(msg) list, _ := l.(list.Model) @@ -195,7 +185,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { height := msg.Height m.list.Width = width m.list.Height = height - log.Printf("Recieved window since message. Seting window size to width: %d, height: %d", width, height) if !m.ready { // Since this program can use the full size of the viewport we need diff --git a/list/list.go b/list/list.go index 64342512a..fdff6f2fa 100644 --- a/list/list.go +++ b/list/list.go @@ -6,7 +6,6 @@ import ( "github.com/muesli/reflow/ansi" "github.com/muesli/reflow/wordwrap" "github.com/muesli/termenv" - "log" "sort" "strings" ) @@ -400,7 +399,6 @@ func (m *Model) Move(amount int) (int, error) { target := m.curIndex + amount newCursor, err := m.KeepVisible(target) m.curIndex = newCursor // TODO - log.Printf("Requesting cursor position: %d, setting to: %d", target, newCursor) return newCursor, err } @@ -464,18 +462,20 @@ func (m *Model) ToggleSelect(amount int) error { cur := m.curIndex - // mark index zero when trying to move infront of list - var o int - // but not when moving on index zero - if cur+amount >= 0{ - o=1 - } target, err := m.Move(amount) start, end := cur, target if direction < 0 { - start, end = target+o, cur+1 + start, end = target+1, cur+1 + } + // mark/start at first item + if cur+amount < 0 { + start = 0 + } + // mark last item when trying to go beyond list + if cur+amount >= len(m.listItems) { + end++ } - for c := start; c < end; c++{ + for c := start; c < end; c++ { m.listItems[c].selected = !m.listItems[c].selected } return err @@ -718,7 +718,6 @@ func (m *Model) KeepVisible(cursor int) (int, error) { cursor = length - 1 errMsg := "requested cursor position was behind of the list" err = OutOfBounds(fmt.Errorf(errMsg)) - log.Println(errMsg) } // Check if Cursor would be infront of list @@ -726,7 +725,6 @@ func (m *Model) KeepVisible(cursor int) (int, error) { cursor = 0 errMsg := "requested cursor position was infront of the list" err = OutOfBounds(fmt.Errorf(errMsg)) - log.Println(errMsg) } if cursor == 0 { @@ -749,7 +747,7 @@ func (m *Model) KeepVisible(cursor int) (int, error) { // Cursor is infront of Boundry -> move visible Area up if visItemsBeforCursor < m.CursorOffset { - m.visibleOffset = m.curIndex + visItemsBeforCursor + m.visibleOffset = cursor - m.CursorOffset return cursor, err } @@ -770,7 +768,6 @@ func (m *Model) keepVisibleWrap(cursor int) (int, error) { return 0, OutOfBounds(fmt.Errorf("can't move beyond list bonderys, with requested cursor position: %d", cursor)) } - // Nothing to do if cursor == 0 { // Reset all Offsets @@ -780,12 +777,12 @@ func (m *Model) keepVisibleWrap(cursor int) (int, error) { } direction := 1 - if cursor- m.curIndex < 0 { + if cursor-m.curIndex < 0 { direction = -1 } type beforCursor struct { - listIndex int + listIndex int linesBefor int } @@ -802,7 +799,7 @@ func (m *Model) keepVisibleWrap(cursor int) (int, error) { lineCount = append(lineCount, beforCursor{c, lineSum}) // if new cursor infront of old visible offset dont mark borders - if cursor-1 < m.visibleOffset + m.CursorOffset { + if cursor-1 < m.visibleOffset+m.CursorOffset { continue } @@ -811,7 +808,7 @@ func (m *Model) keepVisibleWrap(cursor int) (int, error) { if !upper && lineSum > upperBorder { upper = true } - lowerBorder := m.Height-m.CursorOffset + lowerBorder := m.Height - m.CursorOffset if !lower && lineSum >= lowerBorder { lower = true } From 7d35d55d0e652ffee102673d26aca02060ab0e7e Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Wed, 4 Nov 2020 10:34:22 +0100 Subject: [PATCH 41/73] refined doc-strings, added new struct, fixed bit of bug - made doc strings more telling - added new struct to hold within it all invormation about view position - fixed lowerBorder-trigger bug, lower and upper cursor border are still not perfect --- list/list.go | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/list/list.go b/list/list.go index fdff6f2fa..520d74f09 100644 --- a/list/list.go +++ b/list/list.go @@ -25,6 +25,13 @@ type ConfigError error // NotFocused is a error return if the action can only be applied to a focused list type NotFocused error +// ViewPos is used for holding the information about the View parameters +type ViewPos struct { + Cursor int + ItemOffset int + LineOffset int +} + // Model is a bubbletea List of strings type Model struct { focus bool @@ -69,8 +76,7 @@ type item struct { value fmt.Stringer } -// StringItem is just a convenience to have fast a version -// to satisfy the fmt.Stringer interface with plain strings +// StringItem is just a convenience to satisfy the fmt.Stringer interface with plain strings type StringItem string func (s StringItem) String() string { @@ -284,13 +290,14 @@ out: visLines++ // Only write lines that are visible - if visLines > height { + if visLines >= height { break out } } } - if len(stringLines) > m.Height { - panic("can't display more lines than screen has lines.") + lenght := len(stringLines) + if lenght > m.Height { + panic(fmt.Sprintf("can't display %d lines when screen has %d lines.", lenght, m.Height)) } return stringLines } @@ -311,6 +318,7 @@ func lineNumber(relativ bool, curser, current int) int { } // Update changes the Model of the List according to the messages received +// if the list is focused, else does nothing. func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if !m.focus { return m, nil @@ -449,7 +457,7 @@ func (m Model) Init() tea.Cmd { // of the current Index if amount is 0 // returns err != nil when amount lands outside list and safely does nothing // else if amount is not 0 toggles selected amount items -// excluding the item on which the cursor lands +// excluding the item on which the cursor would land func (m *Model) ToggleSelect(amount int) error { if amount == 0 { m.listItems[m.curIndex].selected = !m.listItems[m.curIndex].selected @@ -545,12 +553,14 @@ func (m *Model) Less(i, j int) bool { return m.less(m.listItems[i].value.String(), m.listItems[j].value.String()) } -// Swap is used to fulfill the Sort-interface +// Swap swaps the items position within the list +// and is used to fulfill the Sort-interface func (m *Model) Swap(i, j int) { m.listItems[i], m.listItems[j] = m.listItems[j], m.listItems[i] } -// Len is used to fulfill the Sort-interface +// Len returns the amount of list-items +// and is used to fulfill the Sort-interface func (m *Model) Len() int { return len(m.listItems) } @@ -637,8 +647,7 @@ func (m *Model) GetEquals() func(first, second fmt.Stringer) bool { } // GetIndex returns NotFound error if the Equals Methode is not set (SetEquals) -// or multiple items match the returns MultipleMatches error -// else it returns the index of the found found item +// else it returns the index of the found item func (m *Model) GetIndex(toSearch fmt.Stringer) (int, error) { if m.equals == nil { return -1, NotFound(fmt.Errorf("no equals function provided. Use SetEquals to set it")) @@ -792,7 +801,7 @@ func (m *Model) keepVisibleWrap(cursor int) (int, error) { if direction >= 0 { lineSum = 1 // Cursorline is not counted in the following loop, so do it here } - // calculate how much space(lines) the items take + // calculate how much space(lines) the items befor the requested cursor position occupy for c := cursor - 1; c >= 0 && c > cursor-m.Height; c-- { lineAm := m.listItems[c].wrapedLenght lineSum += lineAm @@ -809,7 +818,7 @@ func (m *Model) keepVisibleWrap(cursor int) (int, error) { upper = true } lowerBorder := m.Height - m.CursorOffset - if !lower && lineSum >= lowerBorder { + if !lower && lineSum >= lowerBorder && c >= m.visibleOffset { lower = true } } From 0de39ef85fb5862a624cab36948919aeb3a16058 Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Wed, 4 Nov 2020 10:34:22 +0100 Subject: [PATCH 42/73] replaced single fields about vis and cursor position through one struct, for more compact and connected handling of visible and cursor position. --- list/list.go | 139 +++++++++++++++++++++------------------------- list/list_test.go | 2 +- 2 files changed, 63 insertions(+), 78 deletions(-) diff --git a/list/list.go b/list/list.go index 520d74f09..22ecda5f4 100644 --- a/list/list.go +++ b/list/list.go @@ -36,12 +36,12 @@ type ViewPos struct { type Model struct { focus bool - listItems []item - curIndex int // cursor - visibleOffset int // begin of the visible lines - lineOffset int // if wrap is enabled start at line "lineOffset" from "visibleOffset" - less func(string, string) bool // function used for sorting - equals func(fmt.Stringer, fmt.Stringer) bool // to be set from the user + listItems []item + + viewPos ViewPos + + less func(string, string) bool // function used for sorting + equals func(fmt.Stringer, fmt.Stringer) bool // used after sorting, to be set from the user CursorOffset int // offset or margin between the cursor and the viewport(visible) border @@ -116,7 +116,7 @@ func (m *Model) Lines() []string { if height*width <= 0 { panic("Can't display with zero width or hight of Viewport") } - offset := m.visibleOffset + offset := m.viewPos.ItemOffset // Get separators width widthItem := ansi.PrintableRuneWidth(m.Seperator) @@ -186,7 +186,7 @@ func (m *Model) Lines() []string { } } - lineOffset := m.lineOffset + lineOffset := m.viewPos.LineOffset var visLines int stringLines := make([]string, 0, height) @@ -212,7 +212,7 @@ out: firstPad := strings.Repeat(" ", numWidth) var wrapPad string if m.Number { - lineNum := lineNumber(m.NumberRelative, m.curIndex, index) + lineNum := lineNumber(m.NumberRelative, m.viewPos.Cursor, index) number := fmt.Sprintf("%d", lineNum) // since digits are only single bytes, len is sufficient: firstPad = strings.Repeat(" ", numWidth-len(number)) + number @@ -233,7 +233,7 @@ out: // Current: handle highlighting of current item/first-line curPad := unmark - if index == m.curIndex { + if index == m.viewPos.Cursor { style = m.CurrentStyle curPad = mark } @@ -404,10 +404,10 @@ func (m *Model) AddItems(itemList []fmt.Stringer) { // or if the CursorOffset is greater than half of the display height returns ConfigError // if amount is 0 the Curser will get set within the view bounds func (m *Model) Move(amount int) (int, error) { - target := m.curIndex + amount - newCursor, err := m.KeepVisible(target) - m.curIndex = newCursor // TODO - return newCursor, err + target := m.viewPos.Cursor + amount + newPos, err := m.KeepVisible(target) + m.viewPos = newPos + return newPos.Cursor, err } // NewModel returns a Model with some save/sane defaults @@ -460,7 +460,7 @@ func (m Model) Init() tea.Cmd { // excluding the item on which the cursor would land func (m *Model) ToggleSelect(amount int) error { if amount == 0 { - m.listItems[m.curIndex].selected = !m.listItems[m.curIndex].selected + m.listItems[m.viewPos.Cursor].selected = !m.listItems[m.viewPos.Cursor].selected } direction := 1 @@ -468,7 +468,7 @@ func (m *Model) ToggleSelect(amount int) error { direction = -1 } - cur := m.curIndex + cur := m.viewPos.Cursor target, err := m.Move(amount) start, end := cur, target @@ -494,7 +494,7 @@ func (m *Model) ToggleSelect(amount int) error { // if amount would be outside the list error is from type OutOfBounds // else all items till but excluding the end cursor position gets (un-)marked func (m *Model) MarkSelected(amount int, mark bool) error { - cur := m.curIndex + cur := m.viewPos.Cursor if amount == 0 { m.listItems[cur].selected = mark return nil @@ -511,7 +511,7 @@ func (m *Model) MarkSelected(amount int, mark bool) error { for c := 0; c < amount*direction; c++ { m.listItems[cur+c].selected = mark } - m.curIndex = target + m.viewPos.Cursor = target m.Move(direction) return nil } @@ -525,9 +525,9 @@ func (m *Model) ToggleAllSelected() { // Top moves the cursor to the first line func (m *Model) Top() { - m.curIndex = 0 - m.visibleOffset = 0 - m.lineOffset = 0 + m.viewPos.Cursor = 0 + m.viewPos.ItemOffset = 0 + m.viewPos.LineOffset = 0 } // Bottom moves the cursor to the last line @@ -577,7 +577,7 @@ func (m *Model) Sort() { equ := m.equals var tmp item if equ != nil { - tmp = m.listItems[m.curIndex] + tmp = m.listItems[m.viewPos.Cursor] } sort.Sort(m) if equ == nil { @@ -585,7 +585,7 @@ func (m *Model) Sort() { } for i, item := range m.listItems { if is := equ(item.value, tmp.value); is { - m.curIndex = i + m.viewPos.Cursor = i break // Stop when first (and hopefully only one) is found } } @@ -602,7 +602,7 @@ func (m *Model) MoveItem(amount int) error { if amount == 0 { return nil } - cur := m.curIndex + cur := m.viewPos.Cursor target, err := m.Move(amount) if err != nil { return err @@ -687,13 +687,13 @@ func (m *Model) UpdateAllItems(updater func(fmt.Stringer) fmt.Stringer) { // GetCursorIndex returns current cursor position within the List func (m *Model) GetCursorIndex() (int, error) { if !m.focus { - return m.curIndex, NotFocused(fmt.Errorf("Model is not focused")) + return m.viewPos.Cursor, NotFocused(fmt.Errorf("Model is not focused")) } - if m.CheckWithinBorder(m.curIndex) { - return m.curIndex, OutOfBounds(fmt.Errorf("Cursor is out auf bounds")) + if m.CheckWithinBorder(m.viewPos.Cursor) { + return m.viewPos.Cursor, OutOfBounds(fmt.Errorf("Cursor is out auf bounds")) } // TODO handel not focused case - return m.curIndex, nil + return m.viewPos.Cursor, nil } // GetAllItems returns all items in the list in current order @@ -720,73 +720,67 @@ func (m *Model) UpdateSelectedItems(updater func(fmt.Stringer) fmt.Stringer) { // if CursorOffset is bigger than half the screen hight error will be of type ConfigError // If the cursor would be outside of the list, it will be set to the according nearest value // and error will be of type OutOfBounds. The return int is the absolut item number on which the cursor gets set -func (m *Model) KeepVisible(cursor int) (int, error) { +func (m *Model) KeepVisible(target int) (ViewPos, error) { var err error // Check if Cursor would be beyond list - if length := len(m.listItems); cursor >= length { - cursor = length - 1 + if length := len(m.listItems); target >= length { + target = length - 1 errMsg := "requested cursor position was behind of the list" err = OutOfBounds(fmt.Errorf(errMsg)) } // Check if Cursor would be infront of list - if cursor < 0 { - cursor = 0 + if target < 0 { + target = 0 errMsg := "requested cursor position was infront of the list" err = OutOfBounds(fmt.Errorf(errMsg)) } - if cursor == 0 { - m.visibleOffset = 0 - m.lineOffset = 0 - return 0, nil + if target == 0 { + return ViewPos{}, nil } if m.Wrap { - return m.keepVisibleWrap(cursor) + return m.keepVisibleWrap(target) } + m.viewPos.LineOffset = 0 - visItemsBeforCursor := cursor - m.visibleOffset + visItemsBeforCursor := target - m.viewPos.ItemOffset // Visible Area and Cursor are at beginning of List -> cant move further up. - if m.visibleOffset <= 0 && visItemsBeforCursor <= m.CursorOffset { - m.visibleOffset = 0 - return cursor, err + if m.viewPos.ItemOffset <= 0 && visItemsBeforCursor <= m.CursorOffset { + return ViewPos{Cursor: target}, err } // Cursor is infront of Boundry -> move visible Area up if visItemsBeforCursor < m.CursorOffset { - m.visibleOffset = cursor - m.CursorOffset - return cursor, err + return ViewPos{Cursor: target, ItemOffset: target - m.CursorOffset}, err } // Cursor Position is within bounds -> all good if visItemsBeforCursor >= m.CursorOffset && visItemsBeforCursor < m.Height-m.CursorOffset { - return cursor, err + return ViewPos{Cursor: target, ItemOffset: m.viewPos.ItemOffset}, err } // Cursor is beyond boundry -> move visibel Area down - m.visibleOffset = m.visibleOffset - (m.Height - m.CursorOffset - visItemsBeforCursor - 1) - return cursor, err + lowerOffset := m.viewPos.ItemOffset - (m.Height - m.CursorOffset - visItemsBeforCursor - 1) + return ViewPos{Cursor: target, ItemOffset: lowerOffset}, err } -func (m *Model) keepVisibleWrap(cursor int) (int, error) { +func (m *Model) keepVisibleWrap(target int) (ViewPos, error) { var lower, upper bool // Visible lower/upper - if !m.CheckWithinBorder(cursor) { - return 0, OutOfBounds(fmt.Errorf("can't move beyond list bonderys, with requested cursor position: %d", cursor)) + if !m.CheckWithinBorder(target) { + return ViewPos{}, OutOfBounds(fmt.Errorf("can't move beyond list bonderys, with requested cursor position: %d", target)) } // Nothing to do - if cursor == 0 { - // Reset all Offsets - m.visibleOffset = 0 - m.lineOffset = 0 - return 0, nil + if target == 0 { + return ViewPos{}, nil } direction := 1 - if cursor-m.curIndex < 0 { + if target-m.viewPos.Cursor < 0 { direction = -1 } @@ -802,13 +796,13 @@ func (m *Model) keepVisibleWrap(cursor int) (int, error) { lineSum = 1 // Cursorline is not counted in the following loop, so do it here } // calculate how much space(lines) the items befor the requested cursor position occupy - for c := cursor - 1; c >= 0 && c > cursor-m.Height; c-- { + for c := target - 1; c >= 0 && c > target-m.Height; c-- { lineAm := m.listItems[c].wrapedLenght lineSum += lineAm lineCount = append(lineCount, beforCursor{c, lineSum}) - // if new cursor infront of old visible offset dont mark borders - if cursor-1 < m.visibleOffset+m.CursorOffset { + // if new target infront of old visible offset dont mark borders + if target-1 < m.viewPos.ItemOffset+m.CursorOffset { continue } @@ -818,19 +812,17 @@ func (m *Model) keepVisibleWrap(cursor int) (int, error) { upper = true } lowerBorder := m.Height - m.CursorOffset - if !lower && lineSum >= lowerBorder && c >= m.visibleOffset { + if !lower && lineSum >= lowerBorder && c >= m.viewPos.ItemOffset { lower = true } } - // Can't Move infront of list begin - if direction < 0 && len(lineCount) > 0 && lineCount[len(lineCount)-1].linesBefor < m.CursorOffset && m.visibleOffset <= 0 && m.lineOffset <= 0 { - m.visibleOffset = 0 - m.lineOffset = 0 - return cursor, nil + // Can't Move visible infront of list begin + if direction < 0 && len(lineCount) > 0 && lineCount[len(lineCount)-1].linesBefor < m.CursorOffset && m.viewPos.ItemOffset <= 0 && m.viewPos.LineOffset <= 0 { + return ViewPos{Cursor: target}, nil } // can't Move beyond list end, setting offsets accordingly - if direction >= 0 && cursor >= len(m.listItems)-1 { + if direction >= 0 && target >= len(m.listItems)-1 { var lastOffset, lineOffset int lowerBorder := m.Height - m.CursorOffset for _, item := range lineCount { @@ -840,10 +832,7 @@ func (m *Model) keepVisibleWrap(cursor int) (int, error) { break } } - m.visibleOffset = lastOffset - m.lineOffset = lineOffset - cursor = len(m.listItems) - 1 - return cursor, nil + return ViewPos{ItemOffset: lastOffset, LineOffset: lineOffset, Cursor: len(m.listItems) - 1}, nil } // infront upper border -> Move up @@ -857,9 +846,7 @@ func (m *Model) keepVisibleWrap(cursor int) (int, error) { break } } - m.visibleOffset = lastOffset - m.lineOffset = lineOffset - return cursor, nil + return ViewPos{ItemOffset: lastOffset, LineOffset: lineOffset, Cursor: target}, nil } // beyond lower border -> Moving Down @@ -873,10 +860,8 @@ func (m *Model) keepVisibleWrap(cursor int) (int, error) { break } } - m.visibleOffset = lastOffset - m.lineOffset = lineOffset - return cursor, nil + return ViewPos{ItemOffset: lastOffset, LineOffset: lineOffset, Cursor: target}, nil } // Within bounds - return cursor, nil + return ViewPos{ItemOffset: m.viewPos.ItemOffset, LineOffset: m.viewPos.LineOffset, Cursor: target}, nil } diff --git a/list/list_test.go b/list/list_test.go index 7e344a472..ccc1ca826 100644 --- a/list/list_test.go +++ b/list/list_test.go @@ -177,7 +177,7 @@ func genDynamicModels() []testModel { moveDown.Height = 50 moveDown.Width = 80 moveDown.AddItems(MakeStringerList([]string{"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""})) - moveDown.curIndex = 45 // set cursor next to line Offset Border so that the down move, should move the hole visible area. + moveDown.viewPos.Cursor = 45 // set cursor next to line Offset Border so that the down move, should move the hole visible area. moveDown.Move(1) return []testModel{ {model: blankModel, From c24dd8234a3fd06b0dd49e0868a6577bf6e238c2 Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Wed, 4 Nov 2020 10:34:23 +0100 Subject: [PATCH 43/73] Changed from String field within List-Model to Prefix-Closure but allready thinghing about Prefix-interface field within struct. - changed example accordingly, test NOT yet - added Profile to List struct, but not yet implemented -> thinking about making a dedicated struct for screen info - removed prefix-string-fields from List struct in favor of clusore - move prefix preperation into outer closure --- list/example/main.go | 19 +-- list/list.go | 304 ++++++++++++++++++++++++------------------- 2 files changed, 179 insertions(+), 144 deletions(-) diff --git a/list/example/main.go b/list/example/main.go index 974b300c3..f40e726fa 100644 --- a/list/example/main.go +++ b/list/example/main.go @@ -37,17 +37,14 @@ func main() { "since one can not say what was a line break within an item or what is a new item", "Use '+' or '-' to move the item under the curser up and down.", "The key 'v' inverts the selected state of each item.", - "To toggle betwen only absolute itemnumbers and also relativ numbers, the 'r' key is your friend.", + //"To toggle betwen only absolute itemnumbers and also relativ numbers, the 'r' key is your friend.", } stringerList := list.MakeStringerList(itemList) endResult := make(chan string, 1) + prefixFunc := list.AbsolutLinePrefix list := list.NewModel() list.AddItems(stringerList) - // uncomment the following lines for fancy check (selected) box :-) - // l.WrapPrefix = false - // l.SelectedPrefix = " [x]" - // l.UnSelectedPrefix = "[ ]" // Since in this example we only use UNIQUE string items we can use a String Comparison for the equals methode // but be aware that different items in your case can have the same string -> false-positiv @@ -55,6 +52,7 @@ func main() { list.SetEquals(func(first, second fmt.Stringer) bool { return first.String() == second.String() }) m := model{} m.list = list + m.list.PrefixFunc = prefixFunc m.endResult = endResult @@ -84,7 +82,7 @@ func (m model) Init() tea.Cmd { return nil } -// View waits till the terminal sizes is knowen to the model and than, +// 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 { @@ -132,9 +130,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.list.Move(-j) return m, nil - case "r": - m.list.NumberRelative = !m.list.NumberRelative - return m, nil + //case "r": + // m.list.NumberRelative = !m.list.NumberRelative + // return m, nil case "m": j := 1 if m.jump != "" { @@ -194,10 +192,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // here. m.ready = true } - return m, nil - } - return m, nil } diff --git a/list/list.go b/list/list.go index 22ecda5f4..3180d3428 100644 --- a/list/list.go +++ b/list/list.go @@ -45,21 +45,17 @@ type Model struct { CursorOffset int // offset or margin between the cursor and the viewport(visible) border - Width int - Height int + Width int + Height int + Profile termenv.Profile Wrap bool - SelectedPrefix string - UnSelectedPrefix string - Seperator string - SeperatorWrap string - CurrentMarker string - - PrefixWrap bool - - Number bool - NumberRelative bool + PrefixFunc func(position ViewPos, height int) (func(currentIndex int, selected bool, wrapedIndex int) string, int) + // PrefixFunc func(value fmt.Stringer, position ViewPos, selected bool) string + // PrefixWrapFunc func(value fmt.Stringer, position ViewPos, selected bool, wrapedIndex int) string + // SuffixFunc func(value fmt.Stringer, position ViewPos, selected bool) string + // SuffixWrapFunc func(value fmt.Stringer, position ViewPos, selected bool, wrapedIndex int) string LineStyle termenv.Style SelectedStyle termenv.Style @@ -116,58 +112,16 @@ func (m *Model) Lines() []string { if height*width <= 0 { panic("Can't display with zero width or hight of Viewport") } - offset := m.viewPos.ItemOffset - - // Get separators width - widthItem := ansi.PrintableRuneWidth(m.Seperator) - widthWrap := ansi.PrintableRuneWidth(m.SeperatorWrap) - - // Find max width - sepWidth := widthItem - if widthWrap > sepWidth { - sepWidth = widthWrap - } - // get widest *displayed* number, for padding - numWidth := len(fmt.Sprintf("%d", len(m.listItems)-1)) - localMaxWidth := len(fmt.Sprintf("%d", offset+height-1)) - if localMaxWidth < numWidth { - numWidth = localMaxWidth - } - - // pad all prefixes to the same width for easy exchange - selected := m.SelectedPrefix - unselect := m.UnSelectedPrefix - selWid := ansi.PrintableRuneWidth(selected) - tmpWid := ansi.PrintableRuneWidth(unselect) - - selectWidth := selWid - if tmpWid > selectWidth { - selectWidth = tmpWid - } - selected = strings.Repeat(" ", selectWidth-selWid) + selected - - wrapSelectPad := strings.Repeat(" ", selectWidth) - wrapUnSelePad := strings.Repeat(" ", selectWidth) - if m.PrefixWrap { - wrapSelectPad = strings.Repeat(" ", selectWidth-selWid) + selected - wrapUnSelePad = strings.Repeat(" ", selectWidth-tmpWid) + unselect + var prefixFunc func(int, bool, int) string + var holePrefixWidth int + if m.PrefixFunc != nil { + prefixFunc, holePrefixWidth = m.PrefixFunc(m.viewPos, height) + } else { + // use default + prefixFunc, holePrefixWidth = AbsolutLinePrefix(m.viewPos, height) } - unselect = strings.Repeat(" ", selectWidth-tmpWid) + unselect - - // pad all separators to the same width for easy exchange - sepItem := strings.Repeat(" ", sepWidth-widthItem) + m.Seperator - sepWrap := strings.Repeat(" ", sepWidth-widthWrap) + m.SeperatorWrap - - // pad right of prefix, with length of current pointer - mark := m.CurrentMarker - markWidth := ansi.PrintableRuneWidth(mark) - unmark := strings.Repeat(" ", markWidth) - - // Get the hole prefix width - holePrefixWidth := numWidth + selectWidth + sepWidth + markWidth - // Get actual content width contentWidth := width - holePrefixWidth @@ -187,6 +141,7 @@ func (m *Model) Lines() []string { } lineOffset := m.viewPos.LineOffset + offset := m.viewPos.ItemOffset var visLines int stringLines := make([]string, 0, height) @@ -208,44 +163,6 @@ out: panic("cant display item with no visible content") } - // if a number is set, prepend first line with number and both with enough spaces - firstPad := strings.Repeat(" ", numWidth) - var wrapPad string - if m.Number { - lineNum := lineNumber(m.NumberRelative, m.viewPos.Cursor, index) - number := fmt.Sprintf("%d", lineNum) - // since digits are only single bytes, len is sufficient: - firstPad = strings.Repeat(" ", numWidth-len(number)) + number - // pad wrapped lines - wrapPad = strings.Repeat(" ", numWidth) - } - - // Selecting: handle highlighting and prefixing of selected lines - selString := unselect - style := m.LineStyle - - wrapPrePad := wrapUnSelePad - if item.selected { - style = m.SelectedStyle - selString = selected - wrapPrePad = wrapSelectPad - } - - // Current: handle highlighting of current item/first-line - curPad := unmark - if index == m.viewPos.Cursor { - style = m.CurrentStyle - curPad = mark - } - - // join all prefixes - var wrapPrefix, linePrefix string - - linePrefix = strings.Join([]string{firstPad, selString, sepItem, curPad}, "") - if wrap { - wrapPrefix = strings.Join([]string{wrapPad, wrapPrePad, sepWrap, unmark}, "") // don't prefix wrap lines with CurrentMarker (unmark) - } - var content string if wrap { content = item.wrapedLines[0] @@ -254,15 +171,34 @@ out: // TODO hard limit the string length } + // retrieve line prefix from prefix closure + var linePrefix string + if prefixFunc != nil { + linePrefix = prefixFunc(index, item.selected, 0) + } + // join pad and first line content // NOTE line break is not added here because it would mess with the highlighting line := fmt.Sprintf("%s%s", linePrefix, content) + // Selecting: handle highlighting of selected and current lines + style := m.LineStyle + if item.selected { + style = m.SelectedStyle + } + if index == m.viewPos.Cursor { + style = m.CurrentStyle + } + + // skip lines when line offset is activ if !ignoreLines { // Highlight and write first line stringLines = append(stringLines, style.Styled(line)) visLines++ } + // else { // TODO nessecary? + // lineOffset-- + // } // Only write lines that are visible if visLines >= height { @@ -275,16 +211,21 @@ out: } // Write wrapped lines - for _, line := range item.wrapedLines[1:] { - // Pad left of line - // NOTE line break is not added here because it would mess with the highlighting - padLine := fmt.Sprintf("%s%s", wrapPrefix, line) - + for i, line := range item.wrapedLines[1:] { + // skip unvisible leading lines if ignoreLines && lineOffset >= 0 { lineOffset-- continue } + // Pad left of line + // NOTE line break is not added here because it would mess with the highlighting + var wrapPrefix string + if prefixFunc != nil { + wrapPrefix = prefixFunc(index, item.selected, i+1) + } + padLine := fmt.Sprintf("%s%s", wrapPrefix, line) + // Highlight and write wrapped line stringLines = append(stringLines, style.Styled(padLine)) visLines++ @@ -302,21 +243,6 @@ out: return stringLines } -// 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 - } - - diff := curser - current - if diff < 0 { - diff *= -1 - } - return diff -} - // Update changes the Model of the List according to the messages received // if the list is focused, else does nothing. func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -425,19 +351,7 @@ func NewModel() Model { CursorOffset: 5, // Wrap lines to have no loss of information - Wrap: true, - 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: ">", - SelectedPrefix: "*", - - // enable Linenumber - Number: true, + Wrap: true, less: func(k, l string) bool { return k < l @@ -865,3 +779,129 @@ func (m *Model) keepVisibleWrap(target int) (ViewPos, error) { // Within bounds return ViewPos{ItemOffset: m.viewPos.ItemOffset, LineOffset: m.viewPos.LineOffset, Cursor: target}, nil } + +// AbsolutLinePrefix returns a function which will be used for prefix generation +func AbsolutLinePrefix(position ViewPos, height int) (func(currentLine int, selected bool, wrapIndex int) string, int) { + + 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 := ">" + SelectedPrefix := "*" + + // enable Linenumber + Number := true + NumberRelative := false + + UnSelectedPrefix := "" + + offset := position.ItemOffset + + // Get separators width + widthItem := ansi.PrintableRuneWidth(Seperator) + widthWrap := ansi.PrintableRuneWidth(SeperatorWrap) + + // Find max width + sepWidth := widthItem + if widthWrap > sepWidth { + sepWidth = widthWrap + } + + // get widest possible number, for padding + numWidth := len(fmt.Sprintf("%d", offset+height-1)) + + // pad all prefixes to the same width for easy exchange + selectedString := SelectedPrefix + unselect := UnSelectedPrefix + selWid := ansi.PrintableRuneWidth(selectedString) + tmpWid := ansi.PrintableRuneWidth(unselect) + + selectWidth := selWid + if tmpWid > selectWidth { + selectWidth = tmpWid + } + selectedString = strings.Repeat(" ", selectWidth-selWid) + selectedString + + wrapSelectPad := strings.Repeat(" ", selectWidth) + wrapUnSelePad := strings.Repeat(" ", selectWidth) + if PrefixWrap { + wrapSelectPad = strings.Repeat(" ", selectWidth-selWid) + selectedString + wrapUnSelePad = strings.Repeat(" ", selectWidth-tmpWid) + unselect + } + + unselect = strings.Repeat(" ", selectWidth-tmpWid) + unselect + + // pad all separators to the same width for easy exchange + sepItem := strings.Repeat(" ", sepWidth-widthItem) + Seperator + sepWrap := strings.Repeat(" ", sepWidth-widthWrap) + SeperatorWrap + + // pad right of prefix, with length of current pointer + mark := CurrentMarker + markWidth := ansi.PrintableRuneWidth(mark) + unmark := strings.Repeat(" ", markWidth) + + // Get the hole prefix width + holePrefixWidth := numWidth + selectWidth + sepWidth + markWidth + + // Closure varibale + currentCursor := position.Cursor + + return func(currentIndex int, selected bool, wrapIndex int) string { + // if a number is set, prepend first line with number and both with enough spaces + firstPad := strings.Repeat(" ", numWidth) + var wrapPad string + var lineNum int + if Number { + lineNum = lineNumber(NumberRelative, currentCursor, currentIndex) + } + number := fmt.Sprintf("%d", lineNum) + // since digits are only single bytes, len is sufficient: + firstPad = strings.Repeat(" ", numWidth-len(number)) + number + // pad wrapped lines + wrapPad = strings.Repeat(" ", numWidth) + // Selecting: handle highlighting and prefixing of selected lines + selString := unselect + + wrapPrePad := wrapUnSelePad + if selected { + selString = selectedString + wrapPrePad = wrapSelectPad + } + + // Current: handle highlighting of current item/first-line + curPad := unmark + if currentIndex == position.Cursor { + curPad = mark + } + + // join all prefixes + var wrapPrefix, linePrefix string + + linePrefix = strings.Join([]string{firstPad, selString, sepItem, curPad}, "") + if wrapIndex > 0 { + wrapPrefix = strings.Join([]string{wrapPad, wrapPrePad, sepWrap, unmark}, "") // don't prefix wrap lines with CurrentMarker (unmark) + return wrapPrefix + } + + return linePrefix + }, holePrefixWidth +} + +// 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 + } + + diff := curser - current + if diff < 0 { + diff *= -1 + } + return diff +} From 029315c0828cecfe818f5a6a7729a8f4498d8502 Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Wed, 4 Nov 2020 10:34:23 +0100 Subject: [PATCH 44/73] Changed prefix closure to interface - added interfaces Prefixer and Suffixer for handeling line pre- and suffix more go-idomatic - added ScreenInfo struct to transfer informationa about the current screen settings better/shorter - edited example strings --- list/example/main.go | 19 ++-- list/list.go | 236 +++++++++++++++++++++++++++---------------- 2 files changed, 162 insertions(+), 93 deletions(-) diff --git a/list/example/main.go b/list/example/main.go index f40e726fa..0d0ce65f1 100644 --- a/list/example/main.go +++ b/list/example/main.go @@ -29,7 +29,7 @@ func main() { "Use 'q' or 'ctrl-c' to quit!", "You can move the highlighted index up and down with the (arrow keys 'k' and 'j'.", "Move to the beginning with 'g' and to the end with 'G'.", - "Sort the entrys with 's', but be carefull you can't unsort it again.", + "Sort the entrys with 's', but be carefull you can't restore the former order again.", "The list can handel linebreaks,\nand has wordwrap enabled if the line gets to long.", "You can select items with the space key which will select the line and mark it as such.", "Ones you hit 'enter', the selected lines will be printed to StdOut and the program exits.", @@ -37,12 +37,11 @@ func main() { "since one can not say what was a line break within an item or what is a new item", "Use '+' or '-' to move the item under the curser up and down.", "The key 'v' inverts the selected state of each item.", - //"To toggle betwen only absolute itemnumbers and also relativ numbers, the 'r' key is your friend.", + "To toggle betwen only absolute item numbers and relativ numbers, the 'r' key is your friend.", } stringerList := list.MakeStringerList(itemList) endResult := make(chan string, 1) - prefixFunc := list.AbsolutLinePrefix list := list.NewModel() list.AddItems(stringerList) @@ -52,7 +51,6 @@ func main() { list.SetEquals(func(first, second fmt.Stringer) bool { return first.String() == second.String() }) m := model{} m.list = list - m.list.PrefixFunc = prefixFunc m.endResult = endResult @@ -95,6 +93,10 @@ func (m model) View() string { // 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.NewDefault() + } switch msg := msg.(type) { case tea.KeyMsg: @@ -130,9 +132,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.list.Move(-j) return m, nil - //case "r": - // m.list.NumberRelative = !m.list.NumberRelative - // return m, nil + case "r": + d, ok := m.list.PrefixGen.(*list.DefaultPrefixer) + if ok { + d.NumberRelative = !d.NumberRelative + } + return m, nil case "m": j := 1 if m.jump != "" { diff --git a/list/list.go b/list/list.go index 3180d3428..82b914fda 100644 --- a/list/list.go +++ b/list/list.go @@ -32,6 +32,29 @@ type ViewPos struct { LineOffset int } +// ScreenInfo holds all information about the screen Area +type ScreenInfo struct { + Width int + Height int + Profile termenv.Profile +} + +// 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(ViewPos, ScreenInfo) int + Prefix(currentItem, currentLine int, selected, lastLine bool) string +} + +// Suffixer is used to suffix all visible Lines. +// InitSuffixer gets called ones on the beginning of the Lines methode +// and then Suffix ones, per line to draw, to generate according suffixes. +type Suffixer interface { + InitSuffixer(ViewPos, ScreenInfo) int + Suffix(currentItem, currentLine int, selected, lastLine bool) string +} + // Model is a bubbletea List of strings type Model struct { focus bool @@ -51,11 +74,8 @@ type Model struct { Wrap bool - PrefixFunc func(position ViewPos, height int) (func(currentIndex int, selected bool, wrapedIndex int) string, int) - // PrefixFunc func(value fmt.Stringer, position ViewPos, selected bool) string - // PrefixWrapFunc func(value fmt.Stringer, position ViewPos, selected bool, wrapedIndex int) string - // SuffixFunc func(value fmt.Stringer, position ViewPos, selected bool) string - // SuffixWrapFunc func(value fmt.Stringer, position ViewPos, selected bool, wrapedIndex int) string + // PrefixFunc func(position ViewPos, height int) (func(currentIndex int, selected bool, wrapedIndex int) string, int) + PrefixGen Prefixer LineStyle termenv.Style SelectedStyle termenv.Style @@ -113,13 +133,10 @@ func (m *Model) Lines() []string { panic("Can't display with zero width or hight of Viewport") } - var prefixFunc func(int, bool, int) string + // This is only used if the Lines methode is called befor the Update methode is called the first time var holePrefixWidth int - if m.PrefixFunc != nil { - prefixFunc, holePrefixWidth = m.PrefixFunc(m.viewPos, height) - } else { - // use default - prefixFunc, holePrefixWidth = AbsolutLinePrefix(m.viewPos, height) + if m.PrefixGen != nil { + holePrefixWidth = m.PrefixGen.InitPrefixer(m.viewPos, ScreenInfo{Height: m.Height, Width: m.Width, Profile: termenv.ColorProfile()}) } // Get actual content width @@ -171,10 +188,9 @@ out: // TODO hard limit the string length } - // retrieve line prefix from prefix closure var linePrefix string - if prefixFunc != nil { - linePrefix = prefixFunc(index, item.selected, 0) + if m.PrefixGen != nil { + linePrefix = m.PrefixGen.Prefix(index, 0, item.selected, item.wrapedLenght == 0) } // join pad and first line content @@ -221,8 +237,8 @@ out: // Pad left of line // NOTE line break is not added here because it would mess with the highlighting var wrapPrefix string - if prefixFunc != nil { - wrapPrefix = prefixFunc(index, item.selected, i+1) + if m.PrefixGen != nil { + wrapPrefix = m.PrefixGen.Prefix(index, i+1, item.selected, i == item.wrapedLenght-2) } padLine := fmt.Sprintf("%s%s", wrapPrefix, line) @@ -249,6 +265,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if !m.focus { return m, nil } + + if m.PrefixGen == nil { + // use default + m.PrefixGen = NewDefault() + } + var cmd tea.Cmd switch msg := msg.(type) { @@ -780,30 +802,72 @@ func (m *Model) keepVisibleWrap(target int) (ViewPos, error) { return ViewPos{ItemOffset: m.viewPos.ItemOffset, LineOffset: m.viewPos.LineOffset, Cursor: target}, nil } -// AbsolutLinePrefix returns a function which will be used for prefix generation -func AbsolutLinePrefix(position ViewPos, height int) (func(currentLine int, selected bool, wrapIndex int) string, int) { - - PrefixWrap := false +// 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 - Seperator := "╭" - SeperatorWrap := "│" + Seperator string + SeperatorWrap string // Mark it so that even without color support all is explicit - CurrentMarker := ">" - SelectedPrefix := "*" + CurrentMarker string + SelectedPrefix string // enable Linenumber - Number := true - NumberRelative := false + Number bool + NumberRelative bool + + UnSelectedPrefix string - UnSelectedPrefix := "" + prefixWidth int + viewPos ViewPos + + markWidth int + numWidth int + + unmark string + mark string + + selectedString string + unselect string + + wrapSelectPad string + wrapUnSelePad string + + sepItem string + sepWrap string +} + +// NewDefault returns a DefautPrefixer with default values +func NewDefault() *DefaultPrefixer { + return &DefaultPrefixer{ + 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: ">", + SelectedPrefix: "*", + UnSelectedPrefix: "", + + // enable Linenumber + Number: true, + NumberRelative: false, + } +} + +// InitPrefixer returns a function which will be used for prefix generation +func (d *DefaultPrefixer) InitPrefixer(position ViewPos, screen ScreenInfo) int { + d.viewPos = position offset := position.ItemOffset // Get separators width - widthItem := ansi.PrintableRuneWidth(Seperator) - widthWrap := ansi.PrintableRuneWidth(SeperatorWrap) + widthItem := ansi.PrintableRuneWidth(d.Seperator) + widthWrap := ansi.PrintableRuneWidth(d.SeperatorWrap) // Find max width sepWidth := widthItem @@ -812,83 +876,83 @@ func AbsolutLinePrefix(position ViewPos, height int) (func(currentLine int, sele } // get widest possible number, for padding - numWidth := len(fmt.Sprintf("%d", offset+height-1)) + d.numWidth = len(fmt.Sprintf("%d", offset+screen.Height)) // pad all prefixes to the same width for easy exchange - selectedString := SelectedPrefix - unselect := UnSelectedPrefix - selWid := ansi.PrintableRuneWidth(selectedString) - tmpWid := ansi.PrintableRuneWidth(unselect) + d.selectedString = d.SelectedPrefix + d.unselect = d.UnSelectedPrefix + selWid := ansi.PrintableRuneWidth(d.selectedString) + tmpWid := ansi.PrintableRuneWidth(d.unselect) selectWidth := selWid if tmpWid > selectWidth { selectWidth = tmpWid } - selectedString = strings.Repeat(" ", selectWidth-selWid) + selectedString + d.selectedString = strings.Repeat(" ", selectWidth-selWid) + d.selectedString - wrapSelectPad := strings.Repeat(" ", selectWidth) - wrapUnSelePad := strings.Repeat(" ", selectWidth) - if PrefixWrap { - wrapSelectPad = strings.Repeat(" ", selectWidth-selWid) + selectedString - wrapUnSelePad = strings.Repeat(" ", selectWidth-tmpWid) + unselect + d.wrapSelectPad = strings.Repeat(" ", selectWidth) + d.wrapUnSelePad = strings.Repeat(" ", selectWidth) + if d.PrefixWrap { + d.wrapSelectPad = strings.Repeat(" ", selectWidth-selWid) + d.selectedString + d.wrapUnSelePad = strings.Repeat(" ", selectWidth-tmpWid) + d.unselect } - unselect = strings.Repeat(" ", selectWidth-tmpWid) + unselect + d.unselect = strings.Repeat(" ", selectWidth-tmpWid) + d.unselect // pad all separators to the same width for easy exchange - sepItem := strings.Repeat(" ", sepWidth-widthItem) + Seperator - sepWrap := strings.Repeat(" ", sepWidth-widthWrap) + SeperatorWrap + d.sepItem = strings.Repeat(" ", sepWidth-widthItem) + d.Seperator + d.sepWrap = strings.Repeat(" ", sepWidth-widthWrap) + d.SeperatorWrap // pad right of prefix, with length of current pointer - mark := CurrentMarker - markWidth := ansi.PrintableRuneWidth(mark) - unmark := strings.Repeat(" ", markWidth) + d.mark = d.CurrentMarker + d.markWidth = ansi.PrintableRuneWidth(d.mark) + d.unmark = strings.Repeat(" ", d.markWidth) // Get the hole prefix width - holePrefixWidth := numWidth + selectWidth + sepWidth + markWidth - - // Closure varibale - currentCursor := position.Cursor - - return func(currentIndex int, selected bool, wrapIndex int) string { - // if a number is set, prepend first line with number and both with enough spaces - firstPad := strings.Repeat(" ", numWidth) - var wrapPad string - var lineNum int - if Number { - lineNum = lineNumber(NumberRelative, currentCursor, currentIndex) - } - number := fmt.Sprintf("%d", lineNum) - // since digits are only single bytes, len is sufficient: - firstPad = strings.Repeat(" ", numWidth-len(number)) + number - // pad wrapped lines - wrapPad = strings.Repeat(" ", numWidth) - // Selecting: handle highlighting and prefixing of selected lines - selString := unselect - - wrapPrePad := wrapUnSelePad - if selected { - selString = selectedString - wrapPrePad = wrapSelectPad - } + d.prefixWidth = d.numWidth + selectWidth + sepWidth + d.markWidth - // Current: handle highlighting of current item/first-line - curPad := unmark - if currentIndex == position.Cursor { - curPad = mark - } + return d.prefixWidth +} - // join all prefixes - var wrapPrefix, linePrefix string +// Prefix prefixes a given line +func (d *DefaultPrefixer) Prefix(currentIndex int, wrapIndex int, selected, lastLine bool) 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, currentIndex) + } + number := fmt.Sprintf("%d", lineNum) + // since digits are only single bytes, len is sufficient: + firstPad = strings.Repeat(" ", d.numWidth-len(number)) + number + // pad wrapped lines + wrapPad = strings.Repeat(" ", d.numWidth) + // Selecting: handle highlighting and prefixing of selected lines + selString := d.unselect - linePrefix = strings.Join([]string{firstPad, selString, sepItem, curPad}, "") - if wrapIndex > 0 { - wrapPrefix = strings.Join([]string{wrapPad, wrapPrePad, sepWrap, unmark}, "") // don't prefix wrap lines with CurrentMarker (unmark) - return wrapPrefix - } + wrapPrePad := d.wrapUnSelePad + if selected { + selString = d.selectedString + wrapPrePad = d.wrapSelectPad + } + + // Current: handle highlighting of current item/first-line + curPad := d.unmark + if currentIndex == d.viewPos.Cursor { + curPad = d.mark + } + + // join all prefixes + var wrapPrefix, linePrefix string + + linePrefix = strings.Join([]string{firstPad, selString, d.sepItem, curPad}, "") + if wrapIndex > 0 { + wrapPrefix = strings.Join([]string{wrapPad, wrapPrePad, d.sepWrap, d.unmark}, "") // don't prefix wrap lines with CurrentMarker (unmark) + return wrapPrefix + } - return linePrefix - }, holePrefixWidth + return linePrefix } // lineNumber returns line number of the given index From 1dc694a980c2babd64d5738018ab111f6eac7d5a Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Wed, 4 Nov 2020 10:34:24 +0100 Subject: [PATCH 45/73] Changed Pre/suffixer interface, implemented suffixer support - added example suffixer to example now when a suffixer is provided to the list model the resulting string should have a fixed Printable Width. As long as the Init-Prefixer and Suffixer return correct the number of the Prinatbale width used by all there processed lines. --- list/example/main.go | 22 ++++++++++++++++++++++ list/list.go | 38 ++++++++++++++++++++++---------------- 2 files changed, 44 insertions(+), 16 deletions(-) diff --git a/list/example/main.go b/list/example/main.go index 0d0ce65f1..0a61dff1d 100644 --- a/list/example/main.go +++ b/list/example/main.go @@ -5,8 +5,10 @@ import ( "fmt" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" + "github.com/muesli/reflow/ansi" "os" "strconv" + "strings" ) type model struct { @@ -44,6 +46,7 @@ func main() { endResult := make(chan string, 1) list := list.NewModel() list.AddItems(stringerList) + list.SuffixGen = &exampleSuffixer{currentMarker: "<"} // Since in this example we only use UNIQUE string items we can use a String Comparison for the equals methode // but be aware that different items in your case can have the same string -> false-positiv @@ -201,3 +204,22 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil } + +type exampleSuffixer struct { + viewPos list.ViewPos + currentMarker string + markerLenght int +} + +func (e *exampleSuffixer) InitSuffixer(viewPos list.ViewPos, screen list.ScreenInfo) int { + e.viewPos = viewPos + e.markerLenght = ansi.PrintableRuneWidth(e.currentMarker) + return e.markerLenght +} + +func (e *exampleSuffixer) Suffix(item, line int, selected bool) string { + if item == e.viewPos.Cursor && line == 0 { + return e.currentMarker + } + return strings.Repeat(" ", e.markerLenght) +} diff --git a/list/list.go b/list/list.go index 82b914fda..75f992d87 100644 --- a/list/list.go +++ b/list/list.go @@ -44,7 +44,7 @@ type ScreenInfo struct { // and then Prefix ones, per line to draw, to generate according prefixes. type Prefixer interface { InitPrefixer(ViewPos, ScreenInfo) int - Prefix(currentItem, currentLine int, selected, lastLine bool) string + Prefix(currentItem, currentLine int, selected bool) string } // Suffixer is used to suffix all visible Lines. @@ -52,7 +52,7 @@ type Prefixer interface { // and then Suffix ones, per line to draw, to generate according suffixes. type Suffixer interface { InitSuffixer(ViewPos, ScreenInfo) int - Suffix(currentItem, currentLine int, selected, lastLine bool) string + Suffix(currentItem, currentLine int, selected bool) string } // Model is a bubbletea List of strings @@ -74,8 +74,8 @@ type Model struct { Wrap bool - // PrefixFunc func(position ViewPos, height int) (func(currentIndex int, selected bool, wrapedIndex int) string, int) PrefixGen Prefixer + SuffixGen Suffixer LineStyle termenv.Style SelectedStyle termenv.Style @@ -126,6 +126,7 @@ func (m Model) View() string { // used to display the current user interface func (m *Model) Lines() []string { // get public variables as locals so they can't change while using + // check visible area height := m.Height width := m.Width @@ -133,21 +134,23 @@ func (m *Model) Lines() []string { panic("Can't display with zero width or hight of Viewport") } - // This is only used if the Lines methode is called befor the Update methode is called the first time - var holePrefixWidth int + // Get the Width of each suf/prefix + var prefixWidth, suffixWidth int if m.PrefixGen != nil { - holePrefixWidth = m.PrefixGen.InitPrefixer(m.viewPos, ScreenInfo{Height: m.Height, Width: m.Width, Profile: termenv.ColorProfile()}) + prefixWidth = m.PrefixGen.InitPrefixer(m.viewPos, ScreenInfo{Height: m.Height, Width: m.Width, Profile: termenv.ColorProfile()}) + } + if m.SuffixGen != nil { + suffixWidth = m.SuffixGen.InitSuffixer(m.viewPos, ScreenInfo{Height: m.Height, Width: m.Width, Profile: termenv.ColorProfile()}) } // Get actual content width - contentWidth := width - holePrefixWidth + contentWidth := width - prefixWidth - suffixWidth // Check if there is space for the content left if contentWidth <= 0 { panic("Can't display with zero width for content") } - // If set wrap := m.Wrap if wrap { // renew wrap of all items @@ -188,16 +191,19 @@ out: // TODO hard limit the string length } - var linePrefix string + // Surrounding content + var linePrefix, lineSuffix string if m.PrefixGen != nil { - linePrefix = m.PrefixGen.Prefix(index, 0, item.selected, item.wrapedLenght == 0) + linePrefix = m.PrefixGen.Prefix(index, 0, item.selected) + } + if m.SuffixGen != nil { + lineSuffix = fmt.Sprintf("%s%s", strings.Repeat(" ", contentWidth-ansi.PrintableRuneWidth(content)), m.SuffixGen.Suffix(index, 0, item.selected)) } - // join pad and first line content - // NOTE line break is not added here because it would mess with the highlighting - line := fmt.Sprintf("%s%s", linePrefix, content) + // Join all + line := fmt.Sprintf("%s%s%s", linePrefix, content, lineSuffix) - // Selecting: handle highlighting of selected and current lines + // Highlighting of selected and current lines style := m.LineStyle if item.selected { style = m.SelectedStyle @@ -238,7 +244,7 @@ out: // NOTE line break is not added here because it would mess with the highlighting var wrapPrefix string if m.PrefixGen != nil { - wrapPrefix = m.PrefixGen.Prefix(index, i+1, item.selected, i == item.wrapedLenght-2) + wrapPrefix = m.PrefixGen.Prefix(index, i+1, item.selected) } padLine := fmt.Sprintf("%s%s", wrapPrefix, line) @@ -915,7 +921,7 @@ func (d *DefaultPrefixer) InitPrefixer(position ViewPos, screen ScreenInfo) int } // Prefix prefixes a given line -func (d *DefaultPrefixer) Prefix(currentIndex int, wrapIndex int, selected, lastLine bool) string { +func (d *DefaultPrefixer) Prefix(currentIndex int, wrapIndex int, selected bool) string { // if a number is set, prepend first line with number and both with enough spaces firstPad := strings.Repeat(" ", d.numWidth) var wrapPad string From b126f164fd88bdb7efc6783a9b2ab0f57c3f3cc7 Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Wed, 4 Nov 2020 12:42:49 +0100 Subject: [PATCH 46/73] fixed lineOffset-bug and some typos --- list/example/main.go | 2 +- list/list.go | 15 +++++---------- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/list/example/main.go b/list/example/main.go index 0a61dff1d..eaaf908dd 100644 --- a/list/example/main.go +++ b/list/example/main.go @@ -29,7 +29,7 @@ func main() { itemList := []string{ "Welcome to the bubbles-list example!", "Use 'q' or 'ctrl-c' to quit!", - "You can move the highlighted index up and down with the (arrow keys 'k' and 'j'.", + "You can move the highlighted index up and down with the (arrow keys or 'k' and 'j'.)", "Move to the beginning with 'g' and to the end with 'G'.", "Sort the entrys with 's', but be carefull you can't restore the former order again.", "The list can handel linebreaks,\nand has wordwrap enabled if the line gets to long.", diff --git a/list/list.go b/list/list.go index 75f992d87..a12877aa3 100644 --- a/list/list.go +++ b/list/list.go @@ -155,8 +155,7 @@ func (m *Model) Lines() []string { if wrap { // renew wrap of all items for i := range m.listItems { - lineAm := m.listItems[i].genVisLines(contentWidth) - m.listItems[i] = lineAm + m.listItems[i] = m.listItems[i].genVisLines(contentWidth) } } @@ -174,7 +173,7 @@ out: } var ignoreLines bool - if wrap && lineOffset != 0 && index == offset { + if wrap && lineOffset > 0 && index == offset { ignoreLines = true } @@ -212,15 +211,12 @@ out: style = m.CurrentStyle } - // skip lines when line offset is activ + // skip lines only when line offset is activ if !ignoreLines { // Highlight and write first line stringLines = append(stringLines, style.Styled(line)) visLines++ } - // else { // TODO nessecary? - // lineOffset-- - // } // Only write lines that are visible if visLines >= height { @@ -235,7 +231,7 @@ out: // Write wrapped lines for i, line := range item.wrapedLines[1:] { // skip unvisible leading lines - if ignoreLines && lineOffset >= 0 { + if ignoreLines && lineOffset < 0 { lineOffset-- continue } @@ -716,7 +712,6 @@ func (m *Model) keepVisibleWrap(target int) (ViewPos, error) { return ViewPos{}, OutOfBounds(fmt.Errorf("can't move beyond list bonderys, with requested cursor position: %d", target)) } - // Nothing to do if target == 0 { return ViewPos{}, nil } @@ -784,7 +779,7 @@ func (m *Model) keepVisibleWrap(target int) (ViewPos, error) { for _, item := range lineCount { lastOffset = item.listIndex // Visible Offset if item.linesBefor > upperBorder { - lineOffset = item.linesBefor - upperBorder + lineOffset = item.linesBefor - upperBorder - 1 break } } From 14964361997567717481233275ac92eb21c3b1f3 Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Fri, 6 Nov 2020 20:54:53 +0100 Subject: [PATCH 47/73] Made View() functional and changed list-Model field - changed from holding the wraped lines in the (list-)item, to in place generating of the wraped lines. - gathered screen information in one struct field of list-Model to have the informations more together. --- list/example/main.go | 4 +- list/list.go | 167 +++++++++++++++++-------------------------- list/list_test.go | 20 +++--- 3 files changed, 77 insertions(+), 114 deletions(-) diff --git a/list/example/main.go b/list/example/main.go index eaaf908dd..7c9a34d9c 100644 --- a/list/example/main.go +++ b/list/example/main.go @@ -189,8 +189,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { width := msg.Width height := msg.Height - m.list.Width = width - m.list.Height = 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 diff --git a/list/list.go b/list/list.go index a12877aa3..72a8438ab 100644 --- a/list/list.go +++ b/list/list.go @@ -61,16 +61,13 @@ type Model struct { listItems []item - viewPos ViewPos - less func(string, string) bool // function used for sorting equals func(fmt.Stringer, fmt.Stringer) bool // used after sorting, to be set from the user CursorOffset int // offset or margin between the cursor and the viewport(visible) border - Width int - Height int - Profile termenv.Profile + Screen ScreenInfo + viewPos ViewPos Wrap bool @@ -85,11 +82,26 @@ type Model struct { // Item are Items used in the list Model // to hold the Content represented as a string type item struct { - selected bool - wrapedLines []string - wrapedLenght int - wrapedto int - value fmt.Stringer + selected bool + value fmt.Stringer +} + +// itemString returns the lines of the item string value wrapped to the according content-width +func (m *Model) itemString(i item) []string { + var preWidth, sufWidth int + if m.PrefixGen != nil { + preWidth = m.PrefixGen.InitPrefixer(m.viewPos, m.Screen) + } + if m.SuffixGen != nil { + sufWidth = m.SuffixGen.InitSuffixer(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 { + return []string{lines[0]} + } + return lines } // StringItem is just a convenience to satisfy the fmt.Stringer interface with plain strings @@ -108,15 +120,6 @@ func MakeStringerList(list []string) []fmt.Stringer { return stringerList } -// genVisLines renews the wrap of the content into wrapedLines -func (i item) genVisLines(wrapTo int) item { - i.wrapedLines = strings.Split(wordwrap.String(i.value.String(), wrapTo), "\n") - //TODO hard wrap lines/words - i.wrapedLenght = len(i.wrapedLines) - i.wrapedto = wrapTo - return i -} - // View renders the List to a (displayable) string func (m Model) View() string { return strings.Join(m.Lines(), "\n") @@ -128,8 +131,8 @@ func (m *Model) Lines() []string { // get public variables as locals so they can't change while using // check visible area - height := m.Height - width := m.Width + height := m.Screen.Height + width := m.Screen.Width if height*width <= 0 { panic("Can't display with zero width or hight of Viewport") } @@ -137,10 +140,10 @@ func (m *Model) Lines() []string { // Get the Width of each suf/prefix var prefixWidth, suffixWidth int if m.PrefixGen != nil { - prefixWidth = m.PrefixGen.InitPrefixer(m.viewPos, ScreenInfo{Height: m.Height, Width: m.Width, Profile: termenv.ColorProfile()}) + prefixWidth = m.PrefixGen.InitPrefixer(m.viewPos, m.Screen) } if m.SuffixGen != nil { - suffixWidth = m.SuffixGen.InitSuffixer(m.viewPos, ScreenInfo{Height: m.Height, Width: m.Width, Profile: termenv.ColorProfile()}) + suffixWidth = m.SuffixGen.InitSuffixer(m.viewPos, m.Screen) } // Get actual content width @@ -151,19 +154,12 @@ func (m *Model) Lines() []string { panic("Can't display with zero width for content") } - wrap := m.Wrap - if wrap { - // renew wrap of all items - for i := range m.listItems { - m.listItems[i] = m.listItems[i].genVisLines(contentWidth) - } - } - lineOffset := m.viewPos.LineOffset offset := m.viewPos.ItemOffset var visLines int stringLines := make([]string, 0, height) + out: // Handle list items, start at first visible and go till end of list or visible (break) for index := offset; index < len(m.listItems); index++ { @@ -172,80 +168,46 @@ out: break } - var ignoreLines bool - if wrap && lineOffset > 0 && index == offset { - ignoreLines = true - } - item := m.listItems[index] - if wrap && item.wrapedLenght <= 0 { - panic("cant display item with no visible content") - } - var content string - if wrap { - content = item.wrapedLines[0] - } else { - content = strings.Split(item.value.String(), "\n")[0] // TODO SplitN - // TODO hard limit the string length - } - - // Surrounding content - var linePrefix, lineSuffix string - if m.PrefixGen != nil { - linePrefix = m.PrefixGen.Prefix(index, 0, item.selected) - } - if m.SuffixGen != nil { - lineSuffix = fmt.Sprintf("%s%s", strings.Repeat(" ", contentWidth-ansi.PrintableRuneWidth(content)), m.SuffixGen.Suffix(index, 0, item.selected)) - } - - // Join all - line := fmt.Sprintf("%s%s%s", linePrefix, content, lineSuffix) - - // Highlighting of selected and current lines - style := m.LineStyle - if item.selected { - style = m.SelectedStyle - } - if index == m.viewPos.Cursor { - style = m.CurrentStyle - } - - // skip lines only when line offset is activ - if !ignoreLines { - // Highlight and write first line - stringLines = append(stringLines, style.Styled(line)) - visLines++ - } - - // Only write lines that are visible - if visLines >= height { - break out - } + lines := m.itemString(item) - // Don't write wrapped lines if not set - if !wrap || item.wrapedLenght <= 1 { - continue + var ignoreLines bool + if len(lines) > 1 && lineOffset > 0 && index == offset { + ignoreLines = true } - // Write wrapped lines - for i, line := range item.wrapedLines[1:] { + // Write lines + for i, line := range lines { // skip unvisible leading lines if ignoreLines && lineOffset < 0 { lineOffset-- continue } - // Pad left of line - // NOTE line break is not added here because it would mess with the highlighting - var wrapPrefix string + // Surrounding content + var linePrefix, lineSuffix string if m.PrefixGen != nil { - wrapPrefix = m.PrefixGen.Prefix(index, i+1, item.selected) + linePrefix = m.PrefixGen.Prefix(index, i, item.selected) + } + if m.SuffixGen != nil { + lineSuffix = fmt.Sprintf("%s%s", strings.Repeat(" ", contentWidth-ansi.PrintableRuneWidth(line)), m.SuffixGen.Suffix(index, i, item.selected)) + } + + // Join all + line := fmt.Sprintf("%s%s%s", linePrefix, line, lineSuffix) + + // Highlighting of selected and current lines + style := m.LineStyle + if item.selected { + style = m.SelectedStyle + } + if index == m.viewPos.Cursor { + style = m.CurrentStyle } - padLine := fmt.Sprintf("%s%s", wrapPrefix, line) // Highlight and write wrapped line - stringLines = append(stringLines, style.Styled(padLine)) + stringLines = append(stringLines, style.Styled(line)) visLines++ // Only write lines that are visible @@ -255,8 +217,8 @@ out: } } lenght := len(stringLines) - if lenght > m.Height { - panic(fmt.Sprintf("can't display %d lines when screen has %d lines.", lenght, m.Height)) + if lenght > m.Screen.Height { + panic(fmt.Sprintf("can't display %d lines when screen has %d lines.", lenght, m.Screen.Height)) } return stringLines } @@ -322,8 +284,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: - m.Width = msg.Width - m.Height = msg.Height + m.Screen.Width = msg.Width + m.Screen.Height = msg.Height + m.Screen.Profile = termenv.ColorProfile() return m, cmd @@ -696,12 +659,12 @@ func (m *Model) KeepVisible(target int) (ViewPos, error) { } // Cursor Position is within bounds -> all good - if visItemsBeforCursor >= m.CursorOffset && visItemsBeforCursor < m.Height-m.CursorOffset { + if visItemsBeforCursor >= m.CursorOffset && visItemsBeforCursor < m.Screen.Height-m.CursorOffset { return ViewPos{Cursor: target, ItemOffset: m.viewPos.ItemOffset}, err } // Cursor is beyond boundry -> move visibel Area down - lowerOffset := m.viewPos.ItemOffset - (m.Height - m.CursorOffset - visItemsBeforCursor - 1) + lowerOffset := m.viewPos.ItemOffset - (m.Screen.Height - m.CursorOffset - visItemsBeforCursor - 1) return ViewPos{Cursor: target, ItemOffset: lowerOffset}, err } @@ -732,9 +695,9 @@ func (m *Model) keepVisibleWrap(target int) (ViewPos, error) { if direction >= 0 { lineSum = 1 // Cursorline is not counted in the following loop, so do it here } - // calculate how much space(lines) the items befor the requested cursor position occupy - for c := target - 1; c >= 0 && c > target-m.Height; c-- { - lineAm := m.listItems[c].wrapedLenght + // calculate how much space/lines the items befor the requested cursor position occupy + for c := target - 1; c >= 0 && c > target-m.Screen.Height; c-- { + lineAm := len(m.itemString(m.listItems[c])) lineSum += lineAm lineCount = append(lineCount, beforCursor{c, lineSum}) @@ -748,7 +711,7 @@ func (m *Model) keepVisibleWrap(target int) (ViewPos, error) { if !upper && lineSum > upperBorder { upper = true } - lowerBorder := m.Height - m.CursorOffset + lowerBorder := m.Screen.Height - m.CursorOffset if !lower && lineSum >= lowerBorder && c >= m.viewPos.ItemOffset { lower = true } @@ -761,7 +724,7 @@ func (m *Model) keepVisibleWrap(target int) (ViewPos, error) { // can't Move beyond list end, setting offsets accordingly if direction >= 0 && target >= len(m.listItems)-1 { var lastOffset, lineOffset int - lowerBorder := m.Height - m.CursorOffset + lowerBorder := m.Screen.Height - m.CursorOffset for _, item := range lineCount { lastOffset = item.listIndex // Visible Offset if item.linesBefor > lowerBorder { @@ -789,7 +752,7 @@ func (m *Model) keepVisibleWrap(target int) (ViewPos, error) { // beyond lower border -> Moving Down if direction >= 0 && lower { var lastOffset, lineOffset int - lowerBorder := m.Height - m.CursorOffset + lowerBorder := m.Screen.Height - m.CursorOffset for _, item := range lineCount { if item.linesBefor >= lowerBorder { lastOffset = item.listIndex // Visible Offset diff --git a/list/list_test.go b/list/list_test.go index ccc1ca826..2b8238275 100644 --- a/list/list_test.go +++ b/list/list_test.go @@ -27,11 +27,11 @@ func TestViewBounds(t *testing.T) { for _, testM := range genModels(genTestModels()) { for i, line := range strings.Split(testM.model.View(), "\n") { lineWidth := ansi.PrintableRuneWidth(line) - width := testM.model.Width + width := testM.model.Screen.Width if lineWidth > width { t.Errorf("The line:\n\n%s\n%s^\n\n is %d chars longer than the Viewport width.", line, strings.Repeat(" ", width-1), lineWidth-width) } - if i > testM.model.Height { + if i > testM.model.Screen.Height { t.Error("There are more lines produced from the View() than the Viewport height") } } @@ -82,8 +82,8 @@ func genModels(rawLists []test) []testModel { processedList := make([]testModel, len(rawLists)) for i, list := range rawLists { m := NewModel() - m.Height = list.vHeight - m.Width = list.vWidth + m.Screen.Height = list.vHeight + m.Screen.Width = list.vWidth m.AddItems(MakeStringerList(list.items)) newItem := testModel{model: m, shouldBe: list.shouldBe} processedList[i] = newItem @@ -164,18 +164,18 @@ func genPanicTests() []test { // genDynamicModels generats test cases for dynamic actions like movement, sorting, resizing func genDynamicModels() []testModel { blankModel := Model{} - blankModel.Height = 10 - blankModel.Width = 10 + blankModel.Screen.Height = 10 + blankModel.Screen.Width = 10 blankModel.AddItems(MakeStringerList([]string{"", "", "", "", "", "", "", "", "", "", "", ""})) blankModel.Move(0) moveBottom := NewModel() - moveBottom.Width = 10 - moveBottom.Height = 10 + moveBottom.Screen.Width = 10 + moveBottom.Screen.Height = 10 moveBottom.AddItems(MakeStringerList([]string{"", "", "", ""})) moveBottom.Bottom() moveDown := NewModel() - moveDown.Height = 50 - moveDown.Width = 80 + moveDown.Screen.Height = 50 + moveDown.Screen.Width = 80 moveDown.AddItems(MakeStringerList([]string{"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""})) moveDown.viewPos.Cursor = 45 // set cursor next to line Offset Border so that the down move, should move the hole visible area. moveDown.Move(1) From 8fcf4824d7acb19b61a2aa4ad84bf583cf75205b Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Thu, 12 Nov 2020 13:34:47 +0100 Subject: [PATCH 48/73] research bugs, found some, but not yet all fixed - reformated code in keepVisibleWrap to reduce code duplication and to find up movement bug after multi-linebreak-item -> found it but no easy fix, still a todo - fixed one lineoffset bug, still one or more to go... - added key to write string of View to file for generating testcases more easly. - added a Copy function for the deep copy of a list model. - added default prefixer to test add changed the testcases accordingly - added a test for the KeepVisible, but thinging about rewriting all tests... --- go.mod | 1 + go.sum | 2 + list/example/main.go | 9 ++++ list/list.go | 95 +++++++++++++++++----------------- list/list_test.go | 118 ++++++++++++++++++++++++++++++++++++++----- 5 files changed, 164 insertions(+), 61 deletions(-) diff --git a/go.mod b/go.mod index b3339494c..f2b12cc65 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ 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 diff --git a/go.sum b/go.sum index 905faafc7..6e40cf0e3 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ 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= diff --git a/list/example/main.go b/list/example/main.go index 7c9a34d9c..8b1719e92 100644 --- a/list/example/main.go +++ b/list/example/main.go @@ -17,6 +17,7 @@ type model struct { finished bool endResult chan<- string jump string + lastViews []string } type stringItem string @@ -41,6 +42,7 @@ func main() { "The key 'v' inverts the selected state of each item.", "To toggle betwen only absolute item numbers and relativ numbers, the 'r' key is your friend.", } + stringerList := list.MakeStringerList(itemList) endResult := make(chan string, 1) @@ -174,6 +176,13 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.endResult <- result.String() return m, tea.Quit + case "t": + m.lastViews = append(m.lastViews, m.View()) + return m, nil + case "T": + f, _ := os.OpenFile("test_cases.txt", os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + f.WriteString(strings.Join(m.lastViews, "\n##########################\n")) + return m, tea.Quit default: // resets jump buffer to prevent confusion m.jump = "" diff --git a/list/list.go b/list/list.go index 72a8438ab..5bb2c5569 100644 --- a/list/list.go +++ b/list/list.go @@ -3,6 +3,7 @@ package list import ( "fmt" tea "github.com/charmbracelet/bubbletea" + "github.com/jinzhu/copier" "github.com/muesli/reflow/ansi" "github.com/muesli/reflow/wordwrap" "github.com/muesli/termenv" @@ -180,7 +181,7 @@ out: // Write lines for i, line := range lines { // skip unvisible leading lines - if ignoreLines && lineOffset < 0 { + if ignoreLines && lineOffset > 0 { lineOffset-- continue } @@ -644,6 +645,7 @@ func (m *Model) KeepVisible(target int) (ViewPos, error) { if m.Wrap { return m.keepVisibleWrap(target) } + m.viewPos.LineOffset = 0 visItemsBeforCursor := target - m.viewPos.ItemOffset @@ -669,7 +671,6 @@ func (m *Model) KeepVisible(target int) (ViewPos, error) { } func (m *Model) keepVisibleWrap(target int) (ViewPos, error) { - var lower, upper bool // Visible lower/upper if !m.CheckWithinBorder(target) { return ViewPos{}, OutOfBounds(fmt.Errorf("can't move beyond list bonderys, with requested cursor position: %d", target)) @@ -680,7 +681,8 @@ func (m *Model) keepVisibleWrap(target int) (ViewPos, error) { } direction := 1 - if target-m.viewPos.Cursor < 0 { + diff := target - m.viewPos.Cursor + if diff < 0 { direction = -1 } @@ -689,83 +691,75 @@ func (m *Model) keepVisibleWrap(target int) (ViewPos, error) { linesBefor int } - var lineCount []beforCursor + lineCount := make([]beforCursor, 0, m.Screen.Height) var lineSum int if direction >= 0 { lineSum = 1 // Cursorline is not counted in the following loop, so do it here } + + var lower, upper bool // Visible lower/upper + upperBorder := m.CursorOffset + lowerBorder := m.Screen.Height - m.CursorOffset // calculate how much space/lines the items befor the requested cursor position occupy for c := target - 1; c >= 0 && c > target-m.Screen.Height; c-- { - lineAm := len(m.itemString(m.listItems[c])) - lineSum += lineAm + lineSum += len(m.itemString(m.listItems[c])) lineCount = append(lineCount, beforCursor{c, lineSum}) // if new target infront of old visible offset dont mark borders + // TODO here is a bug: when there is a list item with more than Screen.Height-m.CursorOffset lines + // the up movement below this item will move to the wrong position, no solution yet if target-1 < m.viewPos.ItemOffset+m.CursorOffset { continue } // mark the pass of a border - upperBorder := m.CursorOffset if !upper && lineSum > upperBorder { upper = true } - lowerBorder := m.Screen.Height - m.CursorOffset if !lower && lineSum >= lowerBorder && c >= m.viewPos.ItemOffset { lower = true } } // Can't Move visible infront of list begin - if direction < 0 && len(lineCount) > 0 && lineCount[len(lineCount)-1].linesBefor < m.CursorOffset && m.viewPos.ItemOffset <= 0 && m.viewPos.LineOffset <= 0 { + if direction < 0 && len(lineCount) > 0 && // possible upwards movement + lineCount[len(lineCount)-1].linesBefor < m.CursorOffset && // beyond upper border + m.viewPos.ItemOffset <= 0 && m.viewPos.LineOffset <= 0 { // but allready at beginning of list + return ViewPos{Cursor: target}, nil } - // can't Move beyond list end, setting offsets accordingly - if direction >= 0 && target >= len(m.listItems)-1 { - var lastOffset, lineOffset int - lowerBorder := m.Screen.Height - m.CursorOffset - for _, item := range lineCount { - lastOffset = item.listIndex // Visible Offset - if item.linesBefor > lowerBorder { - lineOffset = item.linesBefor - lowerBorder - break - } + + var lastOffset, lineOffset int + for _, count := range lineCount { + lastOffset = count.listIndex // Visible Offset + // can't Move beyond list end, setting offsets accordingly + if target >= len(m.listItems)-1 && count.linesBefor > lowerBorder { + lineOffset = count.linesBefor - lowerBorder + return ViewPos{ItemOffset: lastOffset, LineOffset: lineOffset, Cursor: len(m.listItems) - 1}, nil } - return ViewPos{ItemOffset: lastOffset, LineOffset: lineOffset, Cursor: len(m.listItems) - 1}, nil - } - - // infront upper border -> Move up - if direction < 0 && !upper { - var lastOffset, lineOffset int - upperBorder := m.CursorOffset - for _, item := range lineCount { - lastOffset = item.listIndex // Visible Offset - if item.linesBefor > upperBorder { - lineOffset = item.linesBefor - upperBorder - 1 - break - } + // infront upper border -> Move up + if direction < 0 && !upper && count.linesBefor > upperBorder { + lineOffset = count.linesBefor - upperBorder + return ViewPos{ItemOffset: lastOffset, LineOffset: lineOffset, Cursor: target}, nil } - return ViewPos{ItemOffset: lastOffset, LineOffset: lineOffset, Cursor: target}, nil - } - - // beyond lower border -> Moving Down - if direction >= 0 && lower { - var lastOffset, lineOffset int - lowerBorder := m.Screen.Height - m.CursorOffset - for _, item := range lineCount { - if item.linesBefor >= lowerBorder { - lastOffset = item.listIndex // Visible Offset - lineOffset = item.linesBefor - lowerBorder - break - } + // beyond lower border -> Moving Down + if direction >= 0 && lower && count.linesBefor >= lowerBorder { + lastOffset = count.listIndex // Visible Offset + lineOffset = count.linesBefor - lowerBorder + return ViewPos{ItemOffset: lastOffset, LineOffset: lineOffset, Cursor: target}, nil } - return ViewPos{ItemOffset: lastOffset, LineOffset: lineOffset, Cursor: target}, nil } - // Within bounds + + // Within bounds: only change cursor return ViewPos{ItemOffset: m.viewPos.ItemOffset, LineOffset: m.viewPos.LineOffset, Cursor: target}, nil } +// MoveByLine moves the Viewposition by one line +// not by a item +//func (m *Model) MoveByLine(amount) (ViewPos, error) { +//} + // DefaultPrefixer is the default struct used for Prefixing a line type DefaultPrefixer struct { PrefixWrap bool @@ -933,3 +927,10 @@ func lineNumber(relativ bool, curser, current int) int { } return diff } + +// Copy returns a deep copy of the list-model +func (m *Model) Copy() *Model { + copiedModel := Model{} + copier.Copy(&copiedModel, &m) + return &copiedModel +} diff --git a/list/list_test.go b/list/list_test.go index 2b8238275..97bc0515c 100644 --- a/list/list_test.go +++ b/list/list_test.go @@ -25,6 +25,7 @@ type testModel struct { // NEVER leaves the bounds since then it could mess with the layout. func TestViewBounds(t *testing.T) { for _, testM := range genModels(genTestModels()) { + testM.model.PrefixGen = NewDefault() for i, line := range strings.Split(testM.model.View(), "\n") { lineWidth := ansi.PrintableRuneWidth(line) width := testM.model.Screen.Width @@ -42,6 +43,7 @@ func TestViewBounds(t *testing.T) { // Because there is no margin for diviations, if the test fails, lock also if the "golden sample" is sane. func TestGoldenSamples(t *testing.T) { for _, testM := range genModels(genTestModels()) { + testM.model.PrefixGen = NewDefault() actual := testM.model.View() expected := testM.shouldBe if actual != expected { @@ -69,6 +71,7 @@ func TestPanic(t *testing.T) { // TestDynamic tests the view output after a movement/view-changing method func TestDynamic(t *testing.T) { for _, test := range genDynamicModels() { + test.model.PrefixGen = NewDefault() actual := test.model.View() expected := test.shouldBe if actual != expected { @@ -104,7 +107,7 @@ func genTestModels() []test { []string{ "", }, - "\x1b[7m0 ╭>\x1b[0m", + "\x1b[7m 0 ╭>\x1b[0m", }, // if exceding the boards and softwrap (at word bounderys are possible // wrap there. Dont increment the item number because its still the same item. @@ -117,9 +120,9 @@ func genTestModels() []test { "\x1b[7m0 ╭>robert\x1b[0m\n\x1b[7m │ frost\x1b[0m", }, { - 10, - 10, - []string{"", "", "", "", "", "", "", "", "", "", "", "", "", "", ""}, + 9, + 9, + []string{"", "", "", "", "", "", "", "", "", "", "", "", "", ""}, "\x1b[7m0 ╭>\x1b[0m\n" + `1 ╭ 2 ╭ @@ -128,8 +131,7 @@ func genTestModels() []test { 5 ╭ 6 ╭ 7 ╭ -8 ╭ -9 ╭ `, +8 ╭ `, }, } } @@ -165,23 +167,40 @@ func genPanicTests() []test { func genDynamicModels() []testModel { blankModel := Model{} blankModel.Screen.Height = 10 - blankModel.Screen.Width = 10 + blankModel.Screen.Width = 9 blankModel.AddItems(MakeStringerList([]string{"", "", "", "", "", "", "", "", "", "", "", ""})) blankModel.Move(0) moveBottom := NewModel() moveBottom.Screen.Width = 10 - moveBottom.Screen.Height = 10 + moveBottom.Screen.Height = 9 moveBottom.AddItems(MakeStringerList([]string{"", "", "", ""})) moveBottom.Bottom() moveDown := NewModel() moveDown.Screen.Height = 50 moveDown.Screen.Width = 80 moveDown.AddItems(MakeStringerList([]string{"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""})) - moveDown.viewPos.Cursor = 45 // set cursor next to line Offset Border so that the down move, should move the hole visible area. + moveDown.viewPos.Cursor = 49 // set cursor next to line Offset Border so that the down move, should move the hole visible area. moveDown.Move(1) + moveLines := NewModel() + moveLines.Screen.Height = 50 + moveLines.Screen.Width = 80 + moveLines.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", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""})) + moveLines.viewPos.Cursor = 44 // set cursor next to line Offset Border so that the down move, should move the hole visible area. + moveLines.Move(1) + moveFurther := moveLines.Copy() + moveFurther.Move(1) return []testModel{ {model: blankModel, - shouldBe: "\n\n\n\n\n\n\n\n", + shouldBe: ` 0 ╭> + 1 ╭ + 2 ╭ + 3 ╭ + 4 ╭ + 5 ╭ + 6 ╭ + 7 ╭ + 8 ╭ + 9 ╭ `, afterMethode: "Move(0)", }, {model: moveBottom, @@ -189,8 +208,7 @@ func genDynamicModels() []testModel { afterMethode: "Bottom", }, {model: moveDown, - shouldBe: ` 1 ╭ - 2 ╭ + shouldBe: ` 2 ╭ 3 ╭ 4 ╭ 5 ╭ @@ -238,8 +256,66 @@ func genDynamicModels() []testModel { `47 ╭ 48 ╭ 49 ╭ -50 ╭ `, - afterMethode: "Down", +50 ╭ +51 ╭ `, + afterMethode: "Move(1)", + }, + {model: moveLines, + shouldBe: ` │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ + │ ` + + "\n\x1b[7m45 ╭>\x1b[0m\n" + + `46 ╭ +47 ╭ +48 ╭ +49 ╭ `, + afterMethode: "Move(1)", + }, + {model: *moveFurther, + shouldBe: " ", + afterMethode: "Move(1)", }, } } @@ -260,3 +336,17 @@ func TestCheckBorder(t *testing.T) { t.Errorf("len(list) is out of border") } } + +func TestKeepVisible(t *testing.T) { + m := NewModel() + m.Screen = ScreenInfo{Height: 50, Width: 80} + // make more line breaks within the listitem, than the screen has lines + 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", "", "", ""})) + newView, err := m.keepVisibleWrap(47) + if err != nil { + t.Error(err) + } + if newView.ItemOffset != 2 && newView.LineOffset != 0 { + t.Errorf("wrong postion of the view: '%d' after moving beyond multi-linebreak-item", newView.ItemOffset) + } +} From c416b54a317fd29d6e606d61bcc25ce978ad8cee Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Thu, 12 Nov 2020 14:30:46 +0100 Subject: [PATCH 49/73] refactored into dedicated files --- list/item.go | 48 +++ list/list.go | 805 +++++++++++++++++----------------------------- list/list_test.go | 6 +- list/prefixer.go | 168 ++++++++++ list/suffixer.go | 9 + 5 files changed, 525 insertions(+), 511 deletions(-) create mode 100644 list/item.go create mode 100644 list/prefixer.go create mode 100644 list/suffixer.go diff --git a/list/item.go b/list/item.go new file mode 100644 index 000000000..9be2a0920 --- /dev/null +++ b/list/item.go @@ -0,0 +1,48 @@ +package list + +import ( + "fmt" + "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 { + selected bool + value fmt.Stringer +} + +// itemLines returns the lines of the item string value wrapped to the according content-width +func (m *Model) itemLines(i item) []string { + var preWidth, sufWidth int + if m.PrefixGen != nil { + preWidth = m.PrefixGen.InitPrefixer(m.viewPos, m.Screen) + } + if m.SuffixGen != nil { + sufWidth = m.SuffixGen.InitSuffixer(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 { + return []string{lines[0]} + } + return lines +} + +// 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 +} diff --git a/list/list.go b/list/list.go index 5bb2c5569..27dbb0bc5 100644 --- a/list/list.go +++ b/list/list.go @@ -5,57 +5,11 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/jinzhu/copier" "github.com/muesli/reflow/ansi" - "github.com/muesli/reflow/wordwrap" "github.com/muesli/termenv" "sort" "strings" ) -// NotFound gets return if the search does not yield a result -type NotFound error - -// OutOfBounds is return if and index is outside the list bounderys -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 Modul -type ConfigError error - -// NotFocused is a error return if the action can only be applied to a focused list -type NotFocused error - -// ViewPos is used for holding the information about the View parameters -type ViewPos struct { - Cursor int - ItemOffset int - LineOffset int -} - -// ScreenInfo holds all information about the screen Area -type ScreenInfo struct { - Width int - Height int - Profile termenv.Profile -} - -// 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(ViewPos, ScreenInfo) int - Prefix(currentItem, currentLine int, selected bool) string -} - -// Suffixer is used to suffix all visible Lines. -// InitSuffixer gets called ones on the beginning of the Lines methode -// and then Suffix ones, per line to draw, to generate according suffixes. -type Suffixer interface { - InitSuffixer(ViewPos, ScreenInfo) int - Suffix(currentItem, currentLine int, selected bool) string -} - // Model is a bubbletea List of strings type Model struct { focus bool @@ -80,45 +34,35 @@ type Model struct { CurrentStyle termenv.Style } -// Item are Items used in the list Model -// to hold the Content represented as a string -type item struct { - selected bool - value fmt.Stringer -} +// NewModel returns a Model with some save/sane defaults +// design to transfer as much internal information to the user +func NewModel() Model { + p := termenv.ColorProfile() + selStyle := termenv.Style{}.Background(p.Color("#ff0000")) + // just reverse colors to keep there information + curStyle := termenv.Style{}.Reverse() + return Model{ + // Accept key presses + focus: true, -// itemString returns the lines of the item string value wrapped to the according content-width -func (m *Model) itemString(i item) []string { - var preWidth, sufWidth int - if m.PrefixGen != nil { - preWidth = m.PrefixGen.InitPrefixer(m.viewPos, m.Screen) - } - if m.SuffixGen != nil { - sufWidth = m.SuffixGen.InitSuffixer(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 { - return []string{lines[0]} - } - return lines -} + // Try to keep $CursorOffset lines between Cursor and screen Border + CursorOffset: 5, -// StringItem is just a convenience to satisfy the fmt.Stringer interface with plain strings -type StringItem string + // Wrap lines to have no loss of information + Wrap: true, -func (s StringItem) String() string { - return string(s) -} + less: func(k, l string) bool { + return k < l + }, -// 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) + SelectedStyle: selStyle, + CurrentStyle: curStyle, } - return stringerList +} + +// Init does nothing +func (m Model) Init() tea.Cmd { + return nil } // View renders the List to a (displayable) string @@ -171,7 +115,7 @@ out: item := m.listItems[index] - lines := m.itemString(item) + lines := m.itemLines(item) var ignoreLines bool if len(lines) > 1 && lineOffset > 0 && index == offset { @@ -233,7 +177,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.PrefixGen == nil { // use default - m.PrefixGen = NewDefault() + m.PrefixGen = NewPrefixer() } var cmd tea.Cmd @@ -303,15 +247,33 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } -// AddItems adds the given Items to the list Model -// Without performing updating the View TODO -func (m *Model) AddItems(itemList []fmt.Stringer) { - for _, i := range itemList { - m.listItems = append(m.listItems, item{ - selected: false, - value: i}, - ) - } +// NotFound gets return if the search does not yield a result +type NotFound error + +// OutOfBounds is return if and index is outside the list bounderys +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 Modul +type ConfigError error + +// NotFocused is a error return if the action can only be applied to a focused list +type NotFocused error + +// ViewPos is used for holding the information about the View parameters +type ViewPos struct { + Cursor int + ItemOffset int + LineOffset int +} + +// ScreenInfo holds all information about the screen Area +type ScreenInfo struct { + Width int + Height int + Profile termenv.Profile } // Move moves the cursor by amount and returns OutOfBounds error if amount go's beyond list borders @@ -324,118 +286,236 @@ func (m *Model) Move(amount int) (int, error) { return newPos.Cursor, err } -// NewModel returns a Model with some save/sane defaults -// design to transfer as much internal information to the user -func NewModel() Model { - p := termenv.ColorProfile() - selStyle := termenv.Style{}.Background(p.Color("#ff0000")) - // 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, - - // Wrap lines to have no loss of information - Wrap: true, - - less: func(k, l string) bool { - return k < l - }, - - SelectedStyle: selStyle, - CurrentStyle: curStyle, - } +// Top moves the cursor to the first line +func (m *Model) Top() { + m.viewPos.Cursor = 0 + m.viewPos.ItemOffset = 0 + m.viewPos.LineOffset = 0 } -// Init does nothing -func (m Model) Init() tea.Cmd { - return nil +// Bottom moves the cursor to the last line +func (m *Model) Bottom() { + end := len(m.listItems) - 1 + m.Move(end) } -// ToggleSelect toggles the selected status -// of the current Index if amount is 0 -// returns err != nil when amount lands outside list and safely does nothing -// else if amount is not 0 toggles selected amount items -// excluding the item on which the cursor would land -func (m *Model) ToggleSelect(amount int) error { - if amount == 0 { - m.listItems[m.viewPos.Cursor].selected = !m.listItems[m.viewPos.Cursor].selected +// KeepVisible will set the Cursor within the visible area of the list +// and if CursorOffset is != 0 will set it within this bounderys +// if CursorOffset is bigger than half the screen hight error will be of type ConfigError +// If the cursor would be outside of the list, it will be set to the according nearest value +// and error will be of type OutOfBounds. The return int is the absolut item number on which the cursor gets set +func (m *Model) KeepVisible(target int) (ViewPos, error) { + var err error + // Check if Cursor would be beyond list + if length := len(m.listItems); target >= length { + target = length - 1 + errMsg := "requested cursor position was behind of the list" + err = OutOfBounds(fmt.Errorf(errMsg)) } - direction := 1 - if amount < 0 { - direction = -1 + // Check if Cursor would be infront of list + if target < 0 { + target = 0 + errMsg := "requested cursor position was infront of the list" + err = OutOfBounds(fmt.Errorf(errMsg)) } - cur := m.viewPos.Cursor + if target == 0 { + return ViewPos{}, nil + } - target, err := m.Move(amount) - start, end := cur, target - if direction < 0 { - start, end = target+1, cur+1 + if m.Wrap { + return m.keepVisibleWrap(target) } - // mark/start at first item - if cur+amount < 0 { - start = 0 + + m.viewPos.LineOffset = 0 + + visItemsBeforCursor := target - m.viewPos.ItemOffset + + // Visible Area and Cursor are at beginning of List -> cant move further up. + if m.viewPos.ItemOffset <= 0 && visItemsBeforCursor <= m.CursorOffset { + return ViewPos{Cursor: target}, err } - // mark last item when trying to go beyond list - if cur+amount >= len(m.listItems) { - end++ + + // Cursor is infront of Boundry -> move visible Area up + if visItemsBeforCursor < m.CursorOffset { + return ViewPos{Cursor: target, ItemOffset: target - m.CursorOffset}, err } - for c := start; c < end; c++ { - m.listItems[c].selected = !m.listItems[c].selected + + // Cursor Position is within bounds -> all good + if visItemsBeforCursor >= m.CursorOffset && visItemsBeforCursor < m.Screen.Height-m.CursorOffset { + return ViewPos{Cursor: target, ItemOffset: m.viewPos.ItemOffset}, err } - return err + + // Cursor is beyond boundry -> move visibel Area down + lowerOffset := m.viewPos.ItemOffset - (m.Screen.Height - m.CursorOffset - visItemsBeforCursor - 1) + return ViewPos{Cursor: target, ItemOffset: lowerOffset}, err } -// MarkSelected selects or unselects depending on 'mark' -// amount = 0 changes the current item but does not move the cursor -// if amount would be outside the list error is from type OutOfBounds -// else all items till but excluding the end cursor position gets (un-)marked -func (m *Model) MarkSelected(amount int, mark bool) error { - cur := m.viewPos.Cursor - if amount == 0 { - m.listItems[cur].selected = mark - return nil +func (m *Model) keepVisibleWrap(target int) (ViewPos, error) { + + if !m.CheckWithinBorder(target) { + return ViewPos{}, OutOfBounds(fmt.Errorf("can't move beyond list bonderys, with requested cursor position: %d", target)) + } + + if target == 0 { + return ViewPos{}, nil } + direction := 1 - if amount < 0 { + diff := target - m.viewPos.Cursor + if diff < 0 { direction = -1 } - target := cur + amount - direction - if !m.CheckWithinBorder(target) { - return OutOfBounds(fmt.Errorf("Cant go beyond list borders: %d", target)) - } - for c := 0; c < amount*direction; c++ { - m.listItems[cur+c].selected = mark + type beforCursor struct { + listIndex int + linesBefor int } - m.viewPos.Cursor = target - m.Move(direction) - return nil -} -// ToggleAllSelected inverts the select state of ALL items -func (m *Model) ToggleAllSelected() { - for i := range m.listItems { - m.listItems[i].selected = !m.listItems[i].selected + lineCount := make([]beforCursor, 0, m.Screen.Height) + + var lineSum int + if direction >= 0 { + lineSum = 1 // Cursorline is not counted in the following loop, so do it here } -} -// Top moves the cursor to the first line -func (m *Model) Top() { - m.viewPos.Cursor = 0 - m.viewPos.ItemOffset = 0 - m.viewPos.LineOffset = 0 + var lower, upper bool // Visible lower/upper + upperBorder := m.CursorOffset + lowerBorder := m.Screen.Height - m.CursorOffset + // calculate how much space/lines the items befor the requested cursor position occupy + for c := target - 1; c >= 0 && c > target-m.Screen.Height; c-- { + lineSum += len(m.itemLines(m.listItems[c])) + lineCount = append(lineCount, beforCursor{c, lineSum}) + + // if new target infront of old visible offset dont mark borders + // TODO here is a bug: when there is a list item with more than Screen.Height-m.CursorOffset lines + // the up movement below this item will move to the wrong position, no solution yet + if target-1 < m.viewPos.ItemOffset+m.CursorOffset { + continue + } + + // mark the pass of a border + if !upper && lineSum > upperBorder { + upper = true + } + if !lower && lineSum >= lowerBorder && c >= m.viewPos.ItemOffset { + lower = true + } + } + + // Can't Move visible infront of list begin + if direction < 0 && len(lineCount) > 0 && // possible upwards movement + lineCount[len(lineCount)-1].linesBefor < m.CursorOffset && // beyond upper border + m.viewPos.ItemOffset <= 0 && m.viewPos.LineOffset <= 0 { // but allready at beginning of list + + return ViewPos{Cursor: target}, nil + } + + var lastOffset, lineOffset int + for _, count := range lineCount { + lastOffset = count.listIndex // Visible Offset + // can't Move beyond list end, setting offsets accordingly + if target >= len(m.listItems)-1 && count.linesBefor > lowerBorder { + lineOffset = count.linesBefor - lowerBorder + return ViewPos{ItemOffset: lastOffset, LineOffset: lineOffset, Cursor: len(m.listItems) - 1}, nil + } + // infront upper border -> Move up + if direction < 0 && !upper && count.linesBefor > upperBorder { + lineOffset = count.linesBefor - upperBorder + return ViewPos{ItemOffset: lastOffset, LineOffset: lineOffset, Cursor: target}, nil + } + // beyond lower border -> Moving Down + if direction >= 0 && lower && count.linesBefor >= lowerBorder { + lastOffset = count.listIndex // Visible Offset + lineOffset = count.linesBefor - lowerBorder + return ViewPos{ItemOffset: lastOffset, LineOffset: lineOffset, Cursor: target}, nil + } + } + + // Within bounds: only change cursor + return ViewPos{ItemOffset: m.viewPos.ItemOffset, LineOffset: m.viewPos.LineOffset, Cursor: target}, nil } -// Bottom moves the cursor to the last line -func (m *Model) Bottom() { - end := len(m.listItems) - 1 - m.Move(end) +// AddItems adds the given Items to the list Model +// Without performing updating the View TODO +func (m *Model) AddItems(itemList []fmt.Stringer) { + for _, i := range itemList { + m.listItems = append(m.listItems, item{ + selected: false, + value: i}, + ) + } +} + +// ToggleSelect toggles the selected status +// of the current Index if amount is 0 +// returns err != nil when amount lands outside list and safely does nothing +// else if amount is not 0 toggles selected amount items +// excluding the item on which the cursor would land +func (m *Model) ToggleSelect(amount int) error { + if amount == 0 { + m.listItems[m.viewPos.Cursor].selected = !m.listItems[m.viewPos.Cursor].selected + } + + direction := 1 + if amount < 0 { + direction = -1 + } + + cur := m.viewPos.Cursor + + target, err := m.Move(amount) + start, end := cur, target + if direction < 0 { + start, end = target+1, cur+1 + } + // mark/start at first item + if cur+amount < 0 { + start = 0 + } + // mark last item when trying to go beyond list + if cur+amount >= len(m.listItems) { + end++ + } + for c := start; c < end; c++ { + m.listItems[c].selected = !m.listItems[c].selected + } + return err +} + +// MarkSelected selects or unselects depending on 'mark' +// amount = 0 changes the current item but does not move the cursor +// if amount would be outside the list error is from type OutOfBounds +// else all items till but excluding the end cursor position gets (un-)marked +func (m *Model) MarkSelected(amount int, mark bool) error { + cur := m.viewPos.Cursor + if amount == 0 { + m.listItems[cur].selected = mark + return nil + } + direction := 1 + if amount < 0 { + direction = -1 + } + + target := cur + amount - direction + if !m.CheckWithinBorder(target) { + return OutOfBounds(fmt.Errorf("Cant go beyond list borders: %d", target)) + } + for c := 0; c < amount*direction; c++ { + m.listItems[cur+c].selected = mark + } + m.viewPos.Cursor = target + m.Move(direction) + return nil +} + +// ToggleAllSelected inverts the select state of ALL items +func (m *Model) ToggleAllSelected() { + for i := range m.listItems { + m.listItems[i].selected = !m.listItems[i].selected + } } // GetSelected returns you a list of all items @@ -450,6 +530,29 @@ func (m *Model) GetSelected() []fmt.Stringer { return selected } +// Sort sorts the list items according to the set less-function +// If there is no Equals-function set (with SetEquals), the current Item will maybe change! +// Since the index of the current pointer does not change +func (m *Model) Sort() { + equ := m.equals + var tmp item + if equ != nil { + tmp = m.listItems[m.viewPos.Cursor] + } + sort.Sort(m) + if equ == nil { + return + } + for i, item := range m.listItems { + if is := equ(item.value, tmp.value); is { + m.viewPos.Cursor = i + break // Stop when first (and hopefully only one) is found + } + } + m.Move(0) + +} + // Less is a Proxy to the less function, set from the user. func (m *Model) Less(i, j int) bool { return m.less(m.listItems[i].value.String(), m.listItems[j].value.String()) @@ -472,27 +575,15 @@ func (m *Model) SetLess(less func(string, string) bool) { m.less = less } -// Sort sorts the list items according to the set less-function -// If there is no Equals-function set (with SetEquals), the current Item will maybe change! -// Since the index of the current pointer does not change -func (m *Model) Sort() { - equ := m.equals - var tmp item - if equ != nil { - tmp = m.listItems[m.viewPos.Cursor] - } - sort.Sort(m) - if equ == nil { - return - } - for i, item := range m.listItems { - if is := equ(item.value, tmp.value); is { - m.viewPos.Cursor = i - break // Stop when first (and hopefully only one) is found - } - } - m.Move(0) +// SetEquals sets the internal equals methode used if provided to set the cursor again on the same item after sorting +func (m *Model) SetEquals(equ func(first, second fmt.Stringer) bool) { + m.equals = equ +} +// GetEquals returns the internal equals methode +// used to set the curser after sorting on the same item again +func (m *Model) GetEquals() func(first, second fmt.Stringer) bool { + return m.equals } // MoveItem moves the current item by amount to the end @@ -537,17 +628,6 @@ func (m *Model) Focused() bool { return m.focus } -// SetEquals sets the internal equals methode used if provided to set the cursor again on the same item after sorting -func (m *Model) SetEquals(equ func(first, second fmt.Stringer) bool) { - m.equals = equ -} - -// GetEquals returns the internal equals methode -// used to set the curser after sorting on the same item again -func (m *Model) GetEquals() func(first, second fmt.Stringer) bool { - return m.equals -} - // GetIndex returns NotFound error if the Equals Methode is not set (SetEquals) // else it returns the index of the found item func (m *Model) GetIndex(toSearch fmt.Stringer) (int, error) { @@ -586,6 +666,15 @@ func (m *Model) UpdateAllItems(updater func(fmt.Stringer) fmt.Stringer) { } } +// UpdateSelectedItems updates all selected items within the list with given function +func (m *Model) UpdateSelectedItems(updater func(fmt.Stringer) fmt.Stringer) { + for i, item := range m.listItems { + if item.selected { + m.listItems[i].value = updater(item.value) + } + } +} + // GetCursorIndex returns current cursor position within the List func (m *Model) GetCursorIndex() (int, error) { if !m.focus { @@ -608,311 +697,11 @@ func (m *Model) GetAllItems() []fmt.Stringer { return stringerList } -// UpdateSelectedItems updates all selected items within the list with given function -func (m *Model) UpdateSelectedItems(updater func(fmt.Stringer) fmt.Stringer) { - for i, item := range m.listItems { - if item.selected { - m.listItems[i].value = updater(item.value) - } - } -} - -// KeepVisible will set the Cursor within the visible area of the list -// and if CursorOffset is != 0 will set it within this bounderys -// if CursorOffset is bigger than half the screen hight error will be of type ConfigError -// If the cursor would be outside of the list, it will be set to the according nearest value -// and error will be of type OutOfBounds. The return int is the absolut item number on which the cursor gets set -func (m *Model) KeepVisible(target int) (ViewPos, error) { - var err error - // Check if Cursor would be beyond list - if length := len(m.listItems); target >= length { - target = length - 1 - errMsg := "requested cursor position was behind of the list" - err = OutOfBounds(fmt.Errorf(errMsg)) - } - - // Check if Cursor would be infront of list - if target < 0 { - target = 0 - errMsg := "requested cursor position was infront of the list" - err = OutOfBounds(fmt.Errorf(errMsg)) - } - - if target == 0 { - return ViewPos{}, nil - } - - if m.Wrap { - return m.keepVisibleWrap(target) - } - - m.viewPos.LineOffset = 0 - - visItemsBeforCursor := target - m.viewPos.ItemOffset - - // Visible Area and Cursor are at beginning of List -> cant move further up. - if m.viewPos.ItemOffset <= 0 && visItemsBeforCursor <= m.CursorOffset { - return ViewPos{Cursor: target}, err - } - - // Cursor is infront of Boundry -> move visible Area up - if visItemsBeforCursor < m.CursorOffset { - return ViewPos{Cursor: target, ItemOffset: target - m.CursorOffset}, err - } - - // Cursor Position is within bounds -> all good - if visItemsBeforCursor >= m.CursorOffset && visItemsBeforCursor < m.Screen.Height-m.CursorOffset { - return ViewPos{Cursor: target, ItemOffset: m.viewPos.ItemOffset}, err - } - - // Cursor is beyond boundry -> move visibel Area down - lowerOffset := m.viewPos.ItemOffset - (m.Screen.Height - m.CursorOffset - visItemsBeforCursor - 1) - return ViewPos{Cursor: target, ItemOffset: lowerOffset}, err -} - -func (m *Model) keepVisibleWrap(target int) (ViewPos, error) { - - if !m.CheckWithinBorder(target) { - return ViewPos{}, OutOfBounds(fmt.Errorf("can't move beyond list bonderys, with requested cursor position: %d", target)) - } - - if target == 0 { - return ViewPos{}, nil - } - - direction := 1 - diff := target - m.viewPos.Cursor - if diff < 0 { - direction = -1 - } - - type beforCursor struct { - listIndex int - linesBefor int - } - - lineCount := make([]beforCursor, 0, m.Screen.Height) - - var lineSum int - if direction >= 0 { - lineSum = 1 // Cursorline is not counted in the following loop, so do it here - } - - var lower, upper bool // Visible lower/upper - upperBorder := m.CursorOffset - lowerBorder := m.Screen.Height - m.CursorOffset - // calculate how much space/lines the items befor the requested cursor position occupy - for c := target - 1; c >= 0 && c > target-m.Screen.Height; c-- { - lineSum += len(m.itemString(m.listItems[c])) - lineCount = append(lineCount, beforCursor{c, lineSum}) - - // if new target infront of old visible offset dont mark borders - // TODO here is a bug: when there is a list item with more than Screen.Height-m.CursorOffset lines - // the up movement below this item will move to the wrong position, no solution yet - if target-1 < m.viewPos.ItemOffset+m.CursorOffset { - continue - } - - // mark the pass of a border - if !upper && lineSum > upperBorder { - upper = true - } - if !lower && lineSum >= lowerBorder && c >= m.viewPos.ItemOffset { - lower = true - } - } - - // Can't Move visible infront of list begin - if direction < 0 && len(lineCount) > 0 && // possible upwards movement - lineCount[len(lineCount)-1].linesBefor < m.CursorOffset && // beyond upper border - m.viewPos.ItemOffset <= 0 && m.viewPos.LineOffset <= 0 { // but allready at beginning of list - - return ViewPos{Cursor: target}, nil - } - - var lastOffset, lineOffset int - for _, count := range lineCount { - lastOffset = count.listIndex // Visible Offset - // can't Move beyond list end, setting offsets accordingly - if target >= len(m.listItems)-1 && count.linesBefor > lowerBorder { - lineOffset = count.linesBefor - lowerBorder - return ViewPos{ItemOffset: lastOffset, LineOffset: lineOffset, Cursor: len(m.listItems) - 1}, nil - } - // infront upper border -> Move up - if direction < 0 && !upper && count.linesBefor > upperBorder { - lineOffset = count.linesBefor - upperBorder - return ViewPos{ItemOffset: lastOffset, LineOffset: lineOffset, Cursor: target}, nil - } - // beyond lower border -> Moving Down - if direction >= 0 && lower && count.linesBefor >= lowerBorder { - lastOffset = count.listIndex // Visible Offset - lineOffset = count.linesBefor - lowerBorder - return ViewPos{ItemOffset: lastOffset, LineOffset: lineOffset, Cursor: target}, nil - } - } - - // Within bounds: only change cursor - return ViewPos{ItemOffset: m.viewPos.ItemOffset, LineOffset: m.viewPos.LineOffset, Cursor: target}, nil -} - // MoveByLine moves the Viewposition by one line // not by a item //func (m *Model) MoveByLine(amount) (ViewPos, error) { //} -// 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 - Seperator string - SeperatorWrap string - - // Mark it so that even without color support all is explicit - CurrentMarker string - SelectedPrefix string - - // enable Linenumber - Number bool - NumberRelative bool - - UnSelectedPrefix string - - prefixWidth int - viewPos ViewPos - - markWidth int - numWidth int - - unmark string - mark string - - selectedString string - unselect string - - wrapSelectPad string - wrapUnSelePad string - - sepItem string - sepWrap string -} - -// NewDefault returns a DefautPrefixer with default values -func NewDefault() *DefaultPrefixer { - return &DefaultPrefixer{ - 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: ">", - SelectedPrefix: "*", - UnSelectedPrefix: "", - - // enable Linenumber - Number: true, - NumberRelative: false, - } -} - -// InitPrefixer returns a function which will be used for prefix generation -func (d *DefaultPrefixer) InitPrefixer(position ViewPos, screen ScreenInfo) int { - d.viewPos = position - - offset := position.ItemOffset - - // Get separators width - widthItem := ansi.PrintableRuneWidth(d.Seperator) - widthWrap := ansi.PrintableRuneWidth(d.SeperatorWrap) - - // Find max width - sepWidth := widthItem - if widthWrap > sepWidth { - sepWidth = widthWrap - } - - // get widest possible number, for padding - d.numWidth = len(fmt.Sprintf("%d", offset+screen.Height)) - - // pad all prefixes to the same width for easy exchange - d.selectedString = d.SelectedPrefix - d.unselect = d.UnSelectedPrefix - selWid := ansi.PrintableRuneWidth(d.selectedString) - tmpWid := ansi.PrintableRuneWidth(d.unselect) - - selectWidth := selWid - if tmpWid > selectWidth { - selectWidth = tmpWid - } - d.selectedString = strings.Repeat(" ", selectWidth-selWid) + d.selectedString - - d.wrapSelectPad = strings.Repeat(" ", selectWidth) - d.wrapUnSelePad = strings.Repeat(" ", selectWidth) - if d.PrefixWrap { - d.wrapSelectPad = strings.Repeat(" ", selectWidth-selWid) + d.selectedString - d.wrapUnSelePad = strings.Repeat(" ", selectWidth-tmpWid) + d.unselect - } - - d.unselect = strings.Repeat(" ", selectWidth-tmpWid) + d.unselect - - // pad all separators to the same width for easy exchange - d.sepItem = strings.Repeat(" ", sepWidth-widthItem) + d.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 + selectWidth + sepWidth + d.markWidth - - return d.prefixWidth -} - -// Prefix prefixes a given line -func (d *DefaultPrefixer) Prefix(currentIndex int, wrapIndex int, selected bool) 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, currentIndex) - } - number := fmt.Sprintf("%d", lineNum) - // since digits are only single bytes, len is sufficient: - firstPad = strings.Repeat(" ", d.numWidth-len(number)) + number - // pad wrapped lines - wrapPad = strings.Repeat(" ", d.numWidth) - // Selecting: handle highlighting and prefixing of selected lines - selString := d.unselect - - wrapPrePad := d.wrapUnSelePad - if selected { - selString = d.selectedString - wrapPrePad = d.wrapSelectPad - } - - // Current: handle highlighting of current item/first-line - curPad := d.unmark - if currentIndex == d.viewPos.Cursor { - curPad = d.mark - } - - // join all prefixes - var wrapPrefix, linePrefix string - - linePrefix = strings.Join([]string{firstPad, selString, d.sepItem, curPad}, "") - if wrapIndex > 0 { - wrapPrefix = strings.Join([]string{wrapPad, wrapPrePad, d.sepWrap, d.unmark}, "") // don't prefix wrap lines with CurrentMarker (unmark) - return wrapPrefix - } - - 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 diff --git a/list/list_test.go b/list/list_test.go index 97bc0515c..2fe88fe28 100644 --- a/list/list_test.go +++ b/list/list_test.go @@ -25,7 +25,7 @@ type testModel struct { // NEVER leaves the bounds since then it could mess with the layout. func TestViewBounds(t *testing.T) { for _, testM := range genModels(genTestModels()) { - testM.model.PrefixGen = NewDefault() + testM.model.PrefixGen = NewPrefixer() for i, line := range strings.Split(testM.model.View(), "\n") { lineWidth := ansi.PrintableRuneWidth(line) width := testM.model.Screen.Width @@ -43,7 +43,7 @@ func TestViewBounds(t *testing.T) { // Because there is no margin for diviations, if the test fails, lock also if the "golden sample" is sane. func TestGoldenSamples(t *testing.T) { for _, testM := range genModels(genTestModels()) { - testM.model.PrefixGen = NewDefault() + testM.model.PrefixGen = NewPrefixer() actual := testM.model.View() expected := testM.shouldBe if actual != expected { @@ -71,7 +71,7 @@ func TestPanic(t *testing.T) { // TestDynamic tests the view output after a movement/view-changing method func TestDynamic(t *testing.T) { for _, test := range genDynamicModels() { - test.model.PrefixGen = NewDefault() + test.model.PrefixGen = NewPrefixer() actual := test.model.View() expected := test.shouldBe if actual != expected { diff --git a/list/prefixer.go b/list/prefixer.go new file mode 100644 index 000000000..4f8041fdc --- /dev/null +++ b/list/prefixer.go @@ -0,0 +1,168 @@ +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(ViewPos, ScreenInfo) int + Prefix(currentItem, currentLine int, selected bool) 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 + Seperator string + SeperatorWrap string + + // Mark it so that even without color support all is explicit + CurrentMarker string + SelectedPrefix string + + // enable Linenumber + Number bool + NumberRelative bool + + UnSelectedPrefix string + + prefixWidth int + viewPos ViewPos + + markWidth int + numWidth int + + unmark string + mark string + + selectedString string + unselect string + + wrapSelectPad string + wrapUnSelePad string + + sepItem string + sepWrap string +} + +// NewPrefixer returns a DefautPrefixer with default values +func NewPrefixer() *DefaultPrefixer { + return &DefaultPrefixer{ + 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: ">", + SelectedPrefix: "*", + UnSelectedPrefix: "", + + // enable Linenumber + Number: true, + NumberRelative: false, + } +} + +// InitPrefixer sets up all strings used to prefix a given line later by Prefix() +func (d *DefaultPrefixer) InitPrefixer(position ViewPos, screen ScreenInfo) int { + d.viewPos = position + + offset := position.ItemOffset + + // Get separators width + widthItem := ansi.PrintableRuneWidth(d.Seperator) + widthWrap := ansi.PrintableRuneWidth(d.SeperatorWrap) + + // Find max width + sepWidth := widthItem + if widthWrap > sepWidth { + sepWidth = widthWrap + } + + // get widest possible number, for padding + d.numWidth = len(fmt.Sprintf("%d", offset+screen.Height)) + + // pad all prefixes to the same width for easy exchange + d.selectedString = d.SelectedPrefix + d.unselect = d.UnSelectedPrefix + selWid := ansi.PrintableRuneWidth(d.selectedString) + tmpWid := ansi.PrintableRuneWidth(d.unselect) + + selectWidth := selWid + if tmpWid > selectWidth { + selectWidth = tmpWid + } + d.selectedString = strings.Repeat(" ", selectWidth-selWid) + d.selectedString + + d.wrapSelectPad = strings.Repeat(" ", selectWidth) + d.wrapUnSelePad = strings.Repeat(" ", selectWidth) + if d.PrefixWrap { + d.wrapSelectPad = strings.Repeat(" ", selectWidth-selWid) + d.selectedString + d.wrapUnSelePad = strings.Repeat(" ", selectWidth-tmpWid) + d.unselect + } + + d.unselect = strings.Repeat(" ", selectWidth-tmpWid) + d.unselect + + // pad all separators to the same width for easy exchange + d.sepItem = strings.Repeat(" ", sepWidth-widthItem) + d.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 + selectWidth + sepWidth + d.markWidth + + return d.prefixWidth +} + +// Prefix prefixes a given line +func (d *DefaultPrefixer) Prefix(currentIndex int, wrapIndex int, selected bool) 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, currentIndex) + } + number := fmt.Sprintf("%d", lineNum) + // since digits are only single bytes, len is sufficient: + firstPad = strings.Repeat(" ", d.numWidth-len(number)) + number + // pad wrapped lines + wrapPad = strings.Repeat(" ", d.numWidth) + // Selecting: handle highlighting and prefixing of selected lines + selString := d.unselect + + wrapPrePad := d.wrapUnSelePad + if selected { + selString = d.selectedString + wrapPrePad = d.wrapSelectPad + } + + // Current: handle highlighting of current item/first-line + curPad := d.unmark + if currentIndex == d.viewPos.Cursor { + curPad = d.mark + } + + // join all prefixes + var wrapPrefix, linePrefix string + + linePrefix = strings.Join([]string{firstPad, selString, d.sepItem, curPad}, "") + if wrapIndex > 0 { + wrapPrefix = strings.Join([]string{wrapPad, wrapPrePad, d.sepWrap, d.unmark}, "") // don't prefix wrap lines with CurrentMarker (unmark) + return wrapPrefix + } + + return linePrefix +} diff --git a/list/suffixer.go b/list/suffixer.go new file mode 100644 index 000000000..adbcb4549 --- /dev/null +++ b/list/suffixer.go @@ -0,0 +1,9 @@ +package list + +// Suffixer is used to suffix all visible Lines. +// InitSuffixer gets called ones on the beginning of the Lines methode +// and then Suffix ones, per line to draw, to generate according suffixes. +type Suffixer interface { + InitSuffixer(ViewPos, ScreenInfo) int + Suffix(currentItem, currentLine int, selected bool) string +} From 7857dc94d8f6ea11968613ccedd9425be3f17212 Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Fri, 13 Nov 2020 13:24:25 +0100 Subject: [PATCH 50/73] Rewrote Tests to be more specific - rewrote tests - Move exampleSuffixer to suffixer.go changed other files accordingly - removed redundant Border checks within Lines() - changed DefaultPrefixer to mark wraped line if selected --- list/example/main.go | 32 +-- list/list.go | 9 - list/list_test.go | 456 ++++++++++++++----------------------------- list/prefixer.go | 2 +- list/suffixer.go | 35 ++++ 5 files changed, 186 insertions(+), 348 deletions(-) diff --git a/list/example/main.go b/list/example/main.go index 8b1719e92..b315506a1 100644 --- a/list/example/main.go +++ b/list/example/main.go @@ -5,7 +5,6 @@ import ( "fmt" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" - "github.com/muesli/reflow/ansi" "os" "strconv" "strings" @@ -46,16 +45,16 @@ func main() { stringerList := list.MakeStringerList(itemList) endResult := make(chan string, 1) - list := list.NewModel() - list.AddItems(stringerList) - list.SuffixGen = &exampleSuffixer{currentMarker: "<"} + l := list.NewModel() + l.AddItems(stringerList) + l.SuffixGen = list.NewSuffixer() // Since in this example we only use UNIQUE string items we can use a String Comparison for the equals methode // but be aware that different items in your case can have the same string -> false-positiv // Better: Assert back to your struct and test on something unique within it! - list.SetEquals(func(first, second fmt.Stringer) bool { return first.String() == second.String() }) + l.SetEquals(func(first, second fmt.Stringer) bool { return first.String() == second.String() }) m := model{} - m.list = list + m.list = l m.endResult = endResult @@ -100,7 +99,7 @@ func (m model) View() string { func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.list.PrefixGen == nil { // use default - m.list.PrefixGen = list.NewDefault() + m.list.PrefixGen = list.NewPrefixer() } switch msg := msg.(type) { @@ -213,22 +212,3 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil } - -type exampleSuffixer struct { - viewPos list.ViewPos - currentMarker string - markerLenght int -} - -func (e *exampleSuffixer) InitSuffixer(viewPos list.ViewPos, screen list.ScreenInfo) int { - e.viewPos = viewPos - e.markerLenght = ansi.PrintableRuneWidth(e.currentMarker) - return e.markerLenght -} - -func (e *exampleSuffixer) Suffix(item, line int, selected bool) string { - if item == e.viewPos.Cursor && line == 0 { - return e.currentMarker - } - return strings.Repeat(" ", e.markerLenght) -} diff --git a/list/list.go b/list/list.go index 27dbb0bc5..3f55da67a 100644 --- a/list/list.go +++ b/list/list.go @@ -108,11 +108,6 @@ func (m *Model) Lines() []string { out: // Handle list items, start at first visible and go till end of list or visible (break) for index := offset; index < len(m.listItems); index++ { - if index >= len(m.listItems) || index < 0 { - // TODO log error - break - } - item := m.listItems[index] lines := m.itemLines(item) @@ -161,10 +156,6 @@ out: } } } - lenght := len(stringLines) - if lenght > m.Screen.Height { - panic(fmt.Sprintf("can't display %d lines when screen has %d lines.", lenght, m.Screen.Height)) - } return stringLines } diff --git a/list/list_test.go b/list/list_test.go index 2fe88fe28..d963f5a3f 100644 --- a/list/list_test.go +++ b/list/list_test.go @@ -1,352 +1,184 @@ package list import ( - "github.com/muesli/reflow/ansi" + "fmt" + tea "github.com/charmbracelet/bubbletea" "strings" "testing" ) -// test is a shorthand and will be converted to proper testModels -// with genModels -type test struct { - vWidth int - vHeight int - items []string - shouldBe string -} - -type testModel struct { - model Model - shouldBe string - afterMethode string +// TestViewPanic runs the View on various model list model states that should yield a panic +func TestNoAreaPanic(t *testing.T) { + m := NewModel() + var panicMsg interface{} + defer func() { + panicMsg, _ = recover().(string) + if panicMsg != "Can't display with zero width or hight of Viewport" { + t.Errorf("No Panic or wrong panic message: %s", panicMsg) + } + }() + m.View() } -// TestViewBounds is use to make sure that the Renderer String -// NEVER leaves the bounds since then it could mess with the layout. -func TestViewBounds(t *testing.T) { - for _, testM := range genModels(genTestModels()) { - testM.model.PrefixGen = NewPrefixer() - for i, line := range strings.Split(testM.model.View(), "\n") { - lineWidth := ansi.PrintableRuneWidth(line) - width := testM.model.Screen.Width - if lineWidth > width { - t.Errorf("The line:\n\n%s\n%s^\n\n is %d chars longer than the Viewport width.", line, strings.Repeat(" ", width-1), lineWidth-width) - } - if i > testM.model.Screen.Height { - t.Error("There are more lines produced from the View() than the Viewport height") - } +// TestNoContentSpacePanic Fails if after the Prefixer Width is subtracted there is still spaces left for contnent when there shouldent be +func TestNoContentSpacePanic(t *testing.T) { + m := NewModel() + m.Screen = ScreenInfo{Width: 1, Height: 50} + m.PrefixGen = NewPrefixer() + m.SuffixGen = NewSuffixer() + var panicMsg interface{} + defer func() { + panicMsg, _ = recover().(string) + if panicMsg != "Can't display with zero width for content" { + t.Errorf("No Panic or wrong panic message: %s", panicMsg) } - } + }() + m.View() } -// TestGoldenSamples checks the View's string result against a knowen string (golden sample) -// Because there is no margin for diviations, if the test fails, lock also if the "golden sample" is sane. -func TestGoldenSamples(t *testing.T) { - for _, testM := range genModels(genTestModels()) { - testM.model.PrefixGen = NewPrefixer() - actual := testM.model.View() - expected := testM.shouldBe - if actual != expected { - t.Errorf("expected Output:\n\n%s\n\nactual Output:\n\n%s\n\n", expected, actual) - } +// 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} + if len(m.Lines()) != 0 { + t.Error("A list with no entrys should return no lines.") + } + m.Sort() + if len(m.Lines()) != 0 { + t.Error("A list with no entrys should return no lines.") } } -// TestPanic is also a golden sampling, but for cases that should panic. -func TestPanic(t *testing.T) { - for _, testM := range genModels(genPanicTests()) { - panicRes := make(chan interface{}) - go func(resChan chan<- interface{}) { - defer func() { resChan <- recover() }() // Why does this Yield "%!s()"? - testM.model.View() - }(panicRes) - actual := <-panicRes - expected := testM.shouldBe - if actual != expected { - t.Errorf("expected panic Output:\n\n%s\n\nactual Output:\n\n%s\n\n", expected, actual) - } +// 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() + // first two swaped + itemList := MakeStringerList([]string{"2", "1", "3", "4", "5", "6", "7", "8", "9"}) + m.AddItems(itemList) + // Sort them + m.Sort() + // swap them again + m.MoveItem(1) + // should be the like the beginning + sorteditemList := m.GetAllItems() + + // make sure all itemList get processed + shorter, longer := sorteditemList, itemList + if len(itemList) > len(longer) { + shorter, longer = itemList, sorteditemList } -} -// TestDynamic tests the view output after a movement/view-changing method -func TestDynamic(t *testing.T) { - for _, test := range genDynamicModels() { - test.model.PrefixGen = NewPrefixer() - actual := test.model.View() - expected := test.shouldBe - if actual != expected { - t.Errorf("expected Output, after Methode '%s' called:\n\n%s\n\nactual Output:\n\n%s\n\n", test.afterMethode, expected, actual) + // Process/check all itemList + for c, item := range longer { + if item.String() != shorter[c].String() { + t.Error("something basic failed") } } -} -// genModels embeds the fields from the rawModels into an actual model -func genModels(rawLists []test) []testModel { - processedList := make([]testModel, len(rawLists)) - for i, list := range rawLists { - m := NewModel() - m.Screen.Height = list.vHeight - m.Screen.Width = list.vWidth - m.AddItems(MakeStringerList(list.items)) - newItem := testModel{model: m, shouldBe: list.shouldBe} - processedList[i] = newItem + 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) } - return processedList -} -// small helper function to generate simple test cases. -// for more elaborate ones append them afterwards. -func genTestModels() []test { - return []test{ - // The default has abs linenumber and this seperator enabled - // so that even if the terminal does not support colors - // all propertys are still distinguishable. - { - 240, - 80, - []string{ - "", - }, - "\x1b[7m 0 ╭>\x1b[0m", - }, - // if exceding the boards and softwrap (at word bounderys are possible - // wrap there. Dont increment the item number because its still the same item. - { - 10, - 2, - []string{ - "robert frost", - }, - "\x1b[7m0 ╭>robert\x1b[0m\n\x1b[7m │ frost\x1b[0m", - }, - { - 9, - 9, - []string{"", "", "", "", "", "", "", "", "", "", "", "", "", ""}, - "\x1b[7m0 ╭>\x1b[0m\n" + - `1 ╭ -2 ╭ -3 ╭ -4 ╭ -5 ╭ -6 ╭ -7 ╭ -8 ╭ `, - }, + light := "\x1b[7m" + cur := ">" + for i, line := range out { + // Check Prefixes + num := fmt.Sprintf("%d", i) + prefix := light + strings.Repeat(" ", 2-len(num)) + num + " ╭" + 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 = " " + light = "" } } -// genPanicTests generats test cases that should panic with the shouldBe string -func genPanicTests() []test { - return []test{ - // no width to display -> panic - { - 0, - 1, - []string{""}, - "Can't display with zero width or hight of Viewport", - }, - // no height to display -> panic - { - 1, - 0, - []string{""}, - "Can't display with zero width or hight of Viewport", - }, - // no item to display -> panic TODO handel/think-about this case - //{ - // 1, - // 1, - // []string{}, - // "", - //}, +// 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"})) + m.viewPos = ViewPos{LineOffset: 1} + + out := m.Lines() + wrap, sep := "│", "╭" + num := "\x1b[7m " + for i, line := range out { + if i%2 == 1 { + num = fmt.Sprintf(" %1d", (i/2)+1) + } + prefix := fmt.Sprintf("%s %s %d", num, wrap, i) + 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) + } + wrap, sep = sep, wrap + num = " " } } -// genDynamicModels generats test cases for dynamic actions like movement, sorting, resizing -func genDynamicModels() []testModel { - blankModel := Model{} - blankModel.Screen.Height = 10 - blankModel.Screen.Width = 9 - blankModel.AddItems(MakeStringerList([]string{"", "", "", "", "", "", "", "", "", "", "", ""})) - blankModel.Move(0) - moveBottom := NewModel() - moveBottom.Screen.Width = 10 - moveBottom.Screen.Height = 9 - moveBottom.AddItems(MakeStringerList([]string{"", "", "", ""})) - moveBottom.Bottom() - moveDown := NewModel() - moveDown.Screen.Height = 50 - moveDown.Screen.Width = 80 - moveDown.AddItems(MakeStringerList([]string{"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""})) - moveDown.viewPos.Cursor = 49 // set cursor next to line Offset Border so that the down move, should move the hole visible area. - moveDown.Move(1) - moveLines := NewModel() - moveLines.Screen.Height = 50 - moveLines.Screen.Width = 80 - moveLines.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", "", "", "", "", "", "", "", "", "", "", "", "", "", "", ""})) - moveLines.viewPos.Cursor = 44 // set cursor next to line Offset Border so that the down move, should move the hole visible area. - moveLines.Move(1) - moveFurther := moveLines.Copy() - moveFurther.Move(1) - return []testModel{ - {model: blankModel, - shouldBe: ` 0 ╭> - 1 ╭ - 2 ╭ - 3 ╭ - 4 ╭ - 5 ╭ - 6 ╭ - 7 ╭ - 8 ╭ - 9 ╭ `, - afterMethode: "Move(0)", - }, - {model: moveBottom, - shouldBe: "0 ╭ \n1 ╭ \n2 ╭ \n\x1b[7m3 ╭>\x1b[0m", - afterMethode: "Bottom", - }, - {model: moveDown, - shouldBe: ` 2 ╭ - 3 ╭ - 4 ╭ - 5 ╭ - 6 ╭ - 7 ╭ - 8 ╭ - 9 ╭ -10 ╭ -11 ╭ -12 ╭ -13 ╭ -14 ╭ -15 ╭ -16 ╭ -17 ╭ -18 ╭ -19 ╭ -20 ╭ -21 ╭ -22 ╭ -23 ╭ -24 ╭ -25 ╭ -26 ╭ -27 ╭ -28 ╭ -29 ╭ -30 ╭ -31 ╭ -32 ╭ -33 ╭ -34 ╭ -35 ╭ -36 ╭ -37 ╭ -38 ╭ -39 ╭ -40 ╭ -41 ╭ -42 ╭ -43 ╭ -44 ╭ -45 ╭ ` + - "\n\x1b[7m46 ╭>\x1b[0m\n" + - `47 ╭ -48 ╭ -49 ╭ -50 ╭ -51 ╭ `, - afterMethode: "Move(1)", - }, - {model: moveLines, - shouldBe: ` │ - │ - │ - │ - │ - │ - │ - │ - │ - │ - │ - │ - │ - │ - │ - │ - │ - │ - │ - │ - │ - │ - │ - │ - │ - │ - │ - │ - │ - │ - │ - │ - │ - │ - │ - │ - │ - │ - │ - │ - │ - │ - │ - │ - │ ` + - "\n\x1b[7m45 ╭>\x1b[0m\n" + - `46 ╭ -47 ╭ -48 ╭ -49 ╭ `, - afterMethode: "Move(1)", - }, - {model: *moveFurther, - shouldBe: " ", - afterMethode: "Move(1)", - }, +// 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"})) + m.MarkSelected(0, true) + out := m.Lines() + prefix := "\x1b[7m 0*╭>" + 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 *│ " } } -func TestCheckBorder(t *testing.T) { +// TestUpdateKeys test if the key send to the Update function work properly +func TestUpdateKeys(t *testing.T) { m := NewModel() - m.AddItems(MakeStringerList([]string{"", "", "", ""})) - if !m.CheckWithinBorder(0) { - t.Errorf("zero is not out of border") - } - if !m.CheckWithinBorder(len(m.listItems) - 1) { - t.Errorf("lasitem is not out of border") + 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) } - if m.CheckWithinBorder(-1) { - t.Errorf("-1 is out of border") + + _, cmd = m.Update(tea.KeyMsg(tea.Key{Type: tea.KeyRunes, Runes: []rune{'q'}})) + if cmd() != tea.Quit() { + t.Errorf("'q' should result in Quit message, not into: %#v", cmd) } - if m.CheckWithinBorder(len(m.listItems)) { - t.Errorf("len(list) is out of border") + + // Movements + 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"})) + newModel, cmd := m.Update(tea.KeyMsg(tea.Key{Type: tea.KeyRunes, Runes: []rune{'j'}})) + m, _ = newModel.(Model) + if m.viewPos.Cursor != 1 && cmd == nil { + t.Errorf("key 'j' should have nil command but got: '%#v' and move the Cursor down to index one, but got: %d", cmd, m.viewPos.Cursor) } + } -func TestKeepVisible(t *testing.T) { +// TestUnfocused should make sure that the update does not change anything if model is not focused +func TestUnfocused(t *testing.T) { m := NewModel() - m.Screen = ScreenInfo{Height: 50, Width: 80} - // make more line breaks within the listitem, than the screen has lines - 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", "", "", ""})) - newView, err := m.keepVisibleWrap(47) - if err != nil { - t.Error(err) - } - if newView.ItemOffset != 2 && newView.LineOffset != 0 { - t.Errorf("wrong postion of the view: '%d' after moving beyond multi-linebreak-item", newView.ItemOffset) + m.focus = false + newModel, cmd := m.Update(nil) + 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) } } diff --git a/list/prefixer.go b/list/prefixer.go index 4f8041fdc..3301c6e8b 100644 --- a/list/prefixer.go +++ b/list/prefixer.go @@ -54,7 +54,7 @@ type DefaultPrefixer struct { // NewPrefixer returns a DefautPrefixer with default values func NewPrefixer() *DefaultPrefixer { return &DefaultPrefixer{ - PrefixWrap: false, + PrefixWrap: true, // Make clear where a item begins and where it ends Seperator: "╭", diff --git a/list/suffixer.go b/list/suffixer.go index adbcb4549..dc3e6ff68 100644 --- a/list/suffixer.go +++ b/list/suffixer.go @@ -1,5 +1,11 @@ package list +import ( + "strings" + + "github.com/muesli/reflow/ansi" +) + // Suffixer is used to suffix all visible Lines. // InitSuffixer gets called ones on the beginning of the Lines methode // and then Suffix ones, per line to draw, to generate according suffixes. @@ -7,3 +13,32 @@ type Suffixer interface { InitSuffixer(ViewPos, ScreenInfo) int Suffix(currentItem, currentLine int, selected bool) 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 horizontaly joined with other strings/Views. +type DefaultSuffixer struct { + viewPos ViewPos + currentMarker string + markerLenght int +} + +// NewSuffixer returns a simple suffixer +func NewSuffixer() *DefaultSuffixer { + return &DefaultSuffixer{currentMarker: "<"} +} + +// InitSuffixer returns the visble Width of the strings used to suffix the lines +func (e *DefaultSuffixer) InitSuffixer(viewPos ViewPos, screen ScreenInfo) int { + 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(item, line int, selected bool) string { + if item == e.viewPos.Cursor && line == 0 { + return e.currentMarker + } + return strings.Repeat(" ", e.markerLenght) +} From 81eb39599ca61c022a07d079f677677e8bac9226 Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Sun, 15 Nov 2020 16:50:44 +0100 Subject: [PATCH 51/73] Added more test and removed there by found bugs - Added more tests, to cover the Update function and other public functions - Added IsSelected to check directly selected state of index/item - fixed bug within MarkSelected, ToggleSelect, MoveItem, GetCursorIndex to return OutOfBounds error when list has no items - Changed keepVisibleWrap to set wrong targets to the nearest listend - fixed bug within Top and MarkSelected to return errors - added SetCursor Methode to directly move to Index - changed structs less field to function with fmt.Stringer argument and changed Sort and SetLess -method accordingly - added default case to Update in example - commented test-keys out - Move lineNumber to prefixer.go - Changed Copy to copy also private Fields --- list/example/main.go | 23 ++-- list/list.go | 133 ++++++++++-------- list/list_test.go | 316 ++++++++++++++++++++++++++++++++++++++++++- list/prefixer.go | 15 ++ 4 files changed, 413 insertions(+), 74 deletions(-) diff --git a/list/example/main.go b/list/example/main.go index b315506a1..c6f6b87ee 100644 --- a/list/example/main.go +++ b/list/example/main.go @@ -7,7 +7,6 @@ import ( tea "github.com/charmbracelet/bubbletea" "os" "strconv" - "strings" ) type model struct { @@ -175,13 +174,13 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.endResult <- result.String() return m, tea.Quit - case "t": - m.lastViews = append(m.lastViews, m.View()) - return m, nil - case "T": - f, _ := os.OpenFile("test_cases.txt", os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) - f.WriteString(strings.Join(m.lastViews, "\n##########################\n")) - return m, tea.Quit + // case "t": + // m.lastViews = append(m.lastViews, m.View()) + // return m, nil + // case "T": + // f, _ := os.OpenFile("test_cases.txt", os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + // f.WriteString(strings.Join(m.lastViews, "\n##########################\n")) + // return m, tea.Quit default: // resets jump buffer to prevent confusion m.jump = "" @@ -209,6 +208,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 } - return m, nil } diff --git a/list/list.go b/list/list.go index 3f55da67a..7f7095253 100644 --- a/list/list.go +++ b/list/list.go @@ -3,7 +3,6 @@ package list import ( "fmt" tea "github.com/charmbracelet/bubbletea" - "github.com/jinzhu/copier" "github.com/muesli/reflow/ansi" "github.com/muesli/termenv" "sort" @@ -16,7 +15,7 @@ type Model struct { listItems []item - less func(string, string) bool // function used for sorting + 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 CursorOffset int // offset or margin between the cursor and the viewport(visible) border @@ -51,8 +50,8 @@ func NewModel() Model { // Wrap lines to have no loss of information Wrap: true, - less: func(k, l string) bool { - return k < l + less: func(k, l fmt.Stringer) bool { + return k.String() < l.String() }, SelectedStyle: selStyle, @@ -175,38 +174,39 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: - // Ctrl+c exits + // Quit if msg.Type == tea.KeyCtrlC { return m, tea.Quit } switch msg.String() { case "q": return m, tea.Quit + + // Move case "down", "j": m.Move(1) return m, nil case "up", "k": m.Move(-1) return m, nil - case " ": - m.ToggleSelect(1) - m.Move(1) - return m, nil case "g": m.Top() return m, nil case "G": m.Bottom() return m, nil - case "s": - m.Sort() - return m, nil case "+": m.MoveItem(-1) return m, nil case "-": m.MoveItem(1) return m, nil + + // Select + case " ": + m.ToggleSelect(1) + m.Move(1) + return m, nil case "v": // inVert m.ToggleAllSelected() return m, nil @@ -216,6 +216,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "M": // mark False m.MarkSelected(1, false) return m, nil + + // Order changing + case "s": + m.Sort() + return m, nil } case tea.WindowSizeMsg: @@ -230,9 +235,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.Type { case tea.MouseWheelUp: m.Move(-1) + return m, nil case tea.MouseWheelDown: m.Move(1) + return m, nil } } return m, nil @@ -255,9 +262,9 @@ type NotFocused error // ViewPos is used for holding the information about the View parameters type ViewPos struct { - Cursor int ItemOffset int LineOffset int + Cursor int } // ScreenInfo holds all information about the screen Area @@ -277,6 +284,14 @@ func (m *Model) Move(amount int) (int, error) { return newPos.Cursor, err } +// SetCursor set the cursor to the specified index if possible, +// if not the nearest end of the list, will be used and OutOfBounds error is returned +func (m *Model) SetCursor(target int) error { + newPos, err := m.KeepVisible(target) + m.viewPos = newPos + return err +} + // Top moves the cursor to the first line func (m *Model) Top() { m.viewPos.Cursor = 0 @@ -312,11 +327,11 @@ func (m *Model) KeepVisible(target int) (ViewPos, error) { } if target == 0 { - return ViewPos{}, nil + return ViewPos{}, err } if m.Wrap { - return m.keepVisibleWrap(target) + return m.keepVisibleWrap(target), err } m.viewPos.LineOffset = 0 @@ -343,14 +358,15 @@ func (m *Model) KeepVisible(target int) (ViewPos, error) { return ViewPos{Cursor: target, ItemOffset: lowerOffset}, err } -func (m *Model) keepVisibleWrap(target int) (ViewPos, error) { - - if !m.CheckWithinBorder(target) { - return ViewPos{}, OutOfBounds(fmt.Errorf("can't move beyond list bonderys, with requested cursor position: %d", target)) +// keepVisibleWrap returns the new viewPos according to the requested target Cursor position +// is target is outside the list return the nearest end +func (m *Model) keepVisibleWrap(target int) ViewPos { + if target <= 0 { + return ViewPos{} } - if target == 0 { - return ViewPos{}, nil + if target >= m.Len() { + target = m.Len() - 1 } direction := 1 @@ -400,32 +416,27 @@ func (m *Model) keepVisibleWrap(target int) (ViewPos, error) { lineCount[len(lineCount)-1].linesBefor < m.CursorOffset && // beyond upper border m.viewPos.ItemOffset <= 0 && m.viewPos.LineOffset <= 0 { // but allready at beginning of list - return ViewPos{Cursor: target}, nil + return ViewPos{Cursor: target} } var lastOffset, lineOffset int for _, count := range lineCount { lastOffset = count.listIndex // Visible Offset - // can't Move beyond list end, setting offsets accordingly - if target >= len(m.listItems)-1 && count.linesBefor > lowerBorder { - lineOffset = count.linesBefor - lowerBorder - return ViewPos{ItemOffset: lastOffset, LineOffset: lineOffset, Cursor: len(m.listItems) - 1}, nil - } // infront upper border -> Move up if direction < 0 && !upper && count.linesBefor > upperBorder { lineOffset = count.linesBefor - upperBorder - return ViewPos{ItemOffset: lastOffset, LineOffset: lineOffset, Cursor: target}, nil + return ViewPos{ItemOffset: lastOffset, LineOffset: lineOffset, Cursor: target} } // beyond lower border -> Moving Down if direction >= 0 && lower && count.linesBefor >= lowerBorder { lastOffset = count.listIndex // Visible Offset lineOffset = count.linesBefor - lowerBorder - return ViewPos{ItemOffset: lastOffset, LineOffset: lineOffset, Cursor: target}, nil + return ViewPos{ItemOffset: lastOffset, LineOffset: lineOffset, Cursor: target} } } // Within bounds: only change cursor - return ViewPos{ItemOffset: m.viewPos.ItemOffset, LineOffset: m.viewPos.LineOffset, Cursor: target}, nil + return ViewPos{ItemOffset: m.viewPos.ItemOffset, LineOffset: m.viewPos.LineOffset, Cursor: target} } // AddItems adds the given Items to the list Model @@ -445,6 +456,9 @@ func (m *Model) AddItems(itemList []fmt.Stringer) { // else if amount is not 0 toggles selected amount items // excluding the item on which the cursor would land func (m *Model) ToggleSelect(amount int) error { + if m.Len() == 0 { + return OutOfBounds(fmt.Errorf("No Items")) + } if amount == 0 { m.listItems[m.viewPos.Cursor].selected = !m.listItems[m.viewPos.Cursor].selected } @@ -466,7 +480,7 @@ func (m *Model) ToggleSelect(amount int) error { start = 0 } // mark last item when trying to go beyond list - if cur+amount >= len(m.listItems) { + if cur+amount >= m.Len() { end++ } for c := start; c < end; c++ { @@ -480,6 +494,9 @@ func (m *Model) ToggleSelect(amount int) error { // if amount would be outside the list error is from type OutOfBounds // else all items till but excluding the end cursor position gets (un-)marked func (m *Model) MarkSelected(amount int, mark bool) error { + if m.Len() == 0 { + return OutOfBounds(fmt.Errorf("No Items within list")) + } cur := m.viewPos.Cursor if amount == 0 { m.listItems[cur].selected = mark @@ -498,8 +515,8 @@ func (m *Model) MarkSelected(amount int, mark bool) error { m.listItems[cur+c].selected = mark } m.viewPos.Cursor = target - m.Move(direction) - return nil + _, err := m.Move(direction) + return err } // ToggleAllSelected inverts the select state of ALL items @@ -509,6 +526,16 @@ func (m *Model) ToggleAllSelected() { } } +// IsSelected returns true if the given Item is selected +// false otherwise. If the requested index is outside the list +// error is not nil. +func (m *Model) IsSelected(index int) (bool, error) { + if !m.CheckWithinBorder(index) { + return false, OutOfBounds(fmt.Errorf("index: '%d' is outside the list", index)) + } + return m.listItems[index].selected, nil +} + // GetSelected returns you a list of all items // that are selected in current (displayed) order func (m *Model) GetSelected() []fmt.Stringer { @@ -546,7 +573,7 @@ func (m *Model) Sort() { // Less is a Proxy to the less function, set from the user. func (m *Model) Less(i, j int) bool { - return m.less(m.listItems[i].value.String(), m.listItems[j].value.String()) + return m.less(m.listItems[i].value, m.listItems[j].value) } // Swap swaps the items position within the list @@ -562,7 +589,7 @@ func (m *Model) Len() int { } // SetLess sets the internal less function used for sorting the list items -func (m *Model) SetLess(less func(string, string) bool) { +func (m *Model) SetLess(less func(a, b fmt.Stringer) bool) { m.less = less } @@ -574,6 +601,7 @@ func (m *Model) SetEquals(equ func(first, second fmt.Stringer) bool) { // GetEquals returns the internal equals methode // used to set the curser after sorting on the same item again func (m *Model) GetEquals() func(first, second fmt.Stringer) bool { + // TODO remove this function? return m.equals } @@ -583,6 +611,9 @@ func (m *Model) GetEquals() func(first, second fmt.Stringer) bool { // MoveItem(0) safely does nothing // and a amount that would result outside the list returns a error != nil func (m *Model) MoveItem(amount int) error { + if m.Len() == 0 { + return OutOfBounds(fmt.Errorf("can't get MoveItem on empty list")) + } if amount == 0 { return nil } @@ -620,7 +651,7 @@ func (m *Model) Focused() bool { } // GetIndex returns NotFound error if the Equals Methode is not set (SetEquals) -// else it returns the index of the found item +// else it returns the index of the first found item func (m *Model) GetIndex(toSearch fmt.Stringer) (int, error) { if m.equals == nil { return -1, NotFound(fmt.Errorf("no equals function provided. Use SetEquals to set it")) @@ -645,6 +676,7 @@ func (m *Model) GetIndex(toSearch fmt.Stringer) (int, error) { } } if c > 1 { + // TODO performance: trust User and remove check for multiple matches? return -c, MultipleMatches(fmt.Errorf("The provided equals function yields multiple matches betwen one and other fmt.Stringer's")) } return lastIndex, nil @@ -667,14 +699,14 @@ func (m *Model) UpdateSelectedItems(updater func(fmt.Stringer) fmt.Stringer) { } // GetCursorIndex returns current cursor position within the List +// and also NotFocused error if the Model is not focused func (m *Model) GetCursorIndex() (int, error) { + if m.Len() == 0 { + return 0, OutOfBounds(fmt.Errorf("No Items")) + } if !m.focus { return m.viewPos.Cursor, NotFocused(fmt.Errorf("Model is not focused")) } - if m.CheckWithinBorder(m.viewPos.Cursor) { - return m.viewPos.Cursor, OutOfBounds(fmt.Errorf("Cursor is out auf bounds")) - } - // TODO handel not focused case return m.viewPos.Cursor, nil } @@ -693,24 +725,9 @@ func (m *Model) GetAllItems() []fmt.Stringer { //func (m *Model) MoveByLine(amount) (ViewPos, error) { //} -// 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 - } - - diff := curser - current - if diff < 0 { - diff *= -1 - } - return diff -} - // Copy returns a deep copy of the list-model func (m *Model) Copy() *Model { - copiedModel := Model{} - copier.Copy(&copiedModel, &m) - return &copiedModel + copiedModel := &Model{} + *copiedModel = *m + return copiedModel } diff --git a/list/list_test.go b/list/list_test.go index d963f5a3f..b05a505de 100644 --- a/list/list_test.go +++ b/list/list_test.go @@ -59,11 +59,23 @@ func TestBasicsLines(t *testing.T) { m.Screen = ScreenInfo{Height: 50, Width: 80, Profile: 0} // No color m.PrefixGen = NewPrefixer() m.SuffixGen = NewSuffixer() + + m.Wrap = 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 error: %#v", i, err) + } + // first two swaped itemList := MakeStringerList([]string{"2", "1", "3", "4", "5", "6", "7", "8", "9"}) m.AddItems(itemList) + + m.Move(1) + m.SetEquals(func(a, b fmt.Stringer) bool { return a.String() == b.String() }) // Sort them - m.Sort() + newModel, _ := m.Update(tea.KeyMsg(tea.Key{Type: tea.KeyRunes, Runes: []rune{'s'}})) + m, _ = newModel.(Model) // swap them again m.MoveItem(1) // should be the like the beginning @@ -78,7 +90,7 @@ func TestBasicsLines(t *testing.T) { // Process/check all itemList for c, item := range longer { if item.String() != shorter[c].String() { - t.Error("something basic failed") + t.Errorf("this strings should match but dont: %q, %q", item.String(), shorter[c].String()) } } @@ -160,25 +172,315 @@ func TestUpdateKeys(t *testing.T) { if cmd() != tea.Quit() { t.Errorf("'q' should result in Quit message, not into: %#v", cmd) } +} - // Movements +// Movements +func TestMovementKeys(t *testing.T) { + m := NewModel() + m.Wrap = false + 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 newModel, cmd := m.Update(tea.KeyMsg(tea.Key{Type: tea.KeyRunes, Runes: []rune{'j'}})) m, _ = newModel.(Model) - if m.viewPos.Cursor != 1 && cmd == nil { - t.Errorf("key 'j' should have nil command but got: '%#v' and move the Cursor down to index one, but got: %d", cmd, m.viewPos.Cursor) + if m.viewPos.Cursor != finish || cmd != nil { + t.Errorf("key 'j' should have nil command but got: '%#v' and move the Cursor to index '%d', but got: %d", cmd, finish, m.viewPos.Cursor) + } + start, finish = 15, 14 + m.viewPos.Cursor = start + newModel, cmd = m.Update(tea.KeyMsg(tea.Key{Type: tea.KeyRunes, Runes: []rune{'k'}})) + m, _ = newModel.(Model) + if m.viewPos.Cursor != finish || cmd != nil { + t.Errorf("key 'k' should have nil command but got: '%#v' and move the Cursor to index '%d', but got: %d", cmd, finish, m.viewPos.Cursor) + } + + start, finish = 55, 56 + m.viewPos.Cursor = start + newModel, cmd = m.Update(tea.KeyMsg(tea.Key{Type: tea.KeyRunes, Runes: []rune{'-'}})) + m, _ = newModel.(Model) + if m.viewPos.Cursor != finish || cmd != nil { + t.Errorf("key '-' should have nil command but got: '%#v' and move the Cursor to index '%d', but got: %d", cmd, finish, m.viewPos.Cursor) + } + m.viewPos.ItemOffset = 10 + start, finish = 15, 14 + m.viewPos.Cursor = start + newModel, cmd = m.Update(tea.KeyMsg(tea.Key{Type: tea.KeyRunes, Runes: []rune{'+'}})) + m, _ = newModel.(Model) + if m.viewPos.Cursor != finish || cmd != nil { + t.Errorf("key '+' should have nil command but got: '%#v' and move the Cursor to index '%d', but got: %d", cmd, finish, m.viewPos.Cursor) + } + if m.viewPos.ItemOffset != 9 { + t.Errorf("up movement should change the Item offset to '9' but got: %d", m.viewPos.ItemOffset) + } + finish = m.Len() - 1 + newModel, cmd = m.Update(tea.KeyMsg(tea.Key{Type: tea.KeyRunes, Runes: []rune{'G'}})) + m, _ = newModel.(Model) + if m.viewPos.Cursor != finish || cmd != nil { + t.Errorf("key 'G' should have nil command but got: '%#v' and move the Cursor to last index: '%d', but got: %d", cmd, m.Len()-1, m.viewPos.Cursor) + } + finish = 0 + m.viewPos.Cursor = start + newModel, cmd = m.Update(tea.KeyMsg(tea.Key{Type: tea.KeyRunes, Runes: []rune{'g'}})) + m, _ = newModel.(Model) + if m.viewPos.Cursor != finish || cmd != nil { + t.Errorf("key 'g' should have nil command but got: '%#v' and move the Cursor to index '%d', but got: %d", cmd, finish, m.viewPos.Cursor) + } + m.SetCursor(10) + if m.viewPos.Cursor != 10 { + t.Errorf("SetCursor should set the cursor to index '10' but gut '%d'", m.viewPos.Cursor) + } +} + +// 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) + } + +} + +// TestSelectKeys test the keys that change the select status of an item(s). +func TestSelectKeys(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"})) + + // Mark one and move one down + newModel, cmd := m.Update(tea.KeyMsg(tea.Key{Type: tea.KeyRunes, Runes: []rune{' '}})) + m, _ = newModel.(Model) + if len(m.GetSelected()) != 1 { + t.Errorf("key ' ' should mark exactly one items as marked not: '%d'", len(m.GetSelected())) + } + if sel, _ := m.IsSelected(0); !sel || cmd != nil { + t.Errorf("key ' ' should mark the current Index, but did not or command was not nil: %#v", cmd) + } + + // invert all mark stats + newModel, cmd = m.Update(tea.KeyMsg(tea.Key{Type: tea.KeyRunes, Runes: []rune{'v'}})) + m, _ = newModel.(Model) + if len(m.GetSelected()) != m.Len()-1 { + t.Errorf("All items but one should be marked but '%d' from '%d' are marked", len(m.GetSelected()), m.Len()) + } + + // deselect all and move to top + m.ToggleAllSelected() + m.Top() + // mark the first item + newModel, cmd = m.Update(tea.KeyMsg(tea.Key{Type: tea.KeyRunes, Runes: []rune{'m'}})) + m, _ = newModel.(Model) + if len(m.GetSelected()) != 1 { + t.Errorf("key 'm' should mark exactly one items as marked not: '%d'", len(m.GetSelected())) + } + if sel, _ := m.IsSelected(0); !sel || cmd != nil { + t.Errorf("key 'm' should mark the current Index, but did not or command was not nil: %#v", cmd) } + // Move back to top + m.Move(-1) + // Unmark previous marked item + newModel, cmd = m.Update(tea.KeyMsg(tea.Key{Type: tea.KeyRunes, Runes: []rune{'M'}})) + m, _ = newModel.(Model) + if len(m.GetSelected()) != 0 { + t.Errorf("no selected items should be left, but '%d' are", len(m.GetSelected())) + } } // 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 = false - newModel, cmd := m.Update(nil) + m.Focus() + if !m.Focused() { + t.Error("model should be focused but isn't") + } + m.UnFocus() + // 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{' '}})) 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() + _, err := m.GetIndex(StringItem("z")) + if 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, err := m.GetIndex(StringItem("z")) + if err != nil { + t.Errorf("GetIndex should not return error: %s", err) + } + if index != m.Len()-1 { + t.Errorf("GetIndex returns wrong index: '%d' instead of '%d'", index, m.Len()-1) + } +} + +// TestItemUpdater test if items get updated +func TestItemUpdater(t *testing.T) { + m := NewModel() + old := 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.AddItems(old) + m.UpdateAllItems(func(in fmt.Stringer) fmt.Stringer { return StringItem("-") }) + for i, content := range m.GetAllItems() { + if content.String() != "-" { + t.Errorf("after Updating all items should result in string '-' but got '%s' form old item: '%s'", content.String(), old[i]) + } + } + m.Bottom() + m.ToggleSelect(-26) + m.UpdateSelectedItems(func(in fmt.Stringer) fmt.Stringer { return StringItem("_") }) + + for i, content := range m.GetAllItems() { + if content.String() != "_" { + t.Errorf("after Updating selected (all) items should result in string '_' but got '%s' form old item: '%s'", content.String(), old[i]) + } + } +} + +// TestWithinBorder test if indexes are within the listborders +func TestWithinBorder(t *testing.T) { + m := NewModel() + if m.CheckWithinBorder(0) { + t.Error("a empty list has no item '0', should return 'false'") + } +} + +// 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.SelectedStyle) != fmt.Sprintf("%#v", sec.SelectedStyle) || + 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) + } +} + +// TestKeepVisibleWrap test the private helper function of KeepVisible +func TestKeepVisibleWrap(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{ + {ViewPos{0, 0, 3}, -2, ViewPos{0, 0, 0}}, // infront of list + {ViewPos{0, 0, 3}, 2, ViewPos{0, 0, 2}}, // begin of list and upper border + {ViewPos{4, 0, 12}, 8, ViewPos{5, 1, 8}}, // Middel of list and upper border + {ViewPos{5, 0, 15}, 0, ViewPos{0, 0, 0}}, // beginning + {ViewPos{15, 1, 14}, 19, ViewPos{15, 1, 19}}, // Middel + {ViewPos{0, 0, 0}, 25, ViewPos{3, 0, 25}}, // pass of lower border + {ViewPos{0, 0, 0}, 100, ViewPos{49, 0, 71}}, // pass of lower border + } + for i, tCase := range toTest { + m.viewPos = tCase.oldView + if g := m.keepVisibleWrap(tCase.target); g != 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, g, tCase.newView, tCase.target) + } + } +} + +// TestSelectFunctions test if the function that handel the selected state of items work proper +func TestSelectFunctions(t *testing.T) { + m := NewModel() + err1 := m.ToggleSelect(-1) + err2 := m.MarkSelected(-1, true) + if err1 == nil || err2 == nil { + t.Error("cant toggle no items") + } + m.AddItems(MakeStringerList([]string{""})) + err3 := m.ToggleSelect(0) + if ok, err4 := m.IsSelected(0); !ok || err3 != nil || err4 != nil { + t.Errorf("Item should be selected after toggle or no error should be returned: '%#v' or '%#v'", err3, err4) + } + err5 := m.MarkSelected(-1, false) + sel, err6 := m.IsSelected(0) + if err5 == nil || err6 != nil || sel { + t.Errorf("Item should not be selected after marking it false, error should be not nil: '%#v' and other error should be be nil '%#v'", err5, err6) + } + err7 := m.MarkSelected(m.Len()+1, false) + if err7 == nil { + t.Error("MarkSelected should fail if position is beyond list end") + } + _, err8 := m.IsSelected(m.Len()) + if err8 == nil { + t.Error("error Should not be nil after trying to check selected state beyond list end") + } + m.viewPos.Cursor = m.Len() - 1 + err9 := m.ToggleSelect(1) + sel, _ = m.IsSelected(m.Len() - 1) + if _, ok := err9.(OutOfBounds); !ok || !sel { + t.Errorf("marking the last item should give a OutOfBounds error, but got: '%s'\nand after it, it should be marked: '%t'", err9, sel) + } +} + +// TestMoveItem test wrong arguments +func TestMoveItem(t *testing.T) { + m := NewModel() + err := m.MoveItem(0) + _, ok := err.(OutOfBounds) + if !ok { + t.Errorf("MoveItem called on a empty list should return a OutOfBounds error, but got: %s", err) + } + m.AddItems(MakeStringerList([]string{""})) + err = m.MoveItem(0) + if err != nil { + t.Errorf("MoveItem(0) should not not return a error on a not empty list") + } + err = m.MoveItem(1) + _, ok = err.(OutOfBounds) + if !ok { + t.Errorf("MoveItem should return a OutOfBounds error if traget is beyond list border, but got: '%s'", err) + } +} diff --git a/list/prefixer.go b/list/prefixer.go index 3301c6e8b..99f18edbe 100644 --- a/list/prefixer.go +++ b/list/prefixer.go @@ -166,3 +166,18 @@ func (d *DefaultPrefixer) Prefix(currentIndex int, wrapIndex int, selected bool) 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 + } + + diff := curser - current + if diff < 0 { + diff *= -1 + } + return diff +} From 17a749421dc08a818b2cde0e965a520cf5185b16 Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Tue, 17 Nov 2020 14:54:57 +0100 Subject: [PATCH 52/73] made some list-Methods fail **silently** and not panic, because they are public Methods to fullfill the Sort-interface and are not allowed to return errors. - changed example, items to have a unique ID for sorting and comparison. - added methode within example for setting a Style for Styling the value of a item when calling String(). - added more/better example stringItems, so that they are more accurat. - made example fullscreen to examplefy the CursorOffset and to show the multi-linebreak bug/problem. - Changed and added key bindings to be more telling. - added some more Methods to the list-model. - fixed methode MoveItem to not swap with distant items but to move there (sadly slowly). - changed visible default lineNumber to start at 1 rater than 0 and examples accordingly. --- list/example/main.go | 175 ++++++++++++++++++++++++++++++++++++------- list/list.go | 62 ++++++++++++++- list/list_test.go | 22 +++--- list/prefixer.go | 2 +- 4 files changed, 220 insertions(+), 41 deletions(-) diff --git a/list/example/main.go b/list/example/main.go index c6f6b87ee..ea9d24744 100644 --- a/list/example/main.go +++ b/list/example/main.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" + "github.com/muesli/termenv" "os" "strconv" ) @@ -16,51 +17,134 @@ type model struct { endResult chan<- string jump string lastViews []string + + 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() + + l.SetEquals(func(first, second fmt.Stringer) bool { + f := first.(stringItem) + s := second.(stringItem) + return f.id == s.id + }) + 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 { + i := toUp.(stringItem) + i.style = style + return i + } + return m.list.UpdateItem(index, updater) } -type stringItem string +type stringItem struct { + value string + id int + style termenv.Style +} func (s stringItem) String() string { - return string(s) + 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!", - "You can move the highlighted index up and down with the (arrow keys or 'k' and 'j'.)", - "Move to the beginning with 'g' and to the end with 'G'.", - "Sort the entrys with 's', but be carefull you can't restore the former order again.", "The list can handel linebreaks,\nand has wordwrap enabled if the line gets to long.", - "You can select items with the space key which will select the line and mark it as such.", - "Ones you hit 'enter', the selected lines will be printed to StdOut and the program exits.", - "When you print the items there will be a loss of information,", - "since one can not say what was a line break within an item or what is a new item", + "", + "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'.", + "", + "Order:", "Use '+' or '-' to move the item under the curser up and down.", - "The key 'v' inverts the selected state of each item.", - "To toggle betwen only absolute item numbers and relativ numbers, the 'r' key is your friend.", + "Sort the entrys with 's' depending on a costum less function, here the orginal order.", + "To toggle between only absolute item numbers and relativ numbers use the 'r' key.", + "", + "Select:", + "To select a item use 'm'\nor to select all items 'M'.", + "To unselect a item use 'u'\nor to unselect all items 'U'.", + "You can toggle the select state of the current item with the space key.", + "The key 'v' inverts the selected state of all items.", + "", + "Ones you hit 'enter', the selected lines will be printed to StdOut and the program exits.", + "When you print the items there will be a loss of information,\nsince one can not say what was a line break within an item or what is a new item", + "", + "Here are some more items for you to test the scrolling\nand the cursor-Offset which defaults to 5 lines from the screen border.", + "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", + "Be aware that items with more linebreaks than the screen height minus twice the scroll offset cause some display problems to the hole list, but the cursor will be on the right item, even if the cursor jumps relative to the screen.\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\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 lines) from the bottom.", "", "", "", + "Hey i am the last item :) you can move directly to me with the 'b' key, which stands for bottom", } - stringerList := list.MakeStringerList(itemList) + m.AddStrings(itemList) - endResult := make(chan string, 1) - l := list.NewModel() - l.AddItems(stringerList) - l.SuffixGen = list.NewSuffixer() - - // Since in this example we only use UNIQUE string items we can use a String Comparison for the equals methode - // but be aware that different items in your case can have the same string -> false-positiv - // Better: Assert back to your struct and test on something unique within it! - l.SetEquals(func(first, second fmt.Stringer) bool { return first.String() == second.String() }) - m := model{} - m.list = l + m.SetStyle(0, termenv.Style{}.Foreground(termenv.ColorProfile().Color("#ffff00"))) + endResult := make(chan string, 1) m.endResult = endResult p := tea.NewProgram(m) // Use the full size of the terminal in its "alternate screen buffer" - fullScreen := false // change to true if you want fullscreen + fullScreen := true // change to true if you want fullscreen if fullScreen { p.EnterAltScreen() @@ -149,7 +233,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.list.MarkSelected(j, true) return m, nil - case "M": + case "u": j := 1 if m.jump != "" { j, _ = strconv.Atoi(m.jump) @@ -165,6 +249,47 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.list.ToggleSelect(j) return m, nil + case "+": + j := 1 + if m.jump != "" { + j, _ = strconv.Atoi(m.jump) + m.jump = "" + } + m.list.MoveItem(j) + return m, nil + case "-": + 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.Move(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.Move(-j) + return m, nil + case "enter": // Enter prints the selected lines to StdOut var result bytes.Buffer diff --git a/list/list.go b/list/list.go index 7f7095253..b8235ab55 100644 --- a/list/list.go +++ b/list/list.go @@ -189,10 +189,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "up", "k": m.Move(-1) return m, nil - case "g": + case "t", "home": m.Top() return m, nil - case "G": + case "b", "end": m.Bottom() return m, nil case "+": @@ -213,9 +213,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "m": // mark m.MarkSelected(1, true) return m, nil - case "M": // mark False + case "M": // mark All + m.MarkAllSelected(true) + return m, nil + case "u": // unmark m.MarkSelected(1, false) return m, nil + case "U": // unmark All + m.MarkAllSelected(false) + return m, nil // Order changing case "s": @@ -519,6 +525,18 @@ func (m *Model) MarkSelected(amount int, mark bool) error { return err } +// MarkAllSelected marks all items of the list according to mark +// or returns OutOfBounds if list has no Items +func (m *Model) MarkAllSelected(mark bool) error { + if m.Len() == 0 { + return OutOfBounds(fmt.Errorf("No Items within list")) + } + for c := range m.listItems { + m.listItems[c].selected = mark + } + return nil +} + // ToggleAllSelected inverts the select state of ALL items func (m *Model) ToggleAllSelected() { for i := range m.listItems { @@ -572,13 +590,23 @@ func (m *Model) Sort() { } // Less is a Proxy to the less function, set from the user. +// since the Sort-interface demands a Less Methode without a error return value +// so we sadly have to returns silently if a index is out side the list, to not panic. func (m *Model) Less(i, j int) bool { + if !m.CheckWithinBorder(i) || !m.CheckWithinBorder(j) { + return false + } return m.less(m.listItems[i].value, m.listItems[j].value) } // Swap swaps the items position within the list // and is used to fulfill the Sort-interface +// since the Sort-interface demands a Swap Methode without a error return value +// so we sadly have to returns silently if a index is out side the list, to not panic. func (m *Model) Swap(i, j int) { + if !m.CheckWithinBorder(i) || !m.CheckWithinBorder(j) { + return + } m.listItems[i], m.listItems[j] = m.listItems[j], m.listItems[i] } @@ -622,7 +650,14 @@ func (m *Model) MoveItem(amount int) error { if err != nil { return err } - m.Swap(cur, target) + d := 1 + if amount < 0 { + d = -1 + } + for c := 0; c*d < amount*d; c += d { + m.Swap(cur+c, cur+c+d) + } + m.viewPos.Cursor = target return nil } @@ -682,6 +717,16 @@ func (m *Model) GetIndex(toSearch fmt.Stringer) (int, error) { return lastIndex, nil } +// UpdateItem takes a indes and updates the item at the index with the given function +// or if index outside the list returns OutOfBounds error. +func (m *Model) UpdateItem(index int, updater func(fmt.Stringer) fmt.Stringer) error { + if !m.CheckWithinBorder(index) { + return OutOfBounds(fmt.Errorf("index is outside the list")) + } + m.listItems[index].value = updater(m.listItems[index].value) + return nil +} + // UpdateAllItems takes a function and updates with it, all items in the list func (m *Model) UpdateAllItems(updater func(fmt.Stringer) fmt.Stringer) { for i, item := range m.listItems { @@ -710,6 +755,15 @@ func (m *Model) GetCursorIndex() (int, error) { return m.viewPos.Cursor, nil } +// GetItem returns the item if the index exists +// OutOfBounds otherwise +func (m *Model) GetItem(index int) (fmt.Stringer, error) { + if !m.CheckWithinBorder(index) { + return nil, OutOfBounds(fmt.Errorf("requested index is outside the list")) + } + 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 diff --git a/list/list_test.go b/list/list_test.go index b05a505de..d2fd9b755 100644 --- a/list/list_test.go +++ b/list/list_test.go @@ -79,12 +79,12 @@ func TestBasicsLines(t *testing.T) { // swap them again m.MoveItem(1) // should be the like the beginning - sorteditemList := m.GetAllItems() + sortedItemList := m.GetAllItems() // make sure all itemList get processed - shorter, longer := sorteditemList, itemList + shorter, longer := sortedItemList, itemList if len(itemList) > len(longer) { - shorter, longer = itemList, sorteditemList + shorter, longer = itemList, sortedItemList } // Process/check all itemList @@ -104,7 +104,7 @@ func TestBasicsLines(t *testing.T) { cur := ">" for i, line := range out { // Check Prefixes - num := fmt.Sprintf("%d", i) + num := fmt.Sprintf("%d", i+1) prefix := light + strings.Repeat(" ", 2-len(num)) + num + " ╭" + 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) @@ -128,7 +128,7 @@ func TestWrappedLines(t *testing.T) { num := "\x1b[7m " for i, line := range out { if i%2 == 1 { - num = fmt.Sprintf(" %1d", (i/2)+1) + num = fmt.Sprintf(" %1d", (i/2)+2) } prefix := fmt.Sprintf("%s %s %d", num, wrap, i) if !strings.HasPrefix(line, prefix) { @@ -148,7 +148,7 @@ func TestMultiLineBreaks(t *testing.T) { 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"})) m.MarkSelected(0, true) out := m.Lines() - prefix := "\x1b[7m 0*╭>" + 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) @@ -214,17 +214,17 @@ func TestMovementKeys(t *testing.T) { t.Errorf("up movement should change the Item offset to '9' but got: %d", m.viewPos.ItemOffset) } finish = m.Len() - 1 - newModel, cmd = m.Update(tea.KeyMsg(tea.Key{Type: tea.KeyRunes, Runes: []rune{'G'}})) + newModel, cmd = m.Update(tea.KeyMsg(tea.Key{Type: tea.KeyRunes, Runes: []rune{'b'}})) m, _ = newModel.(Model) if m.viewPos.Cursor != finish || cmd != nil { - t.Errorf("key 'G' should have nil command but got: '%#v' and move the Cursor to last index: '%d', but got: %d", cmd, m.Len()-1, m.viewPos.Cursor) + t.Errorf("key 'b' should have nil command but got: '%#v' and move the Cursor to last index: '%d', but got: %d", cmd, m.Len()-1, m.viewPos.Cursor) } finish = 0 m.viewPos.Cursor = start - newModel, cmd = m.Update(tea.KeyMsg(tea.Key{Type: tea.KeyRunes, Runes: []rune{'g'}})) + newModel, cmd = m.Update(tea.KeyMsg(tea.Key{Type: tea.KeyRunes, Runes: []rune{'t'}})) m, _ = newModel.(Model) if m.viewPos.Cursor != finish || cmd != nil { - t.Errorf("key 'g' should have nil command but got: '%#v' and move the Cursor to index '%d', but got: %d", cmd, finish, m.viewPos.Cursor) + t.Errorf("key 't' should have nil command but got: '%#v' and move the Cursor to index '%d', but got: %d", cmd, finish, m.viewPos.Cursor) } m.SetCursor(10) if m.viewPos.Cursor != 10 { @@ -292,7 +292,7 @@ func TestSelectKeys(t *testing.T) { // Move back to top m.Move(-1) // Unmark previous marked item - newModel, cmd = m.Update(tea.KeyMsg(tea.Key{Type: tea.KeyRunes, Runes: []rune{'M'}})) + newModel, cmd = m.Update(tea.KeyMsg(tea.Key{Type: tea.KeyRunes, Runes: []rune{'u'}})) m, _ = newModel.(Model) if len(m.GetSelected()) != 0 { t.Errorf("no selected items should be left, but '%d' are", len(m.GetSelected())) diff --git a/list/prefixer.go b/list/prefixer.go index 99f18edbe..4b4e78d93 100644 --- a/list/prefixer.go +++ b/list/prefixer.go @@ -172,7 +172,7 @@ func (d *DefaultPrefixer) Prefix(currentIndex int, wrapIndex int, selected bool) // or if on the cursor the absolute line number func lineNumber(relativ bool, curser, current int) int { if !relativ || curser == current { - return current + return current + 1 } diff := curser - current From 2fd5173bb39eb8f09d4e932cc7bbb6bd94f8812a Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Tue, 17 Nov 2020 19:01:49 +0100 Subject: [PATCH 53/73] Made example list editable and changed a function interface - removed, channel to print the selected items to StdOut, because if a panic occures the user must use 'ctrl+c' since the recieving channel blocks. - added tea.Cmd to Update interface function, to be able to use other bubbles (i.e.: textinput) as item value. - changed example items to be editable and added key-bindings to edit/change item string content and changed the example update function accordingly. - added SetStyle to example to seperate the concern of style and content. - Catched negative repeats in Lines() and set them to 0, not sure though if that is neccessary, after HardWrap is available. --- list/example/main.go | 117 ++++++++++++++++++++++++++++++++----------- list/list.go | 24 ++++++--- list/list_test.go | 2 +- 3 files changed, 106 insertions(+), 37 deletions(-) diff --git a/list/example/main.go b/list/example/main.go index ea9d24744..406ee72b2 100644 --- a/list/example/main.go +++ b/list/example/main.go @@ -1,9 +1,9 @@ package main import ( - "bytes" "fmt" "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/muesli/termenv" "os" @@ -14,7 +14,7 @@ type model struct { ready bool list list.Model finished bool - endResult chan<- string + edit bool jump string lastViews []string @@ -82,21 +82,28 @@ func (m *model) AddStrings(items []string) error { } func (m *model) SetStyle(index int, style termenv.Style) error { - updater := func(toUp fmt.Stringer) fmt.Stringer { + updater := func(toUp fmt.Stringer) (fmt.Stringer, tea.Cmd) { i := toUp.(stringItem) i.style = style - return i + return i, nil } - return m.list.UpdateItem(index, updater) + _, err := m.list.UpdateItem(index, updater) + return err } 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)) } @@ -123,24 +130,24 @@ func main() { "You can toggle the select state of the current item with the space key.", "The key 'v' inverts the selected state of all items.", "", - "Ones you hit 'enter', the selected lines will be printed to StdOut and the program exits.", - "When you print the items there will be a loss of information,\nsince one can not say what was a line break within an item or what is a new item", + "Edit:", + "With the key 'e' you can edit the string of the current item.", + "There you can make changes to the string and apply them with 'enter' or discard them with 'escape'", + "", + "All keys that change the cursor position can be preceded with the press of numbers and change the movemet to that amount.\nI.e.: the key press order '1','2' and 't' moves the cursor to the twelfth item from the top.", "", "Here are some more items for you to test the scrolling\nand the cursor-Offset which defaults to 5 lines from the screen border.", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", - "Be aware that items with more linebreaks than the screen height minus twice the scroll offset cause some display problems to the hole list, but the cursor will be on the right item, even if the cursor jumps relative to the screen.\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n", + "Be aware, that items with more linebreaks than the screen height minus twice the scroll offset, cause some display problems to the hole list, but the cursor will be on the right item, even if the cursor jumps relative to the screen.\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\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 lines) from the bottom.", "", "", "", - "Hey i am the last item :) you can move directly to me with the 'b' key, which stands for bottom", + "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 directly to me with the 'b' key, which stands for bottom.", } m.AddStrings(itemList) m.SetStyle(0, termenv.Style{}.Foreground(termenv.ColorProfile().Color("#ffff00"))) - endResult := make(chan string, 1) - m.endResult = endResult - p := tea.NewProgram(m) // Use the full size of the terminal in its "alternate screen buffer" @@ -157,10 +164,6 @@ func main() { if fullScreen { p.ExitAltScreen() } - - res := <-endResult - // allways print a newline even on empty string result - fmt.Println(res) } func (m model) Init() tea.Cmd { @@ -185,20 +188,87 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 + } + m.list.UpdateAllItems(updater) + + } + m.edit = false + return m, nil + } + // Ctrl+c exits if msg.Type == tea.KeyCtrlC { - m.endResult <- "" 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.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 + } + m.list.UpdateAllItems(updater) + + } + m.edit = false + return m, nil + case "c": m.list.Move(1) return m, nil case "q": - m.endResult <- "" return m, tea.Quit case "1", "2", "3", "4", "5", "6", "7", "8", "9", "0": m.jump += keyString @@ -290,15 +360,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.list.Move(-j) return m, nil - case "enter": - // Enter prints the selected lines to StdOut - var result bytes.Buffer - for _, item := range m.list.GetSelected() { - result.WriteString(item.String()) - result.WriteString("\n") - } - m.endResult <- result.String() - return m, tea.Quit // case "t": // m.lastViews = append(m.lastViews, m.View()) // return m, nil diff --git a/list/list.go b/list/list.go index b8235ab55..4140e11eb 100644 --- a/list/list.go +++ b/list/list.go @@ -130,7 +130,11 @@ out: linePrefix = m.PrefixGen.Prefix(index, i, item.selected) } if m.SuffixGen != nil { - lineSuffix = fmt.Sprintf("%s%s", strings.Repeat(" ", contentWidth-ansi.PrintableRuneWidth(line)), m.SuffixGen.Suffix(index, i, item.selected)) + free := contentWidth - ansi.PrintableRuneWidth(line) + if free < 0 { + free = 0 // TODO is this nessecary? + } + lineSuffix = fmt.Sprintf("%s%s", strings.Repeat(" ", free), m.SuffixGen.Suffix(index, i, item.selected)) } // Join all @@ -446,7 +450,6 @@ func (m *Model) keepVisibleWrap(target int) ViewPos { } // AddItems adds the given Items to the list Model -// Without performing updating the View TODO func (m *Model) AddItems(itemList []fmt.Stringer) { for _, i := range itemList { m.listItems = append(m.listItems, item{ @@ -719,19 +722,24 @@ func (m *Model) GetIndex(toSearch fmt.Stringer) (int, error) { // UpdateItem takes a indes and updates the item at the index with the given function // or if index outside the list returns OutOfBounds error. -func (m *Model) UpdateItem(index int, updater func(fmt.Stringer) fmt.Stringer) error { +func (m *Model) UpdateItem(index int, updater func(fmt.Stringer) (fmt.Stringer, tea.Cmd)) (tea.Cmd, error) { if !m.CheckWithinBorder(index) { - return OutOfBounds(fmt.Errorf("index is outside the list")) + return nil, OutOfBounds(fmt.Errorf("index is outside the list")) } - m.listItems[index].value = updater(m.listItems[index].value) - return nil + v, cmd := updater(m.listItems[index].value) + m.listItems[index].value = v + return cmd, nil } // UpdateAllItems takes a function and updates with it, all items in the list -func (m *Model) UpdateAllItems(updater func(fmt.Stringer) fmt.Stringer) { +func (m *Model) UpdateAllItems(updater func(fmt.Stringer) (fmt.Stringer, tea.Cmd)) []tea.Cmd { + cmdList := make([]tea.Cmd, 0, m.Len()) for i, item := range m.listItems { - m.listItems[i].value = updater(item.value) + v, cmd := updater(item.value) + m.listItems[i].value = v + cmdList = append(cmdList, cmd) } + return cmdList } // UpdateSelectedItems updates all selected items within the list with given function diff --git a/list/list_test.go b/list/list_test.go index d2fd9b755..4c4e8b5be 100644 --- a/list/list_test.go +++ b/list/list_test.go @@ -343,7 +343,7 @@ func TestItemUpdater(t *testing.T) { m := NewModel() old := 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.AddItems(old) - m.UpdateAllItems(func(in fmt.Stringer) fmt.Stringer { return StringItem("-") }) + m.UpdateAllItems(func(in fmt.Stringer) (fmt.Stringer, tea.Cmd) { return StringItem("-"), nil }) for i, content := range m.GetAllItems() { if content.String() != "-" { t.Errorf("after Updating all items should result in string '-' but got '%s' form old item: '%s'", content.String(), old[i]) From 7062543633ab5ce9362172c513aa147851b780dc Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Wed, 18 Nov 2020 13:46:34 +0100 Subject: [PATCH 54/73] change keybindings within example and added second sort function --- list/example/main.go | 35 ++++++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/list/example/main.go b/list/example/main.go index 406ee72b2..24b06b9a4 100644 --- a/list/example/main.go +++ b/list/example/main.go @@ -118,11 +118,16 @@ func main() { "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 movemet to that amount.\nI.e.: the key press order '1','2' and 't' moves the cursor to the twelfth item from the top.", "", "Order:", - "Use '+' or '-' to move the item under the curser up and down.", - "Sort the entrys with 's' depending on a costum less function, here the orginal order.", + "Use 'K' or 'J' to move the item under the curser up and down.", + "Sort the entrys with 's' depending on a costum 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 relativ numbers use the 'r' key.", + "To toggle between showing the wrapped lines of a item use the 'w' key.", "", "Select:", "To select a item use 'm'\nor to select all items 'M'.", @@ -134,11 +139,9 @@ func main() { "With the key 'e' you can edit the string of the current item.", "There you can make changes to the string and apply them with 'enter' or discard them with 'escape'", "", - "All keys that change the cursor position can be preceded with the press of numbers and change the movemet to that amount.\nI.e.: the key press order '1','2' and 't' moves the cursor to the twelfth item from the top.", - "", "Here are some more items for you to test the scrolling\nand the cursor-Offset which defaults to 5 lines from the screen border.", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", - "Be aware, that items with more linebreaks than the screen height minus twice the scroll offset, cause some display problems to the hole list, but the cursor will be on the right item, even if the cursor jumps relative to the screen.\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n", + //"Be aware, that items with more linebreaks than the screen height minus twice the scroll offset, cause some display problems to the hole list, but the cursor will be on the right item, even if the cursor jumps relative to the screen.\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nBut you can avoid this ,by toggeling wrap to be off, with the 'w' key.", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "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 directly to me with the 'b' key, which stands for bottom.", @@ -319,7 +322,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.list.ToggleSelect(j) return m, nil - case "+": + case "J": j := 1 if m.jump != "" { j, _ = strconv.Atoi(m.jump) @@ -327,7 +330,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.list.MoveItem(j) return m, nil - case "-": + case "K": j := 1 if m.jump != "" { j, _ = strconv.Atoi(m.jump) @@ -367,6 +370,24 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // f, _ := os.OpenFile("test_cases.txt", os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) // f.WriteString(strings.Join(m.lastViews, "\n##########################\n")) // return m, tea.Quit + case "w": + m.list.Wrap = !m.list.Wrap + 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 + default: // resets jump buffer to prevent confusion m.jump = "" From 43990ebcbeeba0750bb3b1fa6fff0798171ba403 Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Wed, 18 Nov 2020 14:53:32 +0100 Subject: [PATCH 55/73] Changed list items to have a unique id to set the cursor after sorting properly. The Set- and Get-Equals Methode of the list still are usefull, if the user wants to get the index of a item. The unique id within the example items are left in, only to show how to make unique items with a proper equals function. --- list/example/main.go | 3 +++ list/item.go | 1 + list/list.go | 59 +++++++++++++++++++++++++++++++++----------- 3 files changed, 48 insertions(+), 15 deletions(-) diff --git a/list/example/main.go b/list/example/main.go index 24b06b9a4..d1775d097 100644 --- a/list/example/main.go +++ b/list/example/main.go @@ -18,6 +18,7 @@ type model struct { jump string lastViews []string + // Channels to create unique ids for all added/new items requestID chan<- struct{} resultID <-chan int } @@ -42,11 +43,13 @@ func newModel() *model { 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) diff --git a/list/item.go b/list/item.go index 9be2a0920..3ae2688d3 100644 --- a/list/item.go +++ b/list/item.go @@ -11,6 +11,7 @@ import ( type item struct { selected bool value fmt.Stringer + id int } // itemLines returns the lines of the item string value wrapped to the according content-width diff --git a/list/list.go b/list/list.go index 4140e11eb..af04ab900 100644 --- a/list/list.go +++ b/list/list.go @@ -31,6 +31,10 @@ type Model struct { LineStyle termenv.Style SelectedStyle 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 @@ -454,7 +458,9 @@ func (m *Model) AddItems(itemList []fmt.Stringer) { for _, i := range itemList { m.listItems = append(m.listItems, item{ selected: false, - value: i}, + value: i, + id: m.getID(), + }, ) } } @@ -570,26 +576,26 @@ func (m *Model) GetSelected() []fmt.Stringer { } // Sort sorts the list items according to the set less-function -// If there is no Equals-function set (with SetEquals), the current Item will maybe change! -// Since the index of the current pointer does not change +// If its not set than order after string. func (m *Model) Sort() { - equ := m.equals - var tmp item - if equ != nil { - tmp = m.listItems[m.viewPos.Cursor] + if m.Len() < 1 { + return } + old := m.listItems[m.viewPos.Cursor].id sort.Sort(m) - if equ == nil { - return + if m.less == nil { + m.less = func(a, b fmt.Stringer) bool { + return a.String() < b.String() + } } for i, item := range m.listItems { - if is := equ(item.value, tmp.value); is { + if item.id == old { m.viewPos.Cursor = i - break // Stop when first (and hopefully only one) is found + break } } + // move the visible m.Move(0) - } // Less is a Proxy to the less function, set from the user. @@ -624,15 +630,14 @@ func (m *Model) SetLess(less func(a, b fmt.Stringer) bool) { m.less = less } -// SetEquals sets the internal equals methode used if provided to set the cursor again on the same item after sorting +// SetEquals sets the internal equals methode used to get the index (GetIndex) of a provided fmt.Stringer value func (m *Model) SetEquals(equ func(first, second fmt.Stringer) bool) { m.equals = equ } // GetEquals returns the internal equals methode -// used to set the curser after sorting on the same item again +// used to get the index (GetIndex) of a provided fmt.Stringer value func (m *Model) GetEquals() func(first, second fmt.Stringer) bool { - // TODO remove this function? return m.equals } @@ -793,3 +798,27 @@ func (m *Model) Copy() *Model { *copiedModel = *m return copiedModel } + +// GetID returns a new for this list unique id +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 +} From 71e97915ff275c7f03f1f7d7063af917dc42134a Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Thu, 19 Nov 2020 16:02:39 +0100 Subject: [PATCH 56/73] Changes Lines/View to be inherently bound to contain the Cursor - Changed Lines() to be inherently bound to contain the cursor within the visible Lines, by starting to draw from the Cursor position. - because of that KeepVisible is redundant and got replaced through ValidIndex and validOffset, which should return the proper values for Cursor and lineOffset - lineOffset is not anymore the line offset of the current offset item but simple the amount of lines that should be infront of the cursor. - changed moving functions and test accordingly. - changed test to account for change of keybindings - added minimal lineOffset to NewModel() which equals the CursorOffset, because if there are less items befor the cursor than the amount of lineOffset only the available items will get draw -> lineOffsets value is only valid between 0+m.CursorOffset and Screen.Height - m.CursorOffset - added NoItems error to be able to tell difference from OutOfBounds - changed AddItems to sort new added items, if user provided custom less function. - moved default less function to be implicied within the Less function when there is no less function set, to be able to check if user provided a custom less function, within the AddItems methode. - fixed bug within MarkSelected to use write amount after change of target when encountering error through ValidIndex. - fixed bug within MoveItem to use right target index. - returned forgotten errors within list methods - added/changed example strings - catched negative repeat count silently within prefixer.go because no logging has yet been implemented. --- list/example/main.go | 37 ++-- list/list.go | 420 ++++++++++++++++++++----------------------- list/list_test.go | 82 +++++---- list/prefixer.go | 10 +- 4 files changed, 275 insertions(+), 274 deletions(-) diff --git a/list/example/main.go b/list/example/main.go index d1775d097..3ceed764b 100644 --- a/list/example/main.go +++ b/list/example/main.go @@ -6,6 +6,7 @@ import ( "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/muesli/termenv" + //"log" "os" "strconv" ) @@ -111,26 +112,34 @@ func (s stringItem) String() string { } func main() { + //f, err := os.OpenFile("list.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + //if err != nil { + // log.Fatal(err) + //} + //defer f.Close() + //log.SetOutput(f) + m := newModel() itemList := []string{ "Welcome to the bubbles-list example!", "", "Use 'q' or 'ctrl-c' to quit!", - "The list can handel linebreaks,\nand has wordwrap enabled if the line gets to long.", + "The list can handle linebreaks,\nand has wordwrap enabled if the line gets to long.", "", "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 movemet to that amount.\nI.e.: the key press order '1','2' and 't' moves the cursor to the twelfth item from the top.", + "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 entrys with 's' depending on a costum less function, in this case string sorting.", + "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 relativ numbers use the 'r' key.", - "To toggle between showing the wrapped lines of a item use the 'w' key.", + "To toggle between only absolute item numbers and relative numbers use the 'r' key.", + "To toggle the line wrapping of items with more lines, use the 'w' key.", "", "Select:", "To select a item use 'm'\nor to select all items 'M'.", @@ -142,12 +151,12 @@ func main() { "With the key 'e' you can edit the string of the current item.", "There you can make changes to the string and apply them with 'enter' or discard them with 'escape'", "", - "Here are some more items for you to test the scrolling\nand the cursor-Offset which defaults to 5 lines from the screen border.", + "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.", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", - //"Be aware, that items with more linebreaks than the screen height minus twice the scroll offset, cause some display problems to the hole list, but the cursor will be on the right item, even if the cursor jumps relative to the screen.\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nBut you can avoid this ,by toggeling wrap to be off, with the 'w' key.", + "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 currently only possible by item and not by line.\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\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 directly to me with the 'b' key, which stands for bottom.", + "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) @@ -266,9 +275,17 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return item, nil } m.list.UpdateAllItems(updater) + m.edit = false + return m, nil } - m.edit = false + + j := 0 + if m.jump != "" { + j, _ = strconv.Atoi(m.jump) + m.jump = "" + m.list.SetCursor(j - 1) + } return m, nil case "c": diff --git a/list/list.go b/list/list.go index af04ab900..2dda1c521 100644 --- a/list/list.go +++ b/list/list.go @@ -50,14 +50,11 @@ func NewModel() Model { // Try to keep $CursorOffset lines between Cursor and screen Border CursorOffset: 5, + viewPos: ViewPos{LineOffset: 5}, // Wrap lines to have no loss of information Wrap: true, - less: func(k, l fmt.Stringer) bool { - return k.String() < l.String() - }, - SelectedStyle: selStyle, CurrentStyle: curStyle, } @@ -102,47 +99,76 @@ func (m *Model) Lines() []string { panic("Can't display with zero width for content") } - lineOffset := m.viewPos.LineOffset - offset := m.viewPos.ItemOffset + linesBefor := make([]string, 0, height) + // loop to add the item(-lines) befor the cursor to the return lines + for c := 1; // dont add cursor item + m.viewPos.Cursor-c >= 0; c++ { + index := m.viewPos.Cursor - c + item := m.listItems[index] - var visLines int - stringLines := make([]string, 0, height) + contentLines := m.itemLines(item) + // append the lines in reverse, to add them in correct order later + for c := len(contentLines) - 1; c >= 0 && len(linesBefor) < m.viewPos.LineOffset; c-- { + lineContent := contentLines[c] + // Surrounding lineContent + var linePrefix, lineSuffix string + if m.PrefixGen != nil { + linePrefix = m.PrefixGen.Prefix(index, c, item.selected) + } + if m.SuffixGen != nil { + free := contentWidth - ansi.PrintableRuneWidth(lineContent) + if free < 0 { + free = 0 // TODO is this nessecary after adding hardwrap? + } + lineSuffix = fmt.Sprintf("%s%s", strings.Repeat(" ", free), m.SuffixGen.Suffix(index, c, item.selected)) + } -out: - // Handle list items, start at first visible and go till end of list or visible (break) - for index := offset; index < len(m.listItems); index++ { - item := m.listItems[index] + // Join all + line := fmt.Sprintf("%s%s%s", linePrefix, lineContent, lineSuffix) - lines := m.itemLines(item) + // Highlighting of selected lines + style := m.LineStyle + if item.selected { + style = m.SelectedStyle + } - var ignoreLines bool - if len(lines) > 1 && lineOffset > 0 && index == offset { - ignoreLines = true + // Highlight and write wrapped line + linesBefor = append(linesBefor, style.Styled(line)) } - // Write lines - for i, line := range lines { - // skip unvisible leading lines - if ignoreLines && lineOffset > 0 { - lineOffset-- - continue - } + } + + // append lines (befor cursor) in correct order to allLines + allLines := make([]string, 0, height) + for c := len(linesBefor) - 1; c >= 0; c-- { + allLines = append(allLines, linesBefor[c]) + } + + var visLines int + // Handle list items, start at cursor and go till end of list or visible (break) + for index := m.viewPos.Cursor; index < len(m.listItems); index++ { + item := m.listItems[index] + + lines := m.itemLines(item) + // append all visibles lines since the cursor + for c := 0; c < len(lines) && len(allLines) < height; c++ { + lineContent := lines[c] // Surrounding content var linePrefix, lineSuffix string if m.PrefixGen != nil { - linePrefix = m.PrefixGen.Prefix(index, i, item.selected) + linePrefix = m.PrefixGen.Prefix(index, c, item.selected) } if m.SuffixGen != nil { - free := contentWidth - ansi.PrintableRuneWidth(line) + free := contentWidth - ansi.PrintableRuneWidth(lineContent) if free < 0 { free = 0 // TODO is this nessecary? } - lineSuffix = fmt.Sprintf("%s%s", strings.Repeat(" ", free), m.SuffixGen.Suffix(index, i, item.selected)) + lineSuffix = fmt.Sprintf("%s%s", strings.Repeat(" ", free), m.SuffixGen.Suffix(index, c, item.selected)) } // Join all - line := fmt.Sprintf("%s%s%s", linePrefix, line, lineSuffix) + line := fmt.Sprintf("%s%s%s", linePrefix, lineContent, lineSuffix) // Highlighting of selected and current lines style := m.LineStyle @@ -154,16 +180,11 @@ out: } // Highlight and write wrapped line - stringLines = append(stringLines, style.Styled(line)) + allLines = append(allLines, style.Styled(line)) visLines++ - - // Only write lines that are visible - if visLines >= height { - break out - } } } - return stringLines + return allLines } // Update changes the Model of the List according to the messages received @@ -203,10 +224,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "b", "end": m.Bottom() return m, nil - case "+": + case "K": m.MoveItem(-1) return m, nil - case "-": + case "J": m.MoveItem(1) return m, nil @@ -259,6 +280,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, 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 @@ -271,12 +295,73 @@ type MultipleMatches error // ConfigError is return if there is a error with the configuration of the list Modul type ConfigError error -// NotFocused is a error return if the action can only be applied to a focused list +// NotFocused is a error return if the action can only be applied to a focused list. type NotFocused error +// 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. +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 { + return 0, NotFocused(fmt.Errorf("the list is not focused")) + } + if index < 0 { + return 0, OutOfBounds(fmt.Errorf("the requested index (%d) is infront 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 { + // assume down (positiv) 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 += strings.Count(m.listItems[m.viewPos.Cursor+i*d].value.String(), "\n") + 1 + } + 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 { - ItemOffset int LineOffset int Cursor int } @@ -290,170 +375,42 @@ type ScreenInfo struct { // Move moves the cursor by amount and returns OutOfBounds error if amount go's beyond list borders // or if the CursorOffset is greater than half of the display height returns ConfigError -// if amount is 0 the Curser will get set within the view bounds func (m *Model) Move(amount int) (int, error) { target := m.viewPos.Cursor + amount - newPos, err := m.KeepVisible(target) - m.viewPos = newPos - return newPos.Cursor, err + + newOffset, err := m.validOffset(target) + target, err = m.ValidIndex(target) + + m.viewPos.Cursor = target + m.viewPos.LineOffset = newOffset + return target, err } // SetCursor set the cursor to the specified index if possible, // if not the nearest end of the list, will be used and OutOfBounds error is returned -func (m *Model) SetCursor(target int) error { - newPos, err := m.KeepVisible(target) - m.viewPos = newPos - return err +func (m *Model) SetCursor(target int) (int, error) { + newOffset, err := m.validOffset(target) + target, err = m.ValidIndex(target) + m.viewPos.Cursor = target + m.viewPos.LineOffset = newOffset + return target, err } // Top moves the cursor to the first line func (m *Model) Top() { m.viewPos.Cursor = 0 - m.viewPos.ItemOffset = 0 - m.viewPos.LineOffset = 0 + m.viewPos.LineOffset = m.CursorOffset } // Bottom moves the cursor to the last line func (m *Model) Bottom() { end := len(m.listItems) - 1 + m.viewPos.LineOffset = m.Screen.Height - m.CursorOffset m.Move(end) } -// KeepVisible will set the Cursor within the visible area of the list -// and if CursorOffset is != 0 will set it within this bounderys -// if CursorOffset is bigger than half the screen hight error will be of type ConfigError -// If the cursor would be outside of the list, it will be set to the according nearest value -// and error will be of type OutOfBounds. The return int is the absolut item number on which the cursor gets set -func (m *Model) KeepVisible(target int) (ViewPos, error) { - var err error - // Check if Cursor would be beyond list - if length := len(m.listItems); target >= length { - target = length - 1 - errMsg := "requested cursor position was behind of the list" - err = OutOfBounds(fmt.Errorf(errMsg)) - } - - // Check if Cursor would be infront of list - if target < 0 { - target = 0 - errMsg := "requested cursor position was infront of the list" - err = OutOfBounds(fmt.Errorf(errMsg)) - } - - if target == 0 { - return ViewPos{}, err - } - - if m.Wrap { - return m.keepVisibleWrap(target), err - } - - m.viewPos.LineOffset = 0 - - visItemsBeforCursor := target - m.viewPos.ItemOffset - - // Visible Area and Cursor are at beginning of List -> cant move further up. - if m.viewPos.ItemOffset <= 0 && visItemsBeforCursor <= m.CursorOffset { - return ViewPos{Cursor: target}, err - } - - // Cursor is infront of Boundry -> move visible Area up - if visItemsBeforCursor < m.CursorOffset { - return ViewPos{Cursor: target, ItemOffset: target - m.CursorOffset}, err - } - - // Cursor Position is within bounds -> all good - if visItemsBeforCursor >= m.CursorOffset && visItemsBeforCursor < m.Screen.Height-m.CursorOffset { - return ViewPos{Cursor: target, ItemOffset: m.viewPos.ItemOffset}, err - } - - // Cursor is beyond boundry -> move visibel Area down - lowerOffset := m.viewPos.ItemOffset - (m.Screen.Height - m.CursorOffset - visItemsBeforCursor - 1) - return ViewPos{Cursor: target, ItemOffset: lowerOffset}, err -} - -// keepVisibleWrap returns the new viewPos according to the requested target Cursor position -// is target is outside the list return the nearest end -func (m *Model) keepVisibleWrap(target int) ViewPos { - if target <= 0 { - return ViewPos{} - } - - if target >= m.Len() { - target = m.Len() - 1 - } - - direction := 1 - diff := target - m.viewPos.Cursor - if diff < 0 { - direction = -1 - } - - type beforCursor struct { - listIndex int - linesBefor int - } - - lineCount := make([]beforCursor, 0, m.Screen.Height) - - var lineSum int - if direction >= 0 { - lineSum = 1 // Cursorline is not counted in the following loop, so do it here - } - - var lower, upper bool // Visible lower/upper - upperBorder := m.CursorOffset - lowerBorder := m.Screen.Height - m.CursorOffset - // calculate how much space/lines the items befor the requested cursor position occupy - for c := target - 1; c >= 0 && c > target-m.Screen.Height; c-- { - lineSum += len(m.itemLines(m.listItems[c])) - lineCount = append(lineCount, beforCursor{c, lineSum}) - - // if new target infront of old visible offset dont mark borders - // TODO here is a bug: when there is a list item with more than Screen.Height-m.CursorOffset lines - // the up movement below this item will move to the wrong position, no solution yet - if target-1 < m.viewPos.ItemOffset+m.CursorOffset { - continue - } - - // mark the pass of a border - if !upper && lineSum > upperBorder { - upper = true - } - if !lower && lineSum >= lowerBorder && c >= m.viewPos.ItemOffset { - lower = true - } - } - - // Can't Move visible infront of list begin - if direction < 0 && len(lineCount) > 0 && // possible upwards movement - lineCount[len(lineCount)-1].linesBefor < m.CursorOffset && // beyond upper border - m.viewPos.ItemOffset <= 0 && m.viewPos.LineOffset <= 0 { // but allready at beginning of list - - return ViewPos{Cursor: target} - } - - var lastOffset, lineOffset int - for _, count := range lineCount { - lastOffset = count.listIndex // Visible Offset - // infront upper border -> Move up - if direction < 0 && !upper && count.linesBefor > upperBorder { - lineOffset = count.linesBefor - upperBorder - return ViewPos{ItemOffset: lastOffset, LineOffset: lineOffset, Cursor: target} - } - // beyond lower border -> Moving Down - if direction >= 0 && lower && count.linesBefor >= lowerBorder { - lastOffset = count.listIndex // Visible Offset - lineOffset = count.linesBefor - lowerBorder - return ViewPos{ItemOffset: lastOffset, LineOffset: lineOffset, Cursor: target} - } - } - - // Within bounds: only change cursor - return ViewPos{ItemOffset: m.viewPos.ItemOffset, LineOffset: m.viewPos.LineOffset, Cursor: target} -} - // AddItems adds the given Items to the list Model +// and if a costum less function is provided, they get sorted. func (m *Model) AddItems(itemList []fmt.Stringer) { for _, i := range itemList { m.listItems = append(m.listItems, item{ @@ -463,6 +420,11 @@ func (m *Model) AddItems(itemList []fmt.Stringer) { }, ) } + // only sort if user set less function + if m.less != nil { + // Sort will take care of the correct position of Cursor and Offset + m.Sort() + } } // ToggleSelect toggles the selected status @@ -509,58 +471,69 @@ func (m *Model) ToggleSelect(amount int) error { // if amount would be outside the list error is from type OutOfBounds // else all items till but excluding the end cursor position gets (un-)marked func (m *Model) MarkSelected(amount int, mark bool) error { - if m.Len() == 0 { - return OutOfBounds(fmt.Errorf("No Items within list")) - } cur := m.viewPos.Cursor - if amount == 0 { - m.listItems[cur].selected = mark - return nil - } direction := 1 if amount < 0 { direction = -1 } - target := cur + amount - direction - if !m.CheckWithinBorder(target) { - return OutOfBounds(fmt.Errorf("Cant go beyond list borders: %d", target)) + + target, err := m.ValidIndex(target) + if m.Len() == 0 { + return err + } + // correct amount in case target has changed + amount = target - cur + direction + + if amount == 0 { + m.listItems[cur].selected = mark + return nil } for c := 0; c < amount*direction; c++ { m.listItems[cur+c].selected = mark } m.viewPos.Cursor = target - _, err := m.Move(direction) + _, errSec := m.Move(direction) + if err == nil { + err = errSec + } return err } // MarkAllSelected marks all items of the list according to mark // or returns OutOfBounds if list has no Items func (m *Model) MarkAllSelected(mark bool) error { + _, err := m.ValidIndex(0) if m.Len() == 0 { - return OutOfBounds(fmt.Errorf("No Items within list")) + return err } for c := range m.listItems { m.listItems[c].selected = mark } - return nil + return err } // ToggleAllSelected inverts the select state of ALL items -func (m *Model) ToggleAllSelected() { +func (m *Model) ToggleAllSelected() error { + _, err := m.ValidIndex(0) + if m.Len() == 0 { + return err + } for i := range m.listItems { m.listItems[i].selected = !m.listItems[i].selected } + return err } // IsSelected returns true if the given Item is selected // false otherwise. If the requested index is outside the list // error is not nil. func (m *Model) IsSelected(index int) (bool, error) { - if !m.CheckWithinBorder(index) { - return false, OutOfBounds(fmt.Errorf("index: '%d' is outside the list", index)) + index, err := m.ValidIndex(index) + if m.Len() == 0 { + return false, err } - return m.listItems[index].selected, nil + return m.listItems[index].selected, err } // GetSelected returns you a list of all items @@ -583,28 +556,27 @@ func (m *Model) Sort() { } old := m.listItems[m.viewPos.Cursor].id sort.Sort(m) - if m.less == nil { - m.less = func(a, b fmt.Stringer) bool { - return a.String() < b.String() - } - } for i, item := range m.listItems { if item.id == old { m.viewPos.Cursor = i break } } - // move the visible - m.Move(0) } // Less is a Proxy to the less function, set from the user. // since the Sort-interface demands a Less Methode without a error return value // so we sadly have to returns silently if a index is out side the list, to not panic. func (m *Model) Less(i, j int) bool { - if !m.CheckWithinBorder(i) || !m.CheckWithinBorder(j) { + _, errI := m.ValidIndex(i) + _, errJ := m.ValidIndex(j) + if errI != nil || errJ != nil { return false } + // 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.listItems[i].value.String() < m.listItems[j].value.String() + } return m.less(m.listItems[i].value, m.listItems[j].value) } @@ -613,7 +585,9 @@ func (m *Model) Less(i, j int) bool { // since the Sort-interface demands a Swap Methode without a error return value // so we sadly have to returns silently if a index is out side the list, to not panic. func (m *Model) Swap(i, j int) { - if !m.CheckWithinBorder(i) || !m.CheckWithinBorder(j) { + _, errI := m.ValidIndex(i) + _, errJ := m.ValidIndex(j) + if errI != nil || errJ != nil { return } m.listItems[i], m.listItems[j] = m.listItems[j], m.listItems[i] @@ -647,14 +621,14 @@ func (m *Model) GetEquals() func(first, second fmt.Stringer) bool { // MoveItem(0) safely does nothing // and a amount that would result outside the list returns a error != nil func (m *Model) MoveItem(amount int) error { + cur := m.viewPos.Cursor + target, err := m.ValidIndex(cur + amount) if m.Len() == 0 { - return OutOfBounds(fmt.Errorf("can't get MoveItem on empty list")) + return err } if amount == 0 { return nil } - cur := m.viewPos.Cursor - target, err := m.Move(amount) if err != nil { return err } @@ -662,22 +636,16 @@ func (m *Model) MoveItem(amount int) error { if amount < 0 { d = -1 } + // TODO change to not O(n) for c := 0; c*d < amount*d; c += d { m.Swap(cur+c, cur+c+d) } + linOff, _ := m.validOffset(target) + m.viewPos.LineOffset = linOff m.viewPos.Cursor = target return nil } -// CheckWithinBorder returns true if the give index is within the list borders -func (m *Model) CheckWithinBorder(index int) bool { - length := len(m.listItems) - if index >= length || index < 0 { - return false - } - return true -} - // Focus sets the list Model focus so it accepts key input and responds to them func (m *Model) Focus() { m.focus = true @@ -728,12 +696,13 @@ func (m *Model) GetIndex(toSearch fmt.Stringer) (int, error) { // UpdateItem takes a indes and updates the item at the index with the given function // or if index outside the list returns OutOfBounds error. func (m *Model) UpdateItem(index int, updater func(fmt.Stringer) (fmt.Stringer, tea.Cmd)) (tea.Cmd, error) { - if !m.CheckWithinBorder(index) { - return nil, OutOfBounds(fmt.Errorf("index is outside the list")) + index, err := m.ValidIndex(index) + if m.Len() == 0 { + return nil, err } v, cmd := updater(m.listItems[index].value) m.listItems[index].value = v - return cmd, nil + return cmd, err } // UpdateAllItems takes a function and updates with it, all items in the list @@ -771,8 +740,9 @@ func (m *Model) GetCursorIndex() (int, error) { // GetItem returns the item if the index exists // OutOfBounds otherwise func (m *Model) GetItem(index int) (fmt.Stringer, error) { - if !m.CheckWithinBorder(index) { - return nil, OutOfBounds(fmt.Errorf("requested index is outside the list")) + index, err := m.ValidIndex(index) + if m.Len() == 0 { + return nil, err } return m.listItems[index].value, nil } diff --git a/list/list_test.go b/list/list_test.go index 4c4e8b5be..9b5ab8e48 100644 --- a/list/list_test.go +++ b/list/list_test.go @@ -68,11 +68,10 @@ func TestBasicsLines(t *testing.T) { } // first two swaped - itemList := MakeStringerList([]string{"2", "1", "3", "4", "5", "6", "7", "8", "9"}) - m.AddItems(itemList) + orgList := MakeStringerList([]string{"2", "1", "3", "4", "5", "6", "7", "8", "9"}) + m.AddItems(orgList) m.Move(1) - m.SetEquals(func(a, b fmt.Stringer) bool { return a.String() == b.String() }) // Sort them newModel, _ := m.Update(tea.KeyMsg(tea.Key{Type: tea.KeyRunes, Runes: []rune{'s'}})) m, _ = newModel.(Model) @@ -81,16 +80,14 @@ func TestBasicsLines(t *testing.T) { // should be the like the beginning sortedItemList := m.GetAllItems() - // make sure all itemList get processed - shorter, longer := sortedItemList, itemList - if len(itemList) > len(longer) { - shorter, longer = itemList, sortedItemList + if len(orgList) != len(sortedItemList) { + t.Errorf("the list should not change size") } - // Process/check all itemList - for c, item := range longer { - if item.String() != shorter[c].String() { - t.Errorf("this strings should match but dont: %q, %q", item.String(), shorter[c].String()) + // 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()) } } @@ -121,16 +118,16 @@ func TestWrappedLines(t *testing.T) { m.SuffixGen = NewSuffixer() m.Screen = ScreenInfo{Height: 50, Width: 80} m.AddItems(MakeStringerList([]string{"\n0", "1\n2", "3\n4", "5\n6", "7\n8"})) - m.viewPos = ViewPos{LineOffset: 1} out := m.Lines() wrap, sep := "│", "╭" num := "\x1b[7m " - for i, line := range out { - if i%2 == 1 { - num = fmt.Sprintf(" %1d", (i/2)+2) + for i := 1; i < len(out); i++ { + line := out[i] + if i%2 == 0 { + num = fmt.Sprintf(" %1d", (i/2)+1) } - prefix := fmt.Sprintf("%s %s %d", num, wrap, i) + prefix := fmt.Sprintf("%s %s %d", num, wrap, 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) } @@ -197,21 +194,21 @@ func TestMovementKeys(t *testing.T) { start, finish = 55, 56 m.viewPos.Cursor = start - newModel, cmd = m.Update(tea.KeyMsg(tea.Key{Type: tea.KeyRunes, Runes: []rune{'-'}})) + newModel, cmd = m.Update(tea.KeyMsg(tea.Key{Type: tea.KeyRunes, Runes: []rune{'J'}})) m, _ = newModel.(Model) if m.viewPos.Cursor != finish || cmd != nil { - t.Errorf("key '-' should have nil command but got: '%#v' and move the Cursor to index '%d', but got: %d", cmd, finish, m.viewPos.Cursor) + t.Errorf("key 'J' should have nil command but got: '%#v' and move the Cursor to index '%d', but got: %d", cmd, finish, m.viewPos.Cursor) } - m.viewPos.ItemOffset = 10 + m.viewPos.LineOffset = 15 start, finish = 15, 14 m.viewPos.Cursor = start - newModel, cmd = m.Update(tea.KeyMsg(tea.Key{Type: tea.KeyRunes, Runes: []rune{'+'}})) + newModel, cmd = m.Update(tea.KeyMsg(tea.Key{Type: tea.KeyRunes, Runes: []rune{'K'}})) m, _ = newModel.(Model) if m.viewPos.Cursor != finish || cmd != nil { - t.Errorf("key '+' should have nil command but got: '%#v' and move the Cursor to index '%d', but got: %d", cmd, finish, m.viewPos.Cursor) + t.Errorf("key 'K' should have nil command but got: '%#v' and move the Cursor to index '%d', but got: %d", cmd, finish, m.viewPos.Cursor) } - if m.viewPos.ItemOffset != 9 { - t.Errorf("up movement should change the Item offset to '9' but got: %d", m.viewPos.ItemOffset) + 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 newModel, cmd = m.Update(tea.KeyMsg(tea.Key{Type: tea.KeyRunes, Runes: []rune{'b'}})) @@ -363,8 +360,9 @@ func TestItemUpdater(t *testing.T) { // TestWithinBorder test if indexes are within the listborders func TestWithinBorder(t *testing.T) { m := NewModel() - if m.CheckWithinBorder(0) { - t.Error("a empty list has no item '0', should return 'false'") + _, 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) } } @@ -404,29 +402,39 @@ func TestCopy(t *testing.T) { } } -// TestKeepVisibleWrap test the private helper function of KeepVisible -func TestKeepVisibleWrap(t *testing.T) { +// 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"})) + 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{ - {ViewPos{0, 0, 3}, -2, ViewPos{0, 0, 0}}, // infront of list - {ViewPos{0, 0, 3}, 2, ViewPos{0, 0, 2}}, // begin of list and upper border - {ViewPos{4, 0, 12}, 8, ViewPos{5, 1, 8}}, // Middel of list and upper border - {ViewPos{5, 0, 15}, 0, ViewPos{0, 0, 0}}, // beginning - {ViewPos{15, 1, 14}, 19, ViewPos{15, 1, 19}}, // Middel - {ViewPos{0, 0, 0}, 25, ViewPos{3, 0, 25}}, // pass of lower border - {ViewPos{0, 0, 0}, 100, ViewPos{49, 0, 71}}, // pass of lower border + // forwards + {ViewPos{0, 0}, -2, ViewPos{5, 0}}, + {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{44, 72}}, + // backwards + {ViewPos{45, m.Len() - 1}, -2, ViewPos{5, 0}}, + {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, 72}}, } for i, tCase := range toTest { m.viewPos = tCase.oldView - if g := m.keepVisibleWrap(tCase.target); g != 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, g, tCase.newView, tCase.target) + 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) } } } diff --git a/list/prefixer.go b/list/prefixer.go index 4b4e78d93..db8ea0d9f 100644 --- a/list/prefixer.go +++ b/list/prefixer.go @@ -75,7 +75,7 @@ func NewPrefixer() *DefaultPrefixer { func (d *DefaultPrefixer) InitPrefixer(position ViewPos, screen ScreenInfo) int { d.viewPos = position - offset := position.ItemOffset + offset := position.Cursor - position.LineOffset // Get separators width widthItem := ansi.PrintableRuneWidth(d.Seperator) @@ -88,6 +88,7 @@ func (d *DefaultPrefixer) InitPrefixer(position ViewPos, screen ScreenInfo) int } // 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 @@ -137,7 +138,12 @@ func (d *DefaultPrefixer) Prefix(currentIndex int, wrapIndex int, selected bool) } number := fmt.Sprintf("%d", lineNum) // since digits are only single bytes, len is sufficient: - firstPad = strings.Repeat(" ", d.numWidth-len(number)) + number + 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) // Selecting: handle highlighting and prefixing of selected lines From a1d3edb9b61196c5f2ac171655f0828f083c8e28 Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Sat, 21 Nov 2020 18:41:38 +0100 Subject: [PATCH 57/73] excluded nil values from list and empty string from View - renamed Move to MoveCursor to be more explicied. - Changed UpdateItem to remove items if it returns a nil value and added a RemoveIndex to do this directly and dedicated. - deleted UpdatedAllItems and UpdateSelectedItems because multi error handling, when the updating function returns a nil-value was cumbersome/unclear. - changed test and example accordingly and added a key to remove (delete) and add items - refined example strings to be more telling. - Since a empty string gets not draw, changed the list View to return "empty" instead. - Changed prefixer to not allow negative offsets since it could mess with the rendering of the user interface when Prefix has not enough space because of this. --- list/example/main.go | 48 +++++++++++++++---- list/list.go | 111 +++++++++++++++++++++++++------------------ list/list_test.go | 26 +--------- list/prefixer.go | 3 ++ 4 files changed, 108 insertions(+), 80 deletions(-) diff --git a/list/example/main.go b/list/example/main.go index 3ceed764b..1905b6775 100644 --- a/list/example/main.go +++ b/list/example/main.go @@ -124,7 +124,8 @@ func main() { "Welcome to the bubbles-list example!", "", "Use 'q' or 'ctrl-c' to quit!", - "The list can handle linebreaks,\nand has wordwrap enabled if the line gets to long.", + "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'.", @@ -148,12 +149,14 @@ func main() { "The key 'v' inverts the selected state of all items.", "", "Edit:", - "With the key 'e' you can edit the string of the current item.", + "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 currently only possible by item and not by line.\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\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", + "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.", @@ -230,7 +233,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { item.edit = false return item, nil } - m.list.UpdateAllItems(updater) + for c := 0; c < m.list.Len(); c++ { + m.list.UpdateItem(c, updater) + } } m.edit = false @@ -274,7 +279,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { item.edit = false return item, nil } - m.list.UpdateAllItems(updater) + for c := 0; c < m.list.Len(); c++ { + m.list.UpdateItem(c, updater) + } m.edit = false return m, nil @@ -289,7 +296,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case "c": - m.list.Move(1) + m.list.MoveCursor(1) return m, nil case "q": return m, tea.Quit @@ -302,7 +309,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { j, _ = strconv.Atoi(m.jump) m.jump = "" } - m.list.Move(j) + m.list.MoveCursor(j) return m, nil case "up", "k": j := 1 @@ -310,7 +317,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { j, _ = strconv.Atoi(m.jump) m.jump = "" } - m.list.Move(-j) + m.list.MoveCursor(-j) return m, nil case "r": d, ok := m.list.PrefixGen.(*list.DefaultPrefixer) @@ -368,7 +375,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { j-- } m.list.Top() - m.list.Move(j) + m.list.MoveCursor(j) return m, nil case "b", "end": j := 0 @@ -380,7 +387,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { j-- } m.list.Bottom() - m.list.Move(-j) + m.list.MoveCursor(-j) return m, nil // case "t": @@ -407,6 +414,27 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 err error + var i int + for c := 0; c < j && err == nil; c++ { + i, _ = m.list.GetCursorIndex() + _, err = m.list.RemoveIndex(i) + } + return m, nil default: // resets jump buffer to prevent confusion diff --git a/list/list.go b/list/list.go index 2dda1c521..37a6fb8bb 100644 --- a/list/list.go +++ b/list/list.go @@ -66,18 +66,23 @@ func (m Model) Init() tea.Cmd { } // View renders the List to a (displayable) string +// since a empty string gets not displayed return something to overwrite the last removed item func (m Model) View() string { - return strings.Join(m.Lines(), "\n") + lines := strings.Join(m.Lines(), "\n") + if lines == "" { + // TODO make empty string handling better, custom empty function? + return "empty" + } + return lines } // Lines returns the Visible lines of the list items // used to display the current user interface func (m *Model) Lines() []string { // get public variables as locals so they can't change while using - - // check visible area height := m.Screen.Height width := m.Screen.Width + // check visible area if height*width <= 0 { panic("Can't display with zero width or hight of Viewport") } @@ -146,7 +151,7 @@ func (m *Model) Lines() []string { var visLines int // Handle list items, start at cursor and go till end of list or visible (break) - for index := m.viewPos.Cursor; index < len(m.listItems); index++ { + for index := m.viewPos.Cursor; index < m.Len(); index++ { item := m.listItems[index] lines := m.itemLines(item) @@ -213,10 +218,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Move case "down", "j": - m.Move(1) + m.MoveCursor(1) return m, nil case "up", "k": - m.Move(-1) + m.MoveCursor(-1) return m, nil case "t", "home": m.Top() @@ -234,7 +239,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Select case " ": m.ToggleSelect(1) - m.Move(1) + m.MoveCursor(1) return m, nil case "v": // inVert m.ToggleAllSelected() @@ -269,11 +274,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.MouseMsg: switch msg.Type { case tea.MouseWheelUp: - m.Move(-1) + m.MoveCursor(-1) return m, nil case tea.MouseWheelDown: - m.Move(1) + m.MoveCursor(1) return m, nil } } @@ -298,6 +303,9 @@ 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 + // 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. func (m *Model) ValidIndex(index int) (int, error) { @@ -373,9 +381,9 @@ type ScreenInfo struct { Profile termenv.Profile } -// Move moves the cursor by amount and returns OutOfBounds error if amount go's beyond list borders +// MoveCursor moves the cursor by amount and returns OutOfBounds error if amount go's beyond list borders // or if the CursorOffset is greater than half of the display height returns ConfigError -func (m *Model) Move(amount int) (int, error) { +func (m *Model) MoveCursor(amount int) (int, error) { target := m.viewPos.Cursor + amount newOffset, err := m.validOffset(target) @@ -406,25 +414,55 @@ func (m *Model) Top() { func (m *Model) Bottom() { end := len(m.listItems) - 1 m.viewPos.LineOffset = m.Screen.Height - m.CursorOffset - m.Move(end) + m.MoveCursor(end) } // AddItems adds the given Items to the list Model // and if a costum less function is provided, they get sorted. -func (m *Model) AddItems(itemList []fmt.Stringer) { +// if a entry of itemList is nil it will get skiped +func (m *Model) AddItems(itemList []fmt.Stringer) error { + oldLenght := m.Len() for _, i := range itemList { - m.listItems = append(m.listItems, item{ - selected: false, - value: i, - id: m.getID(), - }, - ) + if i != nil { + m.listItems = append(m.listItems, item{ + selected: false, + value: i, + id: m.getID(), + }, + ) + } } // only sort if user set less function if m.less != nil { // Sort will take care of the correct position of Cursor and Offset m.Sort() } + var err error + 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 err +} + +// RemoveIndex returns a error if the index is not valid, +// and if valid, returns the item while removing it from the list. +func (m *Model) RemoveIndex(index int) (fmt.Stringer, error) { + index, err := m.ValidIndex(index) + if m.Len() == 0 { + m.viewPos.Cursor = 0 + return nil, 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...) + newCursor, _ := m.ValidIndex(index) + newOffset, _ := m.validOffset(newCursor) + m.viewPos.Cursor = newCursor + m.viewPos.LineOffset = newOffset + return itemValue, err } // ToggleSelect toggles the selected status @@ -447,7 +485,7 @@ func (m *Model) ToggleSelect(amount int) error { cur := m.viewPos.Cursor - target, err := m.Move(amount) + target, err := m.MoveCursor(amount) start, end := cur, target if direction < 0 { start, end = target+1, cur+1 @@ -493,7 +531,7 @@ func (m *Model) MarkSelected(amount int, mark bool) error { m.listItems[cur+c].selected = mark } m.viewPos.Cursor = target - _, errSec := m.Move(direction) + _, errSec := m.MoveCursor(direction) if err == nil { err = errSec } @@ -695,36 +733,22 @@ func (m *Model) GetIndex(toSearch fmt.Stringer) (int, error) { // UpdateItem takes a indes 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 and a NilValue error is returned. func (m *Model) UpdateItem(index int, updater func(fmt.Stringer) (fmt.Stringer, tea.Cmd)) (tea.Cmd, error) { index, err := m.ValidIndex(index) if m.Len() == 0 { return nil, err } v, cmd := updater(m.listItems[index].value) + // remove item when value equals nil + if v == nil { + m.RemoveIndex(index) + return cmd, NilValue(fmt.Errorf("cant add nil value to list")) + } m.listItems[index].value = v return cmd, err } -// UpdateAllItems takes a function and updates with it, all items in the list -func (m *Model) UpdateAllItems(updater func(fmt.Stringer) (fmt.Stringer, tea.Cmd)) []tea.Cmd { - cmdList := make([]tea.Cmd, 0, m.Len()) - for i, item := range m.listItems { - v, cmd := updater(item.value) - m.listItems[i].value = v - cmdList = append(cmdList, cmd) - } - return cmdList -} - -// UpdateSelectedItems updates all selected items within the list with given function -func (m *Model) UpdateSelectedItems(updater func(fmt.Stringer) fmt.Stringer) { - for i, item := range m.listItems { - if item.selected { - m.listItems[i].value = updater(item.value) - } - } -} - // GetCursorIndex returns current cursor position within the List // and also NotFocused error if the Model is not focused func (m *Model) GetCursorIndex() (int, error) { @@ -757,11 +781,6 @@ func (m *Model) GetAllItems() []fmt.Stringer { return stringerList } -// MoveByLine moves the Viewposition by one line -// not by a item -//func (m *Model) MoveByLine(amount) (ViewPos, error) { -//} - // Copy returns a deep copy of the list-model func (m *Model) Copy() *Model { copiedModel := &Model{} diff --git a/list/list_test.go b/list/list_test.go index 9b5ab8e48..2c2c1d3dd 100644 --- a/list/list_test.go +++ b/list/list_test.go @@ -71,7 +71,7 @@ func TestBasicsLines(t *testing.T) { orgList := MakeStringerList([]string{"2", "1", "3", "4", "5", "6", "7", "8", "9"}) m.AddItems(orgList) - m.Move(1) + m.MoveCursor(1) // Sort them newModel, _ := m.Update(tea.KeyMsg(tea.Key{Type: tea.KeyRunes, Runes: []rune{'s'}})) m, _ = newModel.(Model) @@ -287,7 +287,7 @@ func TestSelectKeys(t *testing.T) { } // Move back to top - m.Move(-1) + m.MoveCursor(-1) // Unmark previous marked item newModel, cmd = m.Update(tea.KeyMsg(tea.Key{Type: tea.KeyRunes, Runes: []rune{'u'}})) m, _ = newModel.(Model) @@ -335,28 +335,6 @@ func TestGetIndex(t *testing.T) { } } -// TestItemUpdater test if items get updated -func TestItemUpdater(t *testing.T) { - m := NewModel() - old := 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.AddItems(old) - m.UpdateAllItems(func(in fmt.Stringer) (fmt.Stringer, tea.Cmd) { return StringItem("-"), nil }) - for i, content := range m.GetAllItems() { - if content.String() != "-" { - t.Errorf("after Updating all items should result in string '-' but got '%s' form old item: '%s'", content.String(), old[i]) - } - } - m.Bottom() - m.ToggleSelect(-26) - m.UpdateSelectedItems(func(in fmt.Stringer) fmt.Stringer { return StringItem("_") }) - - for i, content := range m.GetAllItems() { - if content.String() != "_" { - t.Errorf("after Updating selected (all) items should result in string '_' but got '%s' form old item: '%s'", content.String(), old[i]) - } - } -} - // TestWithinBorder test if indexes are within the listborders func TestWithinBorder(t *testing.T) { m := NewModel() diff --git a/list/prefixer.go b/list/prefixer.go index db8ea0d9f..f973484f1 100644 --- a/list/prefixer.go +++ b/list/prefixer.go @@ -76,6 +76,9 @@ func (d *DefaultPrefixer) InitPrefixer(position ViewPos, screen ScreenInfo) int d.viewPos = position offset := position.Cursor - position.LineOffset + if offset < 0 { + offset = 0 + } // Get separators width widthItem := ansi.PrintableRuneWidth(d.Seperator) From e62fe466678ce2d27cfb250c9578f53b7838c26d Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Wed, 25 Nov 2020 15:16:25 +0100 Subject: [PATCH 58/73] Changed Wrap from bool to int and handling of empty suffix - changed Wrap field of the list struct from bool to int, to limit the amount of wrapped lines per item. - changed example strings, keybindings and tests accordingly - changed suffix handling to not pad empty suffixes to content-width so one has to provide a space when all lines should be of lenght content-width. --- list/example/main.go | 10 ++++++++-- list/item.go | 4 ++-- list/list.go | 20 ++++++++++++-------- list/list_test.go | 4 ++-- list/suffixer.go | 12 ++++++------ 5 files changed, 30 insertions(+), 20 deletions(-) diff --git a/list/example/main.go b/list/example/main.go index 1905b6775..44a733a8f 100644 --- a/list/example/main.go +++ b/list/example/main.go @@ -140,7 +140,7 @@ func main() { "", "Settings:", "To toggle between only absolute item numbers and relative numbers use the 'r' key.", - "To toggle the line wrapping of items with more lines, use the 'w' 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.", "", "Select:", "To select a item use 'm'\nor to select all items 'M'.", @@ -398,7 +398,13 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // f.WriteString(strings.Join(m.lastViews, "\n##########################\n")) // return m, tea.Quit case "w": - m.list.Wrap = !m.list.Wrap + 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() } diff --git a/list/item.go b/list/item.go index 3ae2688d3..6e5eaccf1 100644 --- a/list/item.go +++ b/list/item.go @@ -26,8 +26,8 @@ func (m *Model) itemLines(i item) []string { 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 { - return []string{lines[0]} + if m.Wrap != 0 && len(lines) > m.Wrap { + return lines[:m.Wrap] } return lines } diff --git a/list/list.go b/list/list.go index 37a6fb8bb..60ed1287e 100644 --- a/list/list.go +++ b/list/list.go @@ -23,7 +23,8 @@ type Model struct { Screen ScreenInfo viewPos ViewPos - Wrap bool + // Wrap changes the number of lines which get displayed. 0 means unlimited lines. + Wrap int PrefixGen Prefixer SuffixGen Suffixer @@ -52,8 +53,8 @@ func NewModel() Model { CursorOffset: 5, viewPos: ViewPos{LineOffset: 5}, - // Wrap lines to have no loss of information - Wrap: true, + // show all lines + Wrap: 0, SelectedStyle: selStyle, CurrentStyle: curStyle, @@ -121,11 +122,14 @@ func (m *Model) Lines() []string { linePrefix = m.PrefixGen.Prefix(index, c, item.selected) } if m.SuffixGen != nil { - free := contentWidth - ansi.PrintableRuneWidth(lineContent) - if free < 0 { - free = 0 // TODO is this nessecary after adding hardwrap? + lineSuffix = m.SuffixGen.Suffix(index, c, item.selected) + if lineSuffix != "" { + free := contentWidth - ansi.PrintableRuneWidth(lineContent) + if free < 0 { + free = 0 // TODO is this nessecary after adding hardwrap? + } + lineSuffix = fmt.Sprintf("%s%s", strings.Repeat(" ", free), lineSuffix) } - lineSuffix = fmt.Sprintf("%s%s", strings.Repeat(" ", free), m.SuffixGen.Suffix(index, c, item.selected)) } // Join all @@ -341,7 +345,7 @@ func (m *Model) validOffset(newCursor int) (int, error) { } newOffset := m.viewPos.LineOffset + amount - if m.Wrap { + if m.Wrap == 0 || m.Wrap > 1 { // assume down (positiv) movement start := 0 stop := amount - 1 // exclude target item (-lines) diff --git a/list/list_test.go b/list/list_test.go index 2c2c1d3dd..b4733a549 100644 --- a/list/list_test.go +++ b/list/list_test.go @@ -60,7 +60,7 @@ func TestBasicsLines(t *testing.T) { m.PrefixGen = NewPrefixer() m.SuffixGen = NewSuffixer() - m.Wrap = false + m.Wrap = 1 // Check Cursor position if i, err := m.GetCursorIndex(); i != 0 || err == nil { @@ -174,7 +174,7 @@ func TestUpdateKeys(t *testing.T) { // Movements func TestMovementKeys(t *testing.T) { m := NewModel() - m.Wrap = false + 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"})) diff --git a/list/suffixer.go b/list/suffixer.go index dc3e6ff68..0b6129308 100644 --- a/list/suffixer.go +++ b/list/suffixer.go @@ -1,13 +1,11 @@ package list import ( - "strings" - "github.com/muesli/reflow/ansi" ) // Suffixer is used to suffix all visible Lines. -// InitSuffixer gets called ones on the beginning of the Lines methode +// 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(ViewPos, ScreenInfo) int @@ -16,7 +14,7 @@ type Suffixer interface { // 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 horizontaly joined with other strings/Views. +// So that it can be horizontally joined with other strings/Views. type DefaultSuffixer struct { viewPos ViewPos currentMarker string @@ -28,7 +26,7 @@ func NewSuffixer() *DefaultSuffixer { return &DefaultSuffixer{currentMarker: "<"} } -// InitSuffixer returns the visble Width of the strings used to suffix the lines +// InitSuffixer returns the visible Width of the strings used to suffix the lines func (e *DefaultSuffixer) InitSuffixer(viewPos ViewPos, screen ScreenInfo) int { e.viewPos = viewPos e.markerLenght = ansi.PrintableRuneWidth(e.currentMarker) @@ -40,5 +38,7 @@ func (e *DefaultSuffixer) Suffix(item, line int, selected bool) string { if item == e.viewPos.Cursor && line == 0 { return e.currentMarker } - return strings.Repeat(" ", e.markerLenght) + // 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 "" } From 00e1716bbe914c906ce1a96321c58dcdcd5f44bd Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Mon, 30 Nov 2020 16:25:58 +0100 Subject: [PATCH 59/73] fixed lineOffset bug and made list output fill the screen - fixed lineOffset bug, to use right amount of lines. - added config fields to list struct to make the List output fill the screen area, for easier horizontal/vertical combination of bubbles. --- list/list.go | 50 +++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/list/list.go b/list/list.go index 60ed1287e..95105924a 100644 --- a/list/list.go +++ b/list/list.go @@ -18,7 +18,8 @@ type Model struct { 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 - CursorOffset int // offset or margin between the cursor and the viewport(visible) border + // offset or margin between the cursor and the viewport(visible) border + CursorOffset int Screen ScreenInfo viewPos ViewPos @@ -36,6 +37,10 @@ type Model struct { // Channels to create unique ids for all added/new items requestID chan<- struct{} resultID <-chan int + + // if view output should be extended to fit the according space + FillHeight bool + FillWidth bool } // NewModel returns a Model with some save/sane defaults @@ -69,12 +74,15 @@ func (m Model) Init() tea.Cmd { // View renders the List to a (displayable) string // since a empty string gets not displayed return something to overwrite the last removed item func (m Model) View() string { - lines := strings.Join(m.Lines(), "\n") - if lines == "" { + + lines := m.Lines() + + if m.Len() == 0 { // TODO make empty string handling better, custom empty function? - return "empty" + lines[0] = "empty" } - return lines + + return strings.Join(lines, "\n") } // Lines returns the Visible lines of the list items @@ -193,6 +201,34 @@ func (m *Model) Lines() []string { visLines++ } } + // If set, fill up the remaining space + var rest []string + var lineFill string + + if m.FillWidth { + for i, ln := range allLines { + free := m.Screen.Width - ansi.PrintableRuneWidth(ln) + if free < 0 { + free = 0 // TODO log error + } + allLines[i] = ln + strings.Repeat(" ", free) + } + lineFill = strings.Repeat(" ", m.Screen.Width) + } + if m.FillHeight && len(allLines) < m.Screen.Height { + free := m.Screen.Height - len(allLines) + if free < 0 { + free = 0 // TODO log error + } + rest = make([]string, free) + if lineFill != "" { + for i := range rest { + rest[i] = lineFill + } + } + return append(allLines, rest...) + } + return allLines } @@ -345,7 +381,7 @@ func (m *Model) validOffset(newCursor int) (int, error) { } newOffset := m.viewPos.LineOffset + amount - if m.Wrap == 0 || m.Wrap > 1 { + if m.Wrap != 1 { // assume down (positiv) movement start := 0 stop := amount - 1 // exclude target item (-lines) @@ -359,7 +395,7 @@ func (m *Model) validOffset(newCursor int) (int, error) { var lineSum int for i := start; i <= stop; i++ { - lineSum += strings.Count(m.listItems[m.viewPos.Cursor+i*d].value.String(), "\n") + 1 + lineSum += len(m.itemLines(m.listItems[m.viewPos.Cursor+i*d])) } newOffset = m.viewPos.LineOffset + lineSum*d } From 34a1143ba8949eb3e3eca8bedf0c92a0a49797af Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Thu, 3 Dec 2020 23:06:03 +0100 Subject: [PATCH 60/73] Renamed methods and added Method to change selected state from item without effecting the cursor. - added shortcut Method to get item under cursor - removed redundant key binding from example - and corrected return type to NoItems error --- list/example/main.go | 9 ++---- list/list.go | 68 +++++++++++++++++++++++++++++++++----------- list/list_test.go | 32 ++++++++++----------- 3 files changed, 71 insertions(+), 38 deletions(-) diff --git a/list/example/main.go b/list/example/main.go index 44a733a8f..48924586d 100644 --- a/list/example/main.go +++ b/list/example/main.go @@ -295,9 +295,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil - case "c": - m.list.MoveCursor(1) - return m, nil case "q": return m, tea.Quit case "1", "2", "3", "4", "5", "6", "7", "8", "9", "0": @@ -331,7 +328,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { j, _ = strconv.Atoi(m.jump) m.jump = "" } - m.list.MarkSelected(j, true) + m.list.MarkSelectCursor(j, true) return m, nil case "u": j := 1 @@ -339,7 +336,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { j, _ = strconv.Atoi(m.jump) m.jump = "" } - m.list.MarkSelected(j, false) + m.list.MarkSelectCursor(j, false) return m, nil case " ": j := 1 @@ -347,7 +344,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { j, _ = strconv.Atoi(m.jump) m.jump = "" } - m.list.ToggleSelect(j) + m.list.ToggleSelectCursor(j) return m, nil case "J": j := 1 diff --git a/list/list.go b/list/list.go index 95105924a..6c37ba0e5 100644 --- a/list/list.go +++ b/list/list.go @@ -278,23 +278,23 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Select case " ": - m.ToggleSelect(1) + m.ToggleSelectCursor(1) m.MoveCursor(1) return m, nil case "v": // inVert m.ToggleAllSelected() return m, nil case "m": // mark - m.MarkSelected(1, true) + m.MarkSelectCursor(1, true) return m, nil case "M": // mark All - m.MarkAllSelected(true) + m.MarkSelectAll(true) return m, nil case "u": // unmark - m.MarkSelected(1, false) + m.MarkSelectCursor(1, false) return m, nil case "U": // unmark All - m.MarkAllSelected(false) + m.MarkSelectAll(false) return m, nil // Order changing @@ -505,12 +505,12 @@ func (m *Model) RemoveIndex(index int) (fmt.Stringer, error) { return itemValue, err } -// ToggleSelect toggles the selected status +// ToggleSelectCursor toggles the selected status // of the current Index if amount is 0 // returns err != nil when amount lands outside list and safely does nothing // else if amount is not 0 toggles selected amount items // excluding the item on which the cursor would land -func (m *Model) ToggleSelect(amount int) error { +func (m *Model) ToggleSelectCursor(amount int) error { if m.Len() == 0 { return OutOfBounds(fmt.Errorf("No Items")) } @@ -544,11 +544,22 @@ func (m *Model) ToggleSelect(amount int) error { return err } -// MarkSelected selects or unselects depending on 'mark' +// ToggleSelect swaps the selected state of the item at the given index +// or returns a error if index is OutOfBounds. +func (m *Model) ToggleSelect(index int) error { + i, err := m.ValidIndex(index) + if err != nil { + return err + } + m.listItems[i].selected = !m.listItems[i].selected + return nil +} + +// MarkSelectCursor selects or unselects depending on 'mark' // amount = 0 changes the current item but does not move the cursor // if amount would be outside the list error is from type OutOfBounds // else all items till but excluding the end cursor position gets (un-)marked -func (m *Model) MarkSelected(amount int, mark bool) error { +func (m *Model) MarkSelectCursor(amount int, mark bool) error { cur := m.viewPos.Cursor direction := 1 if amount < 0 { @@ -578,9 +589,20 @@ func (m *Model) MarkSelected(amount int, mark bool) error { return err } -// MarkAllSelected marks all items of the list according to mark +// MarkSelect sets the selected state of the item at the given index to true +// or returns a error if index is OutOfBounds. +func (m *Model) MarkSelect(index int, mark bool) error { + i, err := m.ValidIndex(index) + if err != nil { + return err + } + m.listItems[i].selected = mark + return nil +} + +// MarkSelectAll marks all items of the list according to mark // or returns OutOfBounds if list has no Items -func (m *Model) MarkAllSelected(mark bool) error { +func (m *Model) MarkSelectAll(mark bool) error { _, err := m.ValidIndex(0) if m.Len() == 0 { return err @@ -614,9 +636,9 @@ func (m *Model) IsSelected(index int) (bool, error) { return m.listItems[index].selected, err } -// GetSelected returns you a list of all items +// GetAllSelected returns you a list of all items // that are selected in current (displayed) order -func (m *Model) GetSelected() []fmt.Stringer { +func (m *Model) GetAllSelected() []fmt.Stringer { var selected []fmt.Stringer for _, item := range m.listItems { if item.selected { @@ -789,11 +811,12 @@ func (m *Model) UpdateItem(index int, updater func(fmt.Stringer) (fmt.Stringer, return cmd, err } -// GetCursorIndex returns current cursor position within the List -// and also NotFocused error if the Model is not focused +// GetCursorIndex returns the current cursor position +// within the List and also NotFocused error 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, error) { if m.Len() == 0 { - return 0, OutOfBounds(fmt.Errorf("No Items")) + return 0, NoItems(fmt.Errorf("the list has no items on which the cursor could be")) } if !m.focus { return m.viewPos.Cursor, NotFocused(fmt.Errorf("Model is not focused")) @@ -801,6 +824,19 @@ func (m *Model) GetCursorIndex() (int, error) { return m.viewPos.Cursor, nil } +// GetCursorItem returns the item at the current cursor position +// within the List and also NotFocused error 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, error) { + if m.Len() == 0 { + return nil, NoItems(fmt.Errorf("the list has no items on which the cursor could be")) + } + if !m.focus { + return m.listItems[m.viewPos.Cursor].value, NotFocused(fmt.Errorf("Model is not focused")) + } + return m.listItems[m.viewPos.Cursor].value, nil +} + // GetItem returns the item if the index exists // OutOfBounds otherwise func (m *Model) GetItem(index int) (fmt.Stringer, error) { diff --git a/list/list_test.go b/list/list_test.go index b4733a549..75f7a63cd 100644 --- a/list/list_test.go +++ b/list/list_test.go @@ -143,7 +143,7 @@ func TestMultiLineBreaks(t *testing.T) { 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"})) - m.MarkSelected(0, true) + m.MarkSelectCursor(0, true) out := m.Lines() prefix := "\x1b[7m 1*╭>" for i, line := range out { @@ -259,8 +259,8 @@ func TestSelectKeys(t *testing.T) { // Mark one and move one down newModel, cmd := m.Update(tea.KeyMsg(tea.Key{Type: tea.KeyRunes, Runes: []rune{' '}})) m, _ = newModel.(Model) - if len(m.GetSelected()) != 1 { - t.Errorf("key ' ' should mark exactly one items as marked not: '%d'", len(m.GetSelected())) + if len(m.GetAllSelected()) != 1 { + t.Errorf("key ' ' should mark exactly one items as marked not: '%d'", len(m.GetAllSelected())) } if sel, _ := m.IsSelected(0); !sel || cmd != nil { t.Errorf("key ' ' should mark the current Index, but did not or command was not nil: %#v", cmd) @@ -269,8 +269,8 @@ func TestSelectKeys(t *testing.T) { // invert all mark stats newModel, cmd = m.Update(tea.KeyMsg(tea.Key{Type: tea.KeyRunes, Runes: []rune{'v'}})) m, _ = newModel.(Model) - if len(m.GetSelected()) != m.Len()-1 { - t.Errorf("All items but one should be marked but '%d' from '%d' are marked", len(m.GetSelected()), m.Len()) + if len(m.GetAllSelected()) != m.Len()-1 { + t.Errorf("All items but one should be marked but '%d' from '%d' are marked", len(m.GetAllSelected()), m.Len()) } // deselect all and move to top @@ -279,8 +279,8 @@ func TestSelectKeys(t *testing.T) { // mark the first item newModel, cmd = m.Update(tea.KeyMsg(tea.Key{Type: tea.KeyRunes, Runes: []rune{'m'}})) m, _ = newModel.(Model) - if len(m.GetSelected()) != 1 { - t.Errorf("key 'm' should mark exactly one items as marked not: '%d'", len(m.GetSelected())) + if len(m.GetAllSelected()) != 1 { + t.Errorf("key 'm' should mark exactly one items as marked not: '%d'", len(m.GetAllSelected())) } if sel, _ := m.IsSelected(0); !sel || cmd != nil { t.Errorf("key 'm' should mark the current Index, but did not or command was not nil: %#v", cmd) @@ -291,8 +291,8 @@ func TestSelectKeys(t *testing.T) { // Unmark previous marked item newModel, cmd = m.Update(tea.KeyMsg(tea.Key{Type: tea.KeyRunes, Runes: []rune{'u'}})) m, _ = newModel.(Model) - if len(m.GetSelected()) != 0 { - t.Errorf("no selected items should be left, but '%d' are", len(m.GetSelected())) + if len(m.GetAllSelected()) != 0 { + t.Errorf("no selected items should be left, but '%d' are", len(m.GetAllSelected())) } } @@ -420,31 +420,31 @@ func TestSetCursor(t *testing.T) { // TestSelectFunctions test if the function that handel the selected state of items work proper func TestSelectFunctions(t *testing.T) { m := NewModel() - err1 := m.ToggleSelect(-1) - err2 := m.MarkSelected(-1, true) + err1 := m.ToggleSelectCursor(-1) + err2 := m.MarkSelectCursor(-1, true) if err1 == nil || err2 == nil { t.Error("cant toggle no items") } m.AddItems(MakeStringerList([]string{""})) - err3 := m.ToggleSelect(0) + err3 := m.ToggleSelectCursor(0) if ok, err4 := m.IsSelected(0); !ok || err3 != nil || err4 != nil { t.Errorf("Item should be selected after toggle or no error should be returned: '%#v' or '%#v'", err3, err4) } - err5 := m.MarkSelected(-1, false) + err5 := m.MarkSelectCursor(-1, false) sel, err6 := m.IsSelected(0) if err5 == nil || err6 != nil || sel { t.Errorf("Item should not be selected after marking it false, error should be not nil: '%#v' and other error should be be nil '%#v'", err5, err6) } - err7 := m.MarkSelected(m.Len()+1, false) + err7 := m.MarkSelectCursor(m.Len()+1, false) if err7 == nil { - t.Error("MarkSelected should fail if position is beyond list end") + t.Error("MarkSelectCursor should fail if position is beyond list end") } _, err8 := m.IsSelected(m.Len()) if err8 == nil { t.Error("error Should not be nil after trying to check selected state beyond list end") } m.viewPos.Cursor = m.Len() - 1 - err9 := m.ToggleSelect(1) + err9 := m.ToggleSelectCursor(1) sel, _ = m.IsSelected(m.Len() - 1) if _, ok := err9.(OutOfBounds); !ok || !sel { t.Errorf("marking the last item should give a OutOfBounds error, but got: '%s'\nand after it, it should be marked: '%t'", err9, sel) From 5a5e2150424409bed4629979b9804cd1b56739cc Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Fri, 4 Dec 2020 18:29:26 +0100 Subject: [PATCH 61/73] Move select-methods to own file to delete them A selected field is better fitting on the value side from the item, so the user has to implement and handel it if nesseccary. - removed most of the keybindings from the Update methode, except basic movment. To prevent keybinding confusion. - changed empty Lines() handling to not panic. - changed Top and Bottom Methods to have error in case of empty list. - implemented ResetItems to fully replace all items within the list. - removed UnFocus Methode and changed Focus Method to take a argument. --- list/list.go | 249 ++++++++++------------------------------------ list/list_test.go | 88 +++++++--------- list/select.go | 146 +++++++++++++++++++++++++++ 3 files changed, 235 insertions(+), 248 deletions(-) create mode 100644 list/select.go diff --git a/list/list.go b/list/list.go index 6c37ba0e5..0b26d681c 100644 --- a/list/list.go +++ b/list/list.go @@ -72,14 +72,18 @@ func (m Model) Init() tea.Cmd { } // View renders the List to a (displayable) string -// since a empty string gets not displayed return something to overwrite the last removed item +// since a empty string gets not displayed, return something to overwrite the last removed item func (m Model) View() string { lines := m.Lines() if m.Len() == 0 { // TODO make empty string handling better, custom empty function? - lines[0] = "empty" + if len(lines) > 0 { + lines[0] = "empty" + } else { + lines = []string{"empty"} + } } return strings.Join(lines, "\n") @@ -253,54 +257,19 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Quit } switch msg.String() { - case "q": - return m, tea.Quit - // Move - case "down", "j": + case "down": m.MoveCursor(1) return m, nil - case "up", "k": + case "up": m.MoveCursor(-1) return m, nil - case "t", "home": + case "home": m.Top() return m, nil - case "b", "end": + case "end": m.Bottom() return m, nil - case "K": - m.MoveItem(-1) - return m, nil - case "J": - m.MoveItem(1) - return m, nil - - // Select - case " ": - m.ToggleSelectCursor(1) - m.MoveCursor(1) - return m, nil - case "v": // inVert - m.ToggleAllSelected() - return m, nil - case "m": // mark - m.MarkSelectCursor(1, true) - return m, nil - case "M": // mark All - m.MarkSelectAll(true) - return m, nil - case "u": // unmark - m.MarkSelectCursor(1, false) - return m, nil - case "U": // unmark All - m.MarkSelectAll(false) - return m, nil - - // Order changing - case "s": - m.Sort() - return m, nil } case tea.WindowSizeMsg: @@ -445,16 +414,26 @@ func (m *Model) SetCursor(target int) (int, error) { } // Top moves the cursor to the first line -func (m *Model) Top() { +func (m *Model) Top() error { + _, err := m.ValidIndex(0) + if err != nil { + return err + } m.viewPos.Cursor = 0 m.viewPos.LineOffset = m.CursorOffset + return nil } // Bottom moves the cursor to the last line -func (m *Model) Bottom() { +func (m *Model) Bottom() error { end := len(m.listItems) - 1 + _, err := m.ValidIndex(end) + if err != nil { + return err + } m.viewPos.LineOffset = m.Screen.Height - m.CursorOffset m.MoveCursor(end) + return nil } // AddItems adds the given Items to the list Model @@ -484,6 +463,30 @@ func (m *Model) AddItems(itemList []fmt.Stringer) error { return err } +// ResetItems replaces all list items with the new items, +// if equals function is set and a new item yields true +// cursor is set on this item. +func (m *Model) ResetItems(newStringers []fmt.Stringer) error { + oldCursorItem, err := m.GetCursorItem() + // Reset Cursor + m.viewPos.Cursor = 0 + + newItems := make([]item, len(newStringers)) + for i, newValue := range newStringers { + newItems[i].value = newValue + newItems[i].id = m.getID() + + if m.equals != nil && err != nil && m.equals(oldCursorItem, newValue) { + m.viewPos.Cursor = i + } + } + // reset LineOffset if Cursor was not set by matching through equals + if m.viewPos.Cursor == 0 { + m.viewPos.LineOffset = m.CursorOffset + } + return nil +} + // RemoveIndex returns a error if the index is not valid, // and if valid, returns the item while removing it from the list. func (m *Model) RemoveIndex(index int) (fmt.Stringer, error) { @@ -505,149 +508,6 @@ func (m *Model) RemoveIndex(index int) (fmt.Stringer, error) { return itemValue, err } -// ToggleSelectCursor toggles the selected status -// of the current Index if amount is 0 -// returns err != nil when amount lands outside list and safely does nothing -// else if amount is not 0 toggles selected amount items -// excluding the item on which the cursor would land -func (m *Model) ToggleSelectCursor(amount int) error { - if m.Len() == 0 { - return OutOfBounds(fmt.Errorf("No Items")) - } - if amount == 0 { - m.listItems[m.viewPos.Cursor].selected = !m.listItems[m.viewPos.Cursor].selected - } - - direction := 1 - if amount < 0 { - direction = -1 - } - - cur := m.viewPos.Cursor - - target, err := m.MoveCursor(amount) - start, end := cur, target - if direction < 0 { - start, end = target+1, cur+1 - } - // mark/start at first item - if cur+amount < 0 { - start = 0 - } - // mark last item when trying to go beyond list - if cur+amount >= m.Len() { - end++ - } - for c := start; c < end; c++ { - m.listItems[c].selected = !m.listItems[c].selected - } - return err -} - -// ToggleSelect swaps the selected state of the item at the given index -// or returns a error if index is OutOfBounds. -func (m *Model) ToggleSelect(index int) error { - i, err := m.ValidIndex(index) - if err != nil { - return err - } - m.listItems[i].selected = !m.listItems[i].selected - return nil -} - -// MarkSelectCursor selects or unselects depending on 'mark' -// amount = 0 changes the current item but does not move the cursor -// if amount would be outside the list error is from type OutOfBounds -// else all items till but excluding the end cursor position gets (un-)marked -func (m *Model) MarkSelectCursor(amount int, mark bool) error { - cur := m.viewPos.Cursor - direction := 1 - if amount < 0 { - direction = -1 - } - target := cur + amount - direction - - target, err := m.ValidIndex(target) - if m.Len() == 0 { - return err - } - // correct amount in case target has changed - amount = target - cur + direction - - if amount == 0 { - m.listItems[cur].selected = mark - return nil - } - for c := 0; c < amount*direction; c++ { - m.listItems[cur+c].selected = mark - } - m.viewPos.Cursor = target - _, errSec := m.MoveCursor(direction) - if err == nil { - err = errSec - } - return err -} - -// MarkSelect sets the selected state of the item at the given index to true -// or returns a error if index is OutOfBounds. -func (m *Model) MarkSelect(index int, mark bool) error { - i, err := m.ValidIndex(index) - if err != nil { - return err - } - m.listItems[i].selected = mark - return nil -} - -// MarkSelectAll marks all items of the list according to mark -// or returns OutOfBounds if list has no Items -func (m *Model) MarkSelectAll(mark bool) error { - _, err := m.ValidIndex(0) - if m.Len() == 0 { - return err - } - for c := range m.listItems { - m.listItems[c].selected = mark - } - return err -} - -// ToggleAllSelected inverts the select state of ALL items -func (m *Model) ToggleAllSelected() error { - _, err := m.ValidIndex(0) - if m.Len() == 0 { - return err - } - for i := range m.listItems { - m.listItems[i].selected = !m.listItems[i].selected - } - return err -} - -// IsSelected returns true if the given Item is selected -// false otherwise. If the requested index is outside the list -// error is not nil. -func (m *Model) IsSelected(index int) (bool, error) { - index, err := m.ValidIndex(index) - if m.Len() == 0 { - return false, err - } - return m.listItems[index].selected, err -} - -// GetAllSelected returns you a list of all items -// that are selected in current (displayed) order -func (m *Model) GetAllSelected() []fmt.Stringer { - var selected []fmt.Stringer - for _, item := range m.listItems { - if item.selected { - selected = append(selected, item.value) - } - } - return selected -} - // Sort sorts the list items according to the set less-function // If its not set than order after string. func (m *Model) Sort() { @@ -746,14 +606,10 @@ func (m *Model) MoveItem(amount int) error { return nil } -// Focus sets the list Model focus so it accepts key input and responds to them -func (m *Model) Focus() { - m.focus = true -} - -// UnFocus removes the focus so that the list Model does NOT respond to key presses -func (m *Model) UnFocus() { - m.focus = false +// 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 @@ -793,7 +649,7 @@ func (m *Model) GetIndex(toSearch fmt.Stringer) (int, error) { return lastIndex, nil } -// UpdateItem takes a indes and updates the item at the index with the given function +// 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 and a NilValue error is returned. func (m *Model) UpdateItem(index int, updater func(fmt.Stringer) (fmt.Stringer, tea.Cmd)) (tea.Cmd, error) { @@ -838,7 +694,7 @@ func (m *Model) GetCursorItem() (fmt.Stringer, error) { } // GetItem returns the item if the index exists -// OutOfBounds otherwise +// a error otherwise. func (m *Model) GetItem(index int) (fmt.Stringer, error) { index, err := m.ValidIndex(index) if m.Len() == 0 { @@ -865,6 +721,7 @@ func (m *Model) Copy() *Model { } // 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{}) diff --git a/list/list_test.go b/list/list_test.go index 75f7a63cd..b5e8ca607 100644 --- a/list/list_test.go +++ b/list/list_test.go @@ -73,8 +73,7 @@ func TestBasicsLines(t *testing.T) { m.MoveCursor(1) // Sort them - newModel, _ := m.Update(tea.KeyMsg(tea.Key{Type: tea.KeyRunes, Runes: []rune{'s'}})) - m, _ = newModel.(Model) + m.Sort() // swap them again m.MoveItem(1) // should be the like the beginning @@ -154,7 +153,7 @@ func TestMultiLineBreaks(t *testing.T) { } } -// TestUpdateKeys test if the key send to the Update function work properly +// 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} @@ -164,11 +163,6 @@ func TestUpdateKeys(t *testing.T) { if cmd() != tea.Quit() { t.Errorf("ctrl-c should result in Quit message, not into: %#v", cmd) } - - _, cmd = m.Update(tea.KeyMsg(tea.Key{Type: tea.KeyRunes, Runes: []rune{'q'}})) - if cmd() != tea.Quit() { - t.Errorf("'q' should result in Quit message, not into: %#v", cmd) - } } // Movements @@ -179,53 +173,47 @@ func TestMovementKeys(t *testing.T) { 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 - newModel, cmd := m.Update(tea.KeyMsg(tea.Key{Type: tea.KeyRunes, Runes: []rune{'j'}})) - m, _ = newModel.(Model) - if m.viewPos.Cursor != finish || cmd != nil { - t.Errorf("key 'j' should have nil command but got: '%#v' and move the Cursor to index '%d', but got: %d", cmd, finish, m.viewPos.Cursor) + _, err := m.MoveCursor(1) + 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 - newModel, cmd = m.Update(tea.KeyMsg(tea.Key{Type: tea.KeyRunes, Runes: []rune{'k'}})) - m, _ = newModel.(Model) - if m.viewPos.Cursor != finish || cmd != nil { - t.Errorf("key 'k' should have nil command but got: '%#v' and move the Cursor to index '%d', but got: %d", cmd, finish, m.viewPos.Cursor) + _, err = m.MoveCursor(-1) + 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 - newModel, cmd = m.Update(tea.KeyMsg(tea.Key{Type: tea.KeyRunes, Runes: []rune{'J'}})) - m, _ = newModel.(Model) - if m.viewPos.Cursor != finish || cmd != nil { - t.Errorf("key 'J' should have nil command but got: '%#v' and move the Cursor to index '%d', but got: %d", cmd, finish, m.viewPos.Cursor) + err = m.MoveItem(1) + 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 - newModel, cmd = m.Update(tea.KeyMsg(tea.Key{Type: tea.KeyRunes, Runes: []rune{'K'}})) - m, _ = newModel.(Model) - if m.viewPos.Cursor != finish || cmd != nil { - t.Errorf("key 'K' should have nil command but got: '%#v' and move the Cursor to index '%d', but got: %d", cmd, finish, m.viewPos.Cursor) + err = m.MoveItem(-1) + 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 - newModel, cmd = m.Update(tea.KeyMsg(tea.Key{Type: tea.KeyRunes, Runes: []rune{'b'}})) - m, _ = newModel.(Model) - if m.viewPos.Cursor != finish || cmd != nil { - t.Errorf("key 'b' should have nil command but got: '%#v' and move the Cursor to last index: '%d', but got: %d", cmd, m.Len()-1, m.viewPos.Cursor) + err = m.Bottom() + 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 - newModel, cmd = m.Update(tea.KeyMsg(tea.Key{Type: tea.KeyRunes, Runes: []rune{'t'}})) - m, _ = newModel.(Model) - if m.viewPos.Cursor != finish || cmd != nil { - t.Errorf("key 't' should have nil command but got: '%#v' and move the Cursor to index '%d', but got: %d", cmd, finish, m.viewPos.Cursor) + err = m.Top() + 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) } - m.SetCursor(10) - if m.viewPos.Cursor != 10 { - t.Errorf("SetCursor should set the cursor to index '10' but gut '%d'", m.viewPos.Cursor) + _, err = m.SetCursor(10) + if m.viewPos.Cursor != 10 || 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) } } @@ -257,18 +245,16 @@ func TestSelectKeys(t *testing.T) { m.AddItems(MakeStringerList([]string{"\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n", "\n"})) // Mark one and move one down - newModel, cmd := m.Update(tea.KeyMsg(tea.Key{Type: tea.KeyRunes, Runes: []rune{' '}})) - m, _ = newModel.(Model) + err := m.ToggleSelectCursor(1) if len(m.GetAllSelected()) != 1 { - t.Errorf("key ' ' should mark exactly one items as marked not: '%d'", len(m.GetAllSelected())) + t.Errorf("ToggleSelectCursor(1) should mark exactly one items as marked not: '%d'", len(m.GetAllSelected())) } - if sel, _ := m.IsSelected(0); !sel || cmd != nil { - t.Errorf("key ' ' should mark the current Index, but did not or command was not nil: %#v", cmd) + if sel, _ := m.IsSelected(0); !sel || err != nil { + t.Errorf("ToggleSelectCursor(1) should mark the current Index, but did not or command was not nil: %#v", err) } // invert all mark stats - newModel, cmd = m.Update(tea.KeyMsg(tea.Key{Type: tea.KeyRunes, Runes: []rune{'v'}})) - m, _ = newModel.(Model) + m.ToggleAllSelected() if len(m.GetAllSelected()) != m.Len()-1 { t.Errorf("All items but one should be marked but '%d' from '%d' are marked", len(m.GetAllSelected()), m.Len()) } @@ -277,20 +263,18 @@ func TestSelectKeys(t *testing.T) { m.ToggleAllSelected() m.Top() // mark the first item - newModel, cmd = m.Update(tea.KeyMsg(tea.Key{Type: tea.KeyRunes, Runes: []rune{'m'}})) - m, _ = newModel.(Model) + err = m.MarkSelectCursor(1, true) if len(m.GetAllSelected()) != 1 { - t.Errorf("key 'm' should mark exactly one items as marked not: '%d'", len(m.GetAllSelected())) + t.Errorf("MarkSelectCursor(1, true) should mark exactly one items as marked not: '%d'", len(m.GetAllSelected())) } - if sel, _ := m.IsSelected(0); !sel || cmd != nil { - t.Errorf("key 'm' should mark the current Index, but did not or command was not nil: %#v", cmd) + if sel, _ := m.IsSelected(0); !sel || err != nil { + t.Errorf("MarkSelectCursor(1, true) should mark the current Index, but did not or error was not nil: %#v", err) } // Move back to top m.MoveCursor(-1) // Unmark previous marked item - newModel, cmd = m.Update(tea.KeyMsg(tea.Key{Type: tea.KeyRunes, Runes: []rune{'u'}})) - m, _ = newModel.(Model) + m.MarkSelectCursor(1, false) if len(m.GetAllSelected()) != 0 { t.Errorf("no selected items should be left, but '%d' are", len(m.GetAllSelected())) } @@ -299,17 +283,17 @@ func TestSelectKeys(t *testing.T) { // 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() + m.Focus(true) if !m.Focused() { t.Error("model should be focused but isn't") } - m.UnFocus() + 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{' '}})) + 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 { diff --git a/list/select.go b/list/select.go new file mode 100644 index 000000000..1689750e9 --- /dev/null +++ b/list/select.go @@ -0,0 +1,146 @@ +package list + +import "fmt" + +// ToggleSelectCursor toggles the selected status +// of the current Index if amount is 0 +// returns err != nil when amount lands outside list and safely does nothing +// else if amount is not 0 toggles selected amount items +// excluding the item on which the cursor would land +func (m *Model) ToggleSelectCursor(amount int) error { + if m.Len() == 0 { + return OutOfBounds(fmt.Errorf("No Items")) + } + if amount == 0 { + m.listItems[m.viewPos.Cursor].selected = !m.listItems[m.viewPos.Cursor].selected + } + + direction := 1 + if amount < 0 { + direction = -1 + } + + cur := m.viewPos.Cursor + + target, err := m.MoveCursor(amount) + start, end := cur, target + if direction < 0 { + start, end = target+1, cur+1 + } + // mark/start at first item + if cur+amount < 0 { + start = 0 + } + // mark last item when trying to go beyond list + if cur+amount >= m.Len() { + end++ + } + for c := start; c < end; c++ { + m.listItems[c].selected = !m.listItems[c].selected + } + return err +} + +// ToggleSelect swaps the selected state of the item at the given index +// or returns a error if index is OutOfBounds. +func (m *Model) ToggleSelect(index int) error { + i, err := m.ValidIndex(index) + if err != nil { + return err + } + m.listItems[i].selected = !m.listItems[i].selected + return nil +} + +// MarkSelectCursor selects or unselects depending on 'mark' +// amount = 0 changes the current item but does not move the cursor +// if amount would be outside the list error is from type OutOfBounds +// else all items till but excluding the end cursor position gets (un-)marked +func (m *Model) MarkSelectCursor(amount int, mark bool) error { + cur := m.viewPos.Cursor + direction := 1 + if amount < 0 { + direction = -1 + } + target := cur + amount - direction + + target, err := m.ValidIndex(target) + if m.Len() == 0 { + return err + } + // correct amount in case target has changed + amount = target - cur + direction + + if amount == 0 { + m.listItems[cur].selected = mark + return nil + } + for c := 0; c < amount*direction; c++ { + m.listItems[cur+c].selected = mark + } + m.viewPos.Cursor = target + _, errSec := m.MoveCursor(direction) + if err == nil { + err = errSec + } + return err +} + +// MarkSelect sets the selected state of the item at the given index to true +// or returns a error if index is OutOfBounds. +func (m *Model) MarkSelect(index int, mark bool) error { + i, err := m.ValidIndex(index) + if err != nil { + return err + } + m.listItems[i].selected = mark + return nil +} + +// MarkSelectAll marks all items of the list according to mark +// or returns OutOfBounds if list has no Items +func (m *Model) MarkSelectAll(mark bool) error { + _, err := m.ValidIndex(0) + if m.Len() == 0 { + return err + } + for c := range m.listItems { + m.listItems[c].selected = mark + } + return err +} + +// ToggleAllSelected inverts the select state of ALL items +func (m *Model) ToggleAllSelected() error { + _, err := m.ValidIndex(0) + if m.Len() == 0 { + return err + } + for i := range m.listItems { + m.listItems[i].selected = !m.listItems[i].selected + } + return err +} + +// IsSelected returns true if the given Item is selected +// false otherwise. If the requested index is outside the list +// error is not nil. +func (m *Model) IsSelected(index int) (bool, error) { + index, err := m.ValidIndex(index) + if m.Len() == 0 { + return false, err + } + return m.listItems[index].selected, err +} + +// GetAllSelected returns you a list of all items +// that are selected in current (displayed) order +func (m *Model) GetAllSelected() []fmt.Stringer { + var selected []fmt.Stringer + for _, item := range m.listItems { + if item.selected { + selected = append(selected, item.value) + } + } + return selected +} From c91d0867efde95e8e4646ff23d3813c18db446fa Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Fri, 4 Dec 2020 20:29:45 +0100 Subject: [PATCH 62/73] removed select field from list item struct The selected state is unnecessary within the item struct, since its is through the fmt.Stringer interface item value easy to implement it by one self. --- list/example/main.go | 30 --------- list/item.go | 5 +- list/list.go | 36 ++++------- list/list_test.go | 86 ++----------------------- list/prefixer.go | 53 +++------------- list/select.go | 146 ------------------------------------------- list/suffixer.go | 5 +- 7 files changed, 27 insertions(+), 334 deletions(-) delete mode 100644 list/select.go diff --git a/list/example/main.go b/list/example/main.go index 48924586d..e92d28ead 100644 --- a/list/example/main.go +++ b/list/example/main.go @@ -142,12 +142,6 @@ func main() { "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.", "", - "Select:", - "To select a item use 'm'\nor to select all items 'M'.", - "To unselect a item use 'u'\nor to unselect all items 'U'.", - "You can toggle the select state of the current item with the space key.", - "The key 'v' inverts the selected state of all items.", - "", "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'", @@ -322,30 +316,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { d.NumberRelative = !d.NumberRelative } return m, nil - case "m": - j := 1 - if m.jump != "" { - j, _ = strconv.Atoi(m.jump) - m.jump = "" - } - m.list.MarkSelectCursor(j, true) - return m, nil - case "u": - j := 1 - if m.jump != "" { - j, _ = strconv.Atoi(m.jump) - m.jump = "" - } - m.list.MarkSelectCursor(j, false) - return m, nil - case " ": - j := 1 - if m.jump != "" { - j, _ = strconv.Atoi(m.jump) - m.jump = "" - } - m.list.ToggleSelectCursor(j) - return m, nil case "J": j := 1 if m.jump != "" { diff --git a/list/item.go b/list/item.go index 6e5eaccf1..0db19a4bc 100644 --- a/list/item.go +++ b/list/item.go @@ -9,9 +9,8 @@ import ( // Item are Items used in the list Model // to hold the Content represented as a string type item struct { - selected bool - value fmt.Stringer - id int + value fmt.Stringer + id int } // itemLines returns the lines of the item string value wrapped to the according content-width diff --git a/list/list.go b/list/list.go index 0b26d681c..5f3336bc8 100644 --- a/list/list.go +++ b/list/list.go @@ -30,9 +30,8 @@ type Model struct { PrefixGen Prefixer SuffixGen Suffixer - LineStyle termenv.Style - SelectedStyle termenv.Style - CurrentStyle termenv.Style + LineStyle termenv.Style + CurrentStyle termenv.Style // Channels to create unique ids for all added/new items requestID chan<- struct{} @@ -46,8 +45,6 @@ type Model struct { // NewModel returns a Model with some save/sane defaults // design to transfer as much internal information to the user func NewModel() Model { - p := termenv.ColorProfile() - selStyle := termenv.Style{}.Background(p.Color("#ff0000")) // just reverse colors to keep there information curStyle := termenv.Style{}.Reverse() return Model{ @@ -61,8 +58,7 @@ func NewModel() Model { // show all lines Wrap: 0, - SelectedStyle: selStyle, - CurrentStyle: curStyle, + CurrentStyle: curStyle, } } @@ -131,10 +127,10 @@ func (m *Model) Lines() []string { // Surrounding lineContent var linePrefix, lineSuffix string if m.PrefixGen != nil { - linePrefix = m.PrefixGen.Prefix(index, c, item.selected) + linePrefix = m.PrefixGen.Prefix(index, c, item.value) } if m.SuffixGen != nil { - lineSuffix = m.SuffixGen.Suffix(index, c, item.selected) + lineSuffix = m.SuffixGen.Suffix(index, c, item.value) if lineSuffix != "" { free := contentWidth - ansi.PrintableRuneWidth(lineContent) if free < 0 { @@ -147,14 +143,8 @@ func (m *Model) Lines() []string { // Join all line := fmt.Sprintf("%s%s%s", linePrefix, lineContent, lineSuffix) - // Highlighting of selected lines - style := m.LineStyle - if item.selected { - style = m.SelectedStyle - } - - // Highlight and write wrapped line - linesBefor = append(linesBefor, style.Styled(line)) + // Write wrapped line + linesBefor = append(linesBefor, line) } } @@ -178,14 +168,14 @@ func (m *Model) Lines() []string { // Surrounding content var linePrefix, lineSuffix string if m.PrefixGen != nil { - linePrefix = m.PrefixGen.Prefix(index, c, item.selected) + linePrefix = m.PrefixGen.Prefix(index, c, item.value) } if m.SuffixGen != nil { free := contentWidth - ansi.PrintableRuneWidth(lineContent) if free < 0 { free = 0 // TODO is this nessecary? } - lineSuffix = fmt.Sprintf("%s%s", strings.Repeat(" ", free), m.SuffixGen.Suffix(index, c, item.selected)) + lineSuffix = fmt.Sprintf("%s%s", strings.Repeat(" ", free), m.SuffixGen.Suffix(index, c, item.value)) } // Join all @@ -193,9 +183,6 @@ func (m *Model) Lines() []string { // Highlighting of selected and current lines style := m.LineStyle - if item.selected { - style = m.SelectedStyle - } if index == m.viewPos.Cursor { style = m.CurrentStyle } @@ -444,9 +431,8 @@ func (m *Model) AddItems(itemList []fmt.Stringer) error { for _, i := range itemList { if i != nil { m.listItems = append(m.listItems, item{ - selected: false, - value: i, - id: m.getID(), + value: i, + id: m.getID(), }, ) } diff --git a/list/list_test.go b/list/list_test.go index b5e8ca607..a0eb40f2a 100644 --- a/list/list_test.go +++ b/list/list_test.go @@ -101,7 +101,7 @@ func TestBasicsLines(t *testing.T) { for i, line := range out { // Check Prefixes num := fmt.Sprintf("%d", i+1) - prefix := light + strings.Repeat(" ", 2-len(num)) + num + " ╭" + cur + prefix := light + strings.Repeat(" ", 2-len(num)) + num + "╭" + 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) } @@ -126,7 +126,7 @@ func TestWrappedLines(t *testing.T) { if i%2 == 0 { num = fmt.Sprintf(" %1d", (i/2)+1) } - prefix := fmt.Sprintf("%s %s %d", num, wrap, i-1) + prefix := fmt.Sprintf("%s%s %d", num, wrap, 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) } @@ -142,14 +142,13 @@ func TestMultiLineBreaks(t *testing.T) { 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"})) - m.MarkSelectCursor(0, true) out := m.Lines() - prefix := "\x1b[7m 1*╭>" + 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 *│ " + prefix = "\x1b[7m │ " } } @@ -238,48 +237,6 @@ func TestWindowMsg(t *testing.T) { } -// TestSelectKeys test the keys that change the select status of an item(s). -func TestSelectKeys(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"})) - - // Mark one and move one down - err := m.ToggleSelectCursor(1) - if len(m.GetAllSelected()) != 1 { - t.Errorf("ToggleSelectCursor(1) should mark exactly one items as marked not: '%d'", len(m.GetAllSelected())) - } - if sel, _ := m.IsSelected(0); !sel || err != nil { - t.Errorf("ToggleSelectCursor(1) should mark the current Index, but did not or command was not nil: %#v", err) - } - - // invert all mark stats - m.ToggleAllSelected() - if len(m.GetAllSelected()) != m.Len()-1 { - t.Errorf("All items but one should be marked but '%d' from '%d' are marked", len(m.GetAllSelected()), m.Len()) - } - - // deselect all and move to top - m.ToggleAllSelected() - m.Top() - // mark the first item - err = m.MarkSelectCursor(1, true) - if len(m.GetAllSelected()) != 1 { - t.Errorf("MarkSelectCursor(1, true) should mark exactly one items as marked not: '%d'", len(m.GetAllSelected())) - } - if sel, _ := m.IsSelected(0); !sel || err != nil { - t.Errorf("MarkSelectCursor(1, true) should mark the current Index, but did not or error was not nil: %#v", err) - } - - // Move back to top - m.MoveCursor(-1) - // Unmark previous marked item - m.MarkSelectCursor(1, false) - if len(m.GetAllSelected()) != 0 { - t.Errorf("no selected items should be left, but '%d' are", len(m.GetAllSelected())) - } -} - // TestUnfocused should make sure that the update does not change anything if model is not focused func TestUnfocused(t *testing.T) { m := NewModel() @@ -357,7 +314,6 @@ func TestCopy(t *testing.T) { fmt.Sprintf("%#v", org.SuffixGen) != fmt.Sprintf("%#v", sec.SuffixGen) || fmt.Sprintf("%#v", org.LineStyle) != fmt.Sprintf("%#v", sec.LineStyle) || - fmt.Sprintf("%#v", org.SelectedStyle) != fmt.Sprintf("%#v", sec.SelectedStyle) || 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) @@ -401,40 +357,6 @@ func TestSetCursor(t *testing.T) { } } -// TestSelectFunctions test if the function that handel the selected state of items work proper -func TestSelectFunctions(t *testing.T) { - m := NewModel() - err1 := m.ToggleSelectCursor(-1) - err2 := m.MarkSelectCursor(-1, true) - if err1 == nil || err2 == nil { - t.Error("cant toggle no items") - } - m.AddItems(MakeStringerList([]string{""})) - err3 := m.ToggleSelectCursor(0) - if ok, err4 := m.IsSelected(0); !ok || err3 != nil || err4 != nil { - t.Errorf("Item should be selected after toggle or no error should be returned: '%#v' or '%#v'", err3, err4) - } - err5 := m.MarkSelectCursor(-1, false) - sel, err6 := m.IsSelected(0) - if err5 == nil || err6 != nil || sel { - t.Errorf("Item should not be selected after marking it false, error should be not nil: '%#v' and other error should be be nil '%#v'", err5, err6) - } - err7 := m.MarkSelectCursor(m.Len()+1, false) - if err7 == nil { - t.Error("MarkSelectCursor should fail if position is beyond list end") - } - _, err8 := m.IsSelected(m.Len()) - if err8 == nil { - t.Error("error Should not be nil after trying to check selected state beyond list end") - } - m.viewPos.Cursor = m.Len() - 1 - err9 := m.ToggleSelectCursor(1) - sel, _ = m.IsSelected(m.Len() - 1) - if _, ok := err9.(OutOfBounds); !ok || !sel { - t.Errorf("marking the last item should give a OutOfBounds error, but got: '%s'\nand after it, it should be marked: '%t'", err9, sel) - } -} - // TestMoveItem test wrong arguments func TestMoveItem(t *testing.T) { m := NewModel() diff --git a/list/prefixer.go b/list/prefixer.go index f973484f1..4c1e0cb6d 100644 --- a/list/prefixer.go +++ b/list/prefixer.go @@ -11,7 +11,7 @@ import ( // and then Prefix ones, per line to draw, to generate according prefixes. type Prefixer interface { InitPrefixer(ViewPos, ScreenInfo) int - Prefix(currentItem, currentLine int, selected bool) string + Prefix(currentItem, currentLine int, item fmt.Stringer) string } // DefaultPrefixer is the default struct used for Prefixing a line @@ -23,15 +23,12 @@ type DefaultPrefixer struct { SeperatorWrap string // Mark it so that even without color support all is explicit - CurrentMarker string - SelectedPrefix string + CurrentMarker string // enable Linenumber Number bool NumberRelative bool - UnSelectedPrefix string - prefixWidth int viewPos ViewPos @@ -41,12 +38,6 @@ type DefaultPrefixer struct { unmark string mark string - selectedString string - unselect string - - wrapSelectPad string - wrapUnSelePad string - sepItem string sepWrap string } @@ -61,9 +52,7 @@ func NewPrefixer() *DefaultPrefixer { SeperatorWrap: "│", // Mark it so that even without color support all is explicit - CurrentMarker: ">", - SelectedPrefix: "*", - UnSelectedPrefix: "", + CurrentMarker: ">", // enable Linenumber Number: true, @@ -95,26 +84,6 @@ func (d *DefaultPrefixer) InitPrefixer(position ViewPos, screen ScreenInfo) int d.numWidth = len(fmt.Sprintf("%d", offset+screen.Height)) // pad all prefixes to the same width for easy exchange - d.selectedString = d.SelectedPrefix - d.unselect = d.UnSelectedPrefix - selWid := ansi.PrintableRuneWidth(d.selectedString) - tmpWid := ansi.PrintableRuneWidth(d.unselect) - - selectWidth := selWid - if tmpWid > selectWidth { - selectWidth = tmpWid - } - d.selectedString = strings.Repeat(" ", selectWidth-selWid) + d.selectedString - - d.wrapSelectPad = strings.Repeat(" ", selectWidth) - d.wrapUnSelePad = strings.Repeat(" ", selectWidth) - if d.PrefixWrap { - d.wrapSelectPad = strings.Repeat(" ", selectWidth-selWid) + d.selectedString - d.wrapUnSelePad = strings.Repeat(" ", selectWidth-tmpWid) + d.unselect - } - - d.unselect = strings.Repeat(" ", selectWidth-tmpWid) + d.unselect - // pad all separators to the same width for easy exchange d.sepItem = strings.Repeat(" ", sepWidth-widthItem) + d.Seperator d.sepWrap = strings.Repeat(" ", sepWidth-widthWrap) + d.SeperatorWrap @@ -125,13 +94,13 @@ func (d *DefaultPrefixer) InitPrefixer(position ViewPos, screen ScreenInfo) int d.unmark = strings.Repeat(" ", d.markWidth) // Get the hole prefix width - d.prefixWidth = d.numWidth + selectWidth + sepWidth + d.markWidth + d.prefixWidth = d.numWidth + sepWidth + d.markWidth return d.prefixWidth } // Prefix prefixes a given line -func (d *DefaultPrefixer) Prefix(currentIndex int, wrapIndex int, selected bool) string { +func (d *DefaultPrefixer) Prefix(currentIndex int, wrapIndex int, value fmt.Stringer) string { // if a number is set, prepend first line with number and both with enough spaces firstPad := strings.Repeat(" ", d.numWidth) var wrapPad string @@ -149,14 +118,6 @@ func (d *DefaultPrefixer) Prefix(currentIndex int, wrapIndex int, selected bool) firstPad = strings.Repeat(" ", padTo) + number // pad wrapped lines wrapPad = strings.Repeat(" ", d.numWidth) - // Selecting: handle highlighting and prefixing of selected lines - selString := d.unselect - - wrapPrePad := d.wrapUnSelePad - if selected { - selString = d.selectedString - wrapPrePad = d.wrapSelectPad - } // Current: handle highlighting of current item/first-line curPad := d.unmark @@ -167,9 +128,9 @@ func (d *DefaultPrefixer) Prefix(currentIndex int, wrapIndex int, selected bool) // join all prefixes var wrapPrefix, linePrefix string - linePrefix = strings.Join([]string{firstPad, selString, d.sepItem, curPad}, "") + linePrefix = strings.Join([]string{firstPad, d.sepItem, curPad}, "") if wrapIndex > 0 { - wrapPrefix = strings.Join([]string{wrapPad, wrapPrePad, d.sepWrap, d.unmark}, "") // don't prefix wrap lines with CurrentMarker (unmark) + wrapPrefix = strings.Join([]string{wrapPad, d.sepWrap, d.unmark}, "") // don't prefix wrap lines with CurrentMarker (unmark) return wrapPrefix } diff --git a/list/select.go b/list/select.go deleted file mode 100644 index 1689750e9..000000000 --- a/list/select.go +++ /dev/null @@ -1,146 +0,0 @@ -package list - -import "fmt" - -// ToggleSelectCursor toggles the selected status -// of the current Index if amount is 0 -// returns err != nil when amount lands outside list and safely does nothing -// else if amount is not 0 toggles selected amount items -// excluding the item on which the cursor would land -func (m *Model) ToggleSelectCursor(amount int) error { - if m.Len() == 0 { - return OutOfBounds(fmt.Errorf("No Items")) - } - if amount == 0 { - m.listItems[m.viewPos.Cursor].selected = !m.listItems[m.viewPos.Cursor].selected - } - - direction := 1 - if amount < 0 { - direction = -1 - } - - cur := m.viewPos.Cursor - - target, err := m.MoveCursor(amount) - start, end := cur, target - if direction < 0 { - start, end = target+1, cur+1 - } - // mark/start at first item - if cur+amount < 0 { - start = 0 - } - // mark last item when trying to go beyond list - if cur+amount >= m.Len() { - end++ - } - for c := start; c < end; c++ { - m.listItems[c].selected = !m.listItems[c].selected - } - return err -} - -// ToggleSelect swaps the selected state of the item at the given index -// or returns a error if index is OutOfBounds. -func (m *Model) ToggleSelect(index int) error { - i, err := m.ValidIndex(index) - if err != nil { - return err - } - m.listItems[i].selected = !m.listItems[i].selected - return nil -} - -// MarkSelectCursor selects or unselects depending on 'mark' -// amount = 0 changes the current item but does not move the cursor -// if amount would be outside the list error is from type OutOfBounds -// else all items till but excluding the end cursor position gets (un-)marked -func (m *Model) MarkSelectCursor(amount int, mark bool) error { - cur := m.viewPos.Cursor - direction := 1 - if amount < 0 { - direction = -1 - } - target := cur + amount - direction - - target, err := m.ValidIndex(target) - if m.Len() == 0 { - return err - } - // correct amount in case target has changed - amount = target - cur + direction - - if amount == 0 { - m.listItems[cur].selected = mark - return nil - } - for c := 0; c < amount*direction; c++ { - m.listItems[cur+c].selected = mark - } - m.viewPos.Cursor = target - _, errSec := m.MoveCursor(direction) - if err == nil { - err = errSec - } - return err -} - -// MarkSelect sets the selected state of the item at the given index to true -// or returns a error if index is OutOfBounds. -func (m *Model) MarkSelect(index int, mark bool) error { - i, err := m.ValidIndex(index) - if err != nil { - return err - } - m.listItems[i].selected = mark - return nil -} - -// MarkSelectAll marks all items of the list according to mark -// or returns OutOfBounds if list has no Items -func (m *Model) MarkSelectAll(mark bool) error { - _, err := m.ValidIndex(0) - if m.Len() == 0 { - return err - } - for c := range m.listItems { - m.listItems[c].selected = mark - } - return err -} - -// ToggleAllSelected inverts the select state of ALL items -func (m *Model) ToggleAllSelected() error { - _, err := m.ValidIndex(0) - if m.Len() == 0 { - return err - } - for i := range m.listItems { - m.listItems[i].selected = !m.listItems[i].selected - } - return err -} - -// IsSelected returns true if the given Item is selected -// false otherwise. If the requested index is outside the list -// error is not nil. -func (m *Model) IsSelected(index int) (bool, error) { - index, err := m.ValidIndex(index) - if m.Len() == 0 { - return false, err - } - return m.listItems[index].selected, err -} - -// GetAllSelected returns you a list of all items -// that are selected in current (displayed) order -func (m *Model) GetAllSelected() []fmt.Stringer { - var selected []fmt.Stringer - for _, item := range m.listItems { - if item.selected { - selected = append(selected, item.value) - } - } - return selected -} diff --git a/list/suffixer.go b/list/suffixer.go index 0b6129308..3ea6d3875 100644 --- a/list/suffixer.go +++ b/list/suffixer.go @@ -1,6 +1,7 @@ package list import ( + "fmt" "github.com/muesli/reflow/ansi" ) @@ -9,7 +10,7 @@ import ( // and then Suffix ones, per line to draw, to generate according suffixes. type Suffixer interface { InitSuffixer(ViewPos, ScreenInfo) int - Suffix(currentItem, currentLine int, selected bool) string + Suffix(currentItem, currentLine int, item fmt.Stringer) string } // DefaultSuffixer is more a example than a default but still it highlights @@ -34,7 +35,7 @@ func (e *DefaultSuffixer) InitSuffixer(viewPos ViewPos, screen ScreenInfo) int { } // Suffix returns a suffix string for the given line -func (e *DefaultSuffixer) Suffix(item, line int, selected bool) string { +func (e *DefaultSuffixer) Suffix(item, line int, value fmt.Stringer) string { if item == e.viewPos.Cursor && line == 0 { return e.currentMarker } From 44cfb7b03f0fc4c111a9d9add673691ede106e4c Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Sat, 5 Dec 2020 13:13:52 +0100 Subject: [PATCH 63/73] moved parts of Lines() Method to own function to make the Lines() Methode less overfilled. --- list/item.go | 45 ++++++++++++ list/list.go | 202 ++++++++++++++++++--------------------------------- 2 files changed, 115 insertions(+), 132 deletions(-) diff --git a/list/item.go b/list/item.go index 0db19a4bc..425e2e422 100644 --- a/list/item.go +++ b/list/item.go @@ -2,6 +2,7 @@ package list import ( "fmt" + "github.com/muesli/reflow/ansi" "github.com/muesli/reflow/wordwrap" "strings" ) @@ -14,6 +15,7 @@ type item struct { } // 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) []string { var preWidth, sufWidth int if m.PrefixGen != nil { @@ -31,6 +33,49 @@ func (m *Model) itemLines(i item) []string { 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) + completLines := make([]string, len(lines)) + + for c := 0; c < len(lines); c++ { + lineContent := lines[c] + // Surrounding content + var linePrefix, lineSuffix string + if m.PrefixGen != nil { + linePrefix = m.PrefixGen.Prefix(index, c, item.value) + } + if m.SuffixGen != nil { + free := contentWidth - ansi.PrintableRuneWidth(lineContent) + if free < 0 { + free = 0 // TODO is this nessecary? + } + suffix := m.SuffixGen.Suffix(index, c, item.value) + 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 diff --git a/list/list.go b/list/list.go index 5f3336bc8..dd18885db 100644 --- a/list/list.go +++ b/list/list.go @@ -58,6 +58,9 @@ func NewModel() Model { // show all lines Wrap: 0, + // show line number + PrefixGen: NewPrefixer(), + CurrentStyle: curStyle, } } @@ -85,17 +88,64 @@ func (m Model) View() string { return strings.Join(lines, "\n") } +// Update changes the Model of the List according to the messages received +// if the list is focused, else does nothing. +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 + } + + 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 returns the Visible lines of the list items // used to display the current user interface func (m *Model) Lines() []string { - // get public variables as locals so they can't change while using - height := m.Screen.Height - width := m.Screen.Width // check visible area - if height*width <= 0 { + if m.Screen.Height*m.Screen.Width <= 0 { panic("Can't display with zero width or hight of Viewport") } - // Get the Width of each suf/prefix var prefixWidth, suffixWidth int if m.PrefixGen != nil { @@ -104,92 +154,38 @@ func (m *Model) Lines() []string { if m.SuffixGen != nil { suffixWidth = m.SuffixGen.InitSuffixer(m.viewPos, m.Screen) } - // Get actual content width - contentWidth := width - prefixWidth - suffixWidth + contentWidth := m.Screen.Width - prefixWidth - suffixWidth // Check if there is space for the content left if contentWidth <= 0 { panic("Can't display with zero width for content") } - linesBefor := make([]string, 0, height) + linesBefor := make([]string, 0, m.viewPos.LineOffset) // loop to add the item(-lines) befor the cursor to the return lines - for c := 1; // dont add cursor item - m.viewPos.Cursor-c >= 0; c++ { + // dont add cursor item + for c := 1; m.viewPos.Cursor-c >= 0; c++ { index := m.viewPos.Cursor - c - item := m.listItems[index] - - contentLines := m.itemLines(item) - // append the lines in reverse, to add them in correct order later - for c := len(contentLines) - 1; c >= 0 && len(linesBefor) < m.viewPos.LineOffset; c-- { - lineContent := contentLines[c] - // Surrounding lineContent - var linePrefix, lineSuffix string - if m.PrefixGen != nil { - linePrefix = m.PrefixGen.Prefix(index, c, item.value) - } - if m.SuffixGen != nil { - lineSuffix = m.SuffixGen.Suffix(index, c, item.value) - if lineSuffix != "" { - free := contentWidth - ansi.PrintableRuneWidth(lineContent) - if free < 0 { - free = 0 // TODO is this nessecary after adding hardwrap? - } - lineSuffix = fmt.Sprintf("%s%s", strings.Repeat(" ", free), lineSuffix) - } - } - - // Join all - line := fmt.Sprintf("%s%s%s", linePrefix, lineContent, lineSuffix) - - // Write wrapped line - linesBefor = append(linesBefor, line) + 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, height) + allLines := make([]string, 0, m.Screen.Height) for c := len(linesBefor) - 1; c >= 0; c-- { allLines = append(allLines, linesBefor[c]) } - var visLines int // Handle list items, start at cursor and go till end of list or visible (break) for index := m.viewPos.Cursor; index < m.Len(); index++ { - item := m.listItems[index] - - lines := m.itemLines(item) - - // append all visibles lines since the cursor - for c := 0; c < len(lines) && len(allLines) < height; c++ { - lineContent := lines[c] - // Surrounding content - var linePrefix, lineSuffix string - if m.PrefixGen != nil { - linePrefix = m.PrefixGen.Prefix(index, c, item.value) - } - if m.SuffixGen != nil { - free := contentWidth - ansi.PrintableRuneWidth(lineContent) - if free < 0 { - free = 0 // TODO is this nessecary? - } - lineSuffix = fmt.Sprintf("%s%s", strings.Repeat(" ", free), m.SuffixGen.Suffix(index, c, item.value)) - } - - // Join all - line := fmt.Sprintf("%s%s%s", linePrefix, lineContent, lineSuffix) - - // Highlighting of selected and current lines - style := m.LineStyle - if index == m.viewPos.Cursor { - style = m.CurrentStyle - } - - // Highlight and write wrapped line - allLines = append(allLines, style.Styled(line)) - visLines++ + 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 set, fill up the remaining space @@ -223,64 +219,6 @@ func (m *Model) Lines() []string { return allLines } -// Update changes the Model of the List according to the messages received -// if the list is focused, else does nothing. -func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - if !m.focus { - return m, nil - } - - if m.PrefixGen == nil { - // use default - m.PrefixGen = NewPrefixer() - } - - var cmd tea.Cmd - - 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 - } - - case tea.WindowSizeMsg: - - m.Screen.Width = msg.Width - m.Screen.Height = msg.Height - m.Screen.Profile = termenv.ColorProfile() - - return m, cmd - - 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 -} - // NoItems is a error returned when the list is empty type NoItems error @@ -400,7 +338,7 @@ func (m *Model) SetCursor(target int) (int, error) { return target, err } -// Top moves the cursor to the first line +// Top moves the cursor to the first item func (m *Model) Top() error { _, err := m.ValidIndex(0) if err != nil { @@ -411,7 +349,7 @@ func (m *Model) Top() error { return nil } -// Bottom moves the cursor to the last line +// Bottom moves the cursor to the last item func (m *Model) Bottom() error { end := len(m.listItems) - 1 _, err := m.ValidIndex(end) From 29399c922e3a28603d0201a20cd3d5f51d91bf44 Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Sat, 5 Dec 2020 13:22:38 +0100 Subject: [PATCH 64/73] Changed Lines() to fail silently (not panic) in case of to little screen area. --- list/list.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/list/list.go b/list/list.go index dd18885db..a8d542d14 100644 --- a/list/list.go +++ b/list/list.go @@ -144,7 +144,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *Model) Lines() []string { // check visible area if m.Screen.Height*m.Screen.Width <= 0 { - panic("Can't display with zero width or hight of Viewport") + return []string{"Can't display with zero width or hight of Viewport"} } // Get the Width of each suf/prefix var prefixWidth, suffixWidth int @@ -159,7 +159,7 @@ func (m *Model) Lines() []string { // Check if there is space for the content left if contentWidth <= 0 { - panic("Can't display with zero width for content") + return []string{"no space for content left"} } linesBefor := make([]string, 0, m.viewPos.LineOffset) From c2dc5acc122b74238e433690b979d892fd57694a Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Sat, 5 Dec 2020 23:19:23 +0100 Subject: [PATCH 65/73] Changed Method return types from error to tea.Cmd to signal, errors and changes within the list Model, to the caller of the Methods. Hide (made private) Sort interface satisfying Methods because Swap would allow silent(!) change of the list Model, this means since the Swap Method is not allowed to return a value of type tea.Cmd we can't return a command to signify a change of the cursor item, other changes or a (index) error. - changed Methods to fail early and hard, so to not change the Model in any way if the request (-ed index) is wrong. - rewritten top level commends. - removed filling of the width and hight of the Lines method. Should be a bubbles function and not a single part of the list-bubble. - added list-model message structs to signify a change within the list Model. - changed/removed tests accordingly. --- list/example/main.go | 17 ++- list/item.go | 33 ++++ list/list.go | 353 +++++++++++++++++++++++-------------------- list/list_test.go | 86 ++++------- 4 files changed, 266 insertions(+), 223 deletions(-) diff --git a/list/example/main.go b/list/example/main.go index e92d28ead..7ac25886c 100644 --- a/list/example/main.go +++ b/list/example/main.go @@ -91,8 +91,12 @@ func (m *model) SetStyle(index int, style termenv.Style) error { i.style = style return i, nil } - _, err := m.list.UpdateItem(index, updater) - return err + _, err := m.list.ValidIndex(index) + if err != nil { + return err + } + m.list.UpdateItem(index, updater) + return nil } type stringItem struct { @@ -212,7 +216,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return item, cmd } i, _ := m.list.GetCursorIndex() - cmd, _ := m.list.UpdateItem(i, updater) + cmd := m.list.UpdateItem(i, updater) return m, cmd } @@ -401,11 +405,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { j, _ = strconv.Atoi(m.jump) m.jump = "" } - var err error + var ok bool var i int - for c := 0; c < j && err == nil; c++ { + for c := 0; c < j && !ok; c++ { i, _ = m.list.GetCursorIndex() - _, err = m.list.RemoveIndex(i) + _, cmd := m.list.RemoveIndex(i) + _, ok = cmd().(error) } return m, nil diff --git a/list/item.go b/list/item.go index 425e2e422..f2b797fb4 100644 --- a/list/item.go +++ b/list/item.go @@ -91,3 +91,36 @@ func MakeStringerList(list []string) []fmt.Stringer { } return stringerList } + +type itemList struct { + list *[]item + less *func(fmt.Stringer, fmt.Stringer) bool +} + +// Less is a Proxy to the less function, set from the user. +// since the Sort-interface demands a Less Methode without a error return value +// so we sadly have to returns silently if a index is out side the list, to not panic. + +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) +} + +// Swap swaps the items position within the list +// and is used to fulfill the Sort-interface +// since the Sort-interface demands a Swap Methode without a error return value +// so we sadly have to returns silently if a index is out side the list, to not panic. + +func (m *itemList) Swap(i, j int) { + (*m.list)[i], (*m.list)[j] = (*m.list)[j], (*m.list)[i] +} + +// Len returns the amount of list-items +// and is used to fulfill the Sort-interface + +func (m *itemList) Len() int { + return len(*m.list) +} diff --git a/list/list.go b/list/list.go index a8d542d14..e2e9c7921 100644 --- a/list/list.go +++ b/list/list.go @@ -3,7 +3,6 @@ package list import ( "fmt" tea "github.com/charmbracelet/bubbletea" - "github.com/muesli/reflow/ansi" "github.com/muesli/termenv" "sort" "strings" @@ -36,10 +35,6 @@ type Model struct { // Channels to create unique ids for all added/new items requestID chan<- struct{} resultID <-chan int - - // if view output should be extended to fit the according space - FillHeight bool - FillWidth bool } // NewModel returns a Model with some save/sane defaults @@ -70,8 +65,8 @@ func (m Model) Init() tea.Cmd { return nil } -// View renders the List to a (displayable) string -// since a empty string gets not displayed, return something to overwrite the last removed item +// 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 := m.Lines() @@ -89,7 +84,7 @@ func (m Model) View() string { } // Update changes the Model of the List according to the messages received -// if the list is focused, else does nothing. +// 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 { @@ -139,9 +134,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } -// Lines returns the Visible lines of the list items -// used to display the current user interface +// Lines renders the visible lines of the list +// by calling the String Methodes of the items +// and if present the pre- and suffix function. +// It fails silently (with a string message), +// if there is not enough space for the content left. func (m *Model) Lines() []string { + //TODO add error return value // check visible area if m.Screen.Height*m.Screen.Width <= 0 { return []string{"Can't display with zero width or hight of Viewport"} @@ -188,33 +187,6 @@ func (m *Model) Lines() []string { allLines = append(allLines, itemLines[i]) } } - // If set, fill up the remaining space - var rest []string - var lineFill string - - if m.FillWidth { - for i, ln := range allLines { - free := m.Screen.Width - ansi.PrintableRuneWidth(ln) - if free < 0 { - free = 0 // TODO log error - } - allLines[i] = ln + strings.Repeat(" ", free) - } - lineFill = strings.Repeat(" ", m.Screen.Width) - } - if m.FillHeight && len(allLines) < m.Screen.Height { - free := m.Screen.Height - len(allLines) - if free < 0 { - free = 0 // TODO log error - } - rest = make([]string, free) - if lineFill != "" { - for i := range rest { - rest[i] = lineFill - } - } - return append(allLines, rest...) - } return allLines } @@ -225,13 +197,13 @@ 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 bounderys +// 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 Modul +// 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. @@ -240,17 +212,30 @@ type NotFocused error // NilValue is returned if there was a request to set nil as value of a list item. type NilValue 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{} + +// ItemChange signals the change of the order of the items +// or the adding, changing (updating) or deletion of items within the list +type ItemChange struct{} + // 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 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 infront the list begin (%d)", 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)) @@ -276,7 +261,7 @@ func (m *Model) validOffset(newCursor int) (int, error) { newOffset := m.viewPos.LineOffset + amount if m.Wrap != 1 { - // assume down (positiv) movement + // assume down (positive) movement start := 0 stop := amount - 1 // exclude target item (-lines) @@ -315,56 +300,84 @@ type ScreenInfo struct { Profile termenv.Profile } -// MoveCursor moves the cursor by amount and returns OutOfBounds error if amount go's beyond list borders -// or if the CursorOffset is greater than half of the display height returns ConfigError -func (m *Model) MoveCursor(amount int) (int, error) { +// 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 - newOffset, err := m.validOffset(target) - target, err = m.ValidIndex(target) + 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, err + 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, -// if not the nearest end of the list, will be used and OutOfBounds error is returned -func (m *Model) SetCursor(target int) (int, error) { - newOffset, err := m.validOffset(target) - target, err = m.ValidIndex(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, err1 := m.ValidIndex(target) + newOffset, err2 := m.validOffset(target) + if err1 != nil || err2 != nil { + return target, tea.Batch(func() tea.Msg { return err1 }, func() tea.Msg { return err2 }) + } + if target == m.viewPos.Cursor { + return target, nil + } + m.viewPos.Cursor = target m.viewPos.LineOffset = newOffset - return target, err + return target, tea.Batch(func() tea.Msg { return CursorItemChange{} }, func() tea.Msg { return CursorIndexChange(target) }) } -// Top moves the cursor to the first item -func (m *Model) Top() error { +// 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 err + return func() tea.Msg { return err } + } + if m.viewPos.Cursor == 0 { + return nil } m.viewPos.Cursor = 0 m.viewPos.LineOffset = m.CursorOffset - return nil + return tea.Batch(func() tea.Msg { return CursorItemChange{} }, func() tea.Msg { return CursorIndexChange(0) }) } -// Bottom moves the cursor to the last item -func (m *Model) Bottom() error { +// 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 err + return func() tea.Msg { return err } + } + if m.viewPos.Cursor == end { + return nil } m.viewPos.LineOffset = m.Screen.Height - m.CursorOffset - m.MoveCursor(end) - return nil + 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 -// and if a costum less function is provided, they get sorted. -// if a entry of itemList is nil it will get skiped -func (m *Model) AddItems(itemList []fmt.Stringer) error { +// 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 ItemChange Msg. +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 { @@ -375,49 +388,67 @@ func (m *Model) AddItems(itemList []fmt.Stringer) error { ) } } - // only sort if user set less function - if m.less != nil { - // Sort will take care of the correct position of Cursor and Offset - m.Sort() - } var err error + var cmd tea.Cmd + if oldLenght != m.Len() { + cmd = func() tea.Msg { return ItemChange{} } + } 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 err + return cmd } -// ResetItems replaces all list items with the new items, -// if equals function is set and a new item yields true -// cursor is set on this item. -func (m *Model) ResetItems(newStringers []fmt.Stringer) error { +// 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 ItemChange 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 } - return nil + // 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 ItemChange{} }) } -// RemoveIndex returns a error if the index is not valid, -// and if valid, returns the item while removing it from the list. -func (m *Model) RemoveIndex(index int) (fmt.Stringer, error) { +// 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. +func (m *Model) RemoveIndex(index int) (fmt.Stringer, tea.Cmd) { index, err := m.ValidIndex(index) - if m.Len() == 0 { - m.viewPos.Cursor = 0 - return nil, err + if err != nil { + return nil, func() tea.Msg { return err } } var rest []item itemValue, _ := m.GetItem(index) @@ -425,109 +456,94 @@ func (m *Model) RemoveIndex(index int) (fmt.Stringer, error) { rest = m.listItems[index+1:] } m.listItems = append(m.listItems[:index], rest...) - newCursor, _ := m.ValidIndex(index) + cmd := func() tea.Msg { return ItemChange{} } + oldCursor := m.viewPos.Cursor + newCursor, err := m.ValidIndex(index) newOffset, _ := m.validOffset(newCursor) m.viewPos.Cursor = newCursor m.viewPos.LineOffset = newOffset - return itemValue, err + + 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 // If its not set than order after string. -func (m *Model) Sort() { +// Internally the Sort method uses the sort.Sort interface, 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. +func (m *Model) Sort() tea.Cmd { if m.Len() < 1 { - return + return nil } + var cmd tea.Cmd old := m.listItems[m.viewPos.Cursor].id - sort.Sort(m) + sort.Sort(&itemList{&(m.listItems), &(m.less)}) for i, item := range m.listItems { if item.id == old { + if i != m.viewPos.Cursor { + cmd = func() tea.Msg { return CursorIndexChange(i) } + } m.viewPos.Cursor = i break } } + return cmd } -// Less is a Proxy to the less function, set from the user. -// since the Sort-interface demands a Less Methode without a error return value -// so we sadly have to returns silently if a index is out side the list, to not panic. -func (m *Model) Less(i, j int) bool { - _, errI := m.ValidIndex(i) - _, errJ := m.ValidIndex(j) - if errI != nil || errJ != nil { - return false - } - // 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.listItems[i].value.String() < m.listItems[j].value.String() - } - return m.less(m.listItems[i].value, m.listItems[j].value) -} - -// Swap swaps the items position within the list -// and is used to fulfill the Sort-interface -// since the Sort-interface demands a Swap Methode without a error return value -// so we sadly have to returns silently if a index is out side the list, to not panic. -func (m *Model) Swap(i, j int) { - _, errI := m.ValidIndex(i) - _, errJ := m.ValidIndex(j) - if errI != nil || errJ != nil { - return - } - m.listItems[i], m.listItems[j] = m.listItems[j], m.listItems[i] -} - -// Len returns the amount of list-items -// and is used to fulfill the Sort-interface +// Len returns the amount of list-items. func (m *Model) Len() int { return len(m.listItems) } -// SetLess sets the internal less function used for sorting the list items +// 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 methode used to get the index (GetIndex) of a provided fmt.Stringer value +// 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 methode +// 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 -// So: MoveItem(1) Moves the Item towards the end by one -// and MoveItem(-1) Moves the Item towards the beginning -// MoveItem(0) safely does nothing -// and a amount that would result outside the list returns a error != nil -func (m *Model) MoveItem(amount int) error { +// 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 ItemChange and a CursorIndexChange is returned. +func (m *Model) MoveItem(amount int) tea.Cmd { cur := m.viewPos.Cursor target, err := m.ValidIndex(cur + amount) - if m.Len() == 0 { - return err - } - if amount == 0 { - return nil - } if err != nil { - return err + 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.Swap(cur+c, cur+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 nil + + return tea.Batch(func() tea.Msg { return ItemChange{} }, func() tea.Msg { return CursorIndexChange(target) }) } // Focus sets the list Model according to focus. @@ -541,11 +557,11 @@ func (m *Model) Focused() bool { return m.focus } -// GetIndex returns NotFound error if the Equals Methode is not set (SetEquals) -// else it returns the index of the first found item -func (m *Model) GetIndex(toSearch fmt.Stringer) (int, error) { +// 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, NotFound(fmt.Errorf("no equals function provided. Use SetEquals to set it")) + 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)) @@ -568,61 +584,70 @@ func (m *Model) GetIndex(toSearch fmt.Stringer) (int, error) { } if c > 1 { // TODO performance: trust User and remove check for multiple matches? - return -c, MultipleMatches(fmt.Errorf("The provided equals function yields multiple matches betwen one and other fmt.Stringer's")) + 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 and a NilValue error is returned. -func (m *Model) UpdateItem(index int, updater func(fmt.Stringer) (fmt.Stringer, tea.Cmd)) (tea.Cmd, 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 ItemChange or CursorItemChange through tea.Cmd. +func (m *Model) UpdateItem(index int, updater func(fmt.Stringer) (fmt.Stringer, tea.Cmd)) tea.Cmd { index, err := m.ValidIndex(index) - if m.Len() == 0 { - return nil, err + if err != nil { + return func() tea.Msg { return err } } v, cmd := updater(m.listItems[index].value) + + cmd = tea.Batch(func() tea.Msg { return ItemChange{} }, 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, NilValue(fmt.Errorf("cant add nil value to list")) + return cmd } m.listItems[index].value = v - return cmd, err + return cmd } -// GetCursorIndex returns the current cursor position -// within the List and also NotFocused error if the Model is not focused +// 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, error) { +func (m *Model) GetCursorIndex() (int, tea.Cmd) { if m.Len() == 0 { - return 0, NoItems(fmt.Errorf("the list has no items on which the cursor could be")) + 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, NotFocused(fmt.Errorf("Model is not focused")) + 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 if the Model is not focused +// 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, error) { +func (m *Model) GetCursorItem() (fmt.Stringer, tea.Cmd) { if m.Len() == 0 { - return nil, NoItems(fmt.Errorf("the list has no items on which the cursor could be")) + 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, NotFocused(fmt.Errorf("Model is not focused")) + 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 otherwise. -func (m *Model) GetItem(index int) (fmt.Stringer, error) { +// a error through tea.Cmd otherwise. +func (m *Model) GetItem(index int) (fmt.Stringer, tea.Cmd) { index, err := m.ValidIndex(index) - if m.Len() == 0 { - return nil, err + if err != nil { + return nil, func() tea.Msg { return err } } return m.listItems[index].value, nil } @@ -644,7 +669,7 @@ func (m *Model) Copy() *Model { return copiedModel } -// GetID returns a new for this list unique id +// 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 { diff --git a/list/list_test.go b/list/list_test.go index a0eb40f2a..c57d12a7f 100644 --- a/list/list_test.go +++ b/list/list_test.go @@ -7,35 +7,6 @@ import ( "testing" ) -// TestViewPanic runs the View on various model list model states that should yield a panic -func TestNoAreaPanic(t *testing.T) { - m := NewModel() - var panicMsg interface{} - defer func() { - panicMsg, _ = recover().(string) - if panicMsg != "Can't display with zero width or hight of Viewport" { - t.Errorf("No Panic or wrong panic message: %s", panicMsg) - } - }() - m.View() -} - -// TestNoContentSpacePanic Fails if after the Prefixer Width is subtracted there is still spaces left for contnent when there shouldent be -func TestNoContentSpacePanic(t *testing.T) { - m := NewModel() - m.Screen = ScreenInfo{Width: 1, Height: 50} - m.PrefixGen = NewPrefixer() - m.SuffixGen = NewSuffixer() - var panicMsg interface{} - defer func() { - panicMsg, _ = recover().(string) - if panicMsg != "Can't display with zero width for content" { - t.Errorf("No Panic or wrong panic message: %s", panicMsg) - } - }() - m.View() -} - // TestLines test if the models Lines methode returns the write amount of lines func TestEmptyLines(t *testing.T) { m := NewModel() @@ -172,27 +143,31 @@ func TestMovementKeys(t *testing.T) { 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 - _, err := m.MoveCursor(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 - _, err = m.MoveCursor(-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 = 55, 56 m.viewPos.Cursor = start - err = m.MoveItem(1) + 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 - err = m.MoveItem(-1) + 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) } @@ -200,18 +175,21 @@ func TestMovementKeys(t *testing.T) { t.Errorf("up movement should change the Item offset to '14' but got: %d", m.viewPos.LineOffset) } finish = m.Len() - 1 - err = m.Bottom() + 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 - err = m.Top() + 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) } - _, err = m.SetCursor(10) - if m.viewPos.Cursor != 10 || err != nil { + _, 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) } } @@ -261,15 +239,16 @@ func TestUnfocused(t *testing.T) { // TestGetIndex sets a equals function and searches After the index of a specific item with GetIndex func TestGetIndex(t *testing.T) { m := NewModel() - _, err := m.GetIndex(StringItem("z")) - if err == nil { + _, 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, err := m.GetIndex(StringItem("z")) - if err != nil { - t.Errorf("GetIndex should not return error: %s", err) + 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) @@ -332,21 +311,21 @@ func TestSetCursor(t *testing.T) { } toTest := []test{ // forwards - {ViewPos{0, 0}, -2, ViewPos{5, 0}}, + {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{44, 72}}, + {ViewPos{0, 0}, 100, ViewPos{0, 0}}, // wrong request -> no change // backwards - {ViewPos{45, m.Len() - 1}, -2, ViewPos{5, 0}}, + {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, 72}}, + {ViewPos{45, m.Len() - 1}, 100, ViewPos{45, m.Len() - 1}}, // wrong request -> no change } for i, tCase := range toTest { m.viewPos = tCase.oldView @@ -360,18 +339,19 @@ func TestSetCursor(t *testing.T) { // TestMoveItem test wrong arguments func TestMoveItem(t *testing.T) { m := NewModel() - err := m.MoveItem(0) - _, ok := err.(OutOfBounds) + 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{""})) - err = m.MoveItem(0) - if err != nil { - t.Errorf("MoveItem(0) should not not return a error on a not empty list") + 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) } - err = m.MoveItem(1) - _, ok = err.(OutOfBounds) + 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) } From 0397a7362ea662cc9f196652e1931c245164bbbe Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Fri, 11 Dec 2020 11:27:08 +0100 Subject: [PATCH 66/73] Changed Lines to be a function and to return a error so that, when multiple bubbles are used they can be handled uniformly. A error is for example necessary when the width and hight are to small for this bubble to display properly or when the window size is not yet known. - changed Lines to be a **function** and a proxy to the lines **method**, to satisfy the elm architecture and to reduce the amount of **function** calls, and the associated runtime cost, though the copy of the Model. - mentioned the runtime cost when adding lot of items in top level comment from AddItems. - changed test-cases accordingly to change of Lines header. --- list/list.go | 42 ++++++++++++++++++++++++++---------------- list/list_test.go | 16 +++++++++------- 2 files changed, 35 insertions(+), 23 deletions(-) diff --git a/list/list.go b/list/list.go index e2e9c7921..e73c4fabe 100644 --- a/list/list.go +++ b/list/list.go @@ -69,15 +69,9 @@ func (m Model) Init() tea.Cmd { // and returns "empty" if the list has no items. This might change in the future. func (m Model) View() string { - lines := m.Lines() - - if m.Len() == 0 { - // TODO make empty string handling better, custom empty function? - if len(lines) > 0 { - lines[0] = "empty" - } else { - lines = []string{"empty"} - } + lines, err := m.lines() + if err != nil { + return err.Error() } return strings.Join(lines, "\n") @@ -137,13 +131,24 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Lines renders the visible lines of the list // by calling the String Methodes of the items // and if present the pre- and suffix function. -// It fails silently (with a string message), -// if there is not enough space for the content left. -func (m *Model) Lines() []string { - //TODO add error return value +// 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 []string{"Can't display with zero width or hight of Viewport"} + return nil, fmt.Errorf("Can't display with zero width or hight of Viewport") } // Get the Width of each suf/prefix var prefixWidth, suffixWidth int @@ -158,7 +163,7 @@ func (m *Model) Lines() []string { // Check if there is space for the content left if contentWidth <= 0 { - return []string{"no space for content left"} + return nil, fmt.Errorf("Can't display with zero width or hight of Viewport") } linesBefor := make([]string, 0, m.viewPos.LineOffset) @@ -187,8 +192,11 @@ func (m *Model) Lines() []string { allLines = append(allLines, itemLines[i]) } } + if len(allLines) == 0 { + return nil, fmt.Errorf("no visible lines") + } - return allLines + return allLines, nil } // NoItems is a error returned when the list is empty @@ -374,6 +382,8 @@ func (m *Model) Bottom() tea.Cmd { // 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 ItemChange 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 diff --git a/list/list_test.go b/list/list_test.go index c57d12a7f..d8b8a9cf9 100644 --- a/list/list_test.go +++ b/list/list_test.go @@ -15,12 +15,14 @@ func TestEmptyLines(t *testing.T) { t.Error("Init should do nothing") // yet } m.Screen = ScreenInfo{Height: 50, Width: 80} - if len(m.Lines()) != 0 { - t.Error("A list with no entrys should return no lines.") + _, err := m.Lines() + if err == nil { + t.Error("A list with no entrys should return a error.") } m.Sort() - if len(m.Lines()) != 0 { - t.Error("A list with no entrys should return no lines.") + _, err = m.Lines() + if err == nil { + t.Error("A list with no entrys should return a error.") } } @@ -62,7 +64,7 @@ func TestBasicsLines(t *testing.T) { } m.Top() - out := m.Lines() + 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) } @@ -89,7 +91,7 @@ func TestWrappedLines(t *testing.T) { m.Screen = ScreenInfo{Height: 50, Width: 80} m.AddItems(MakeStringerList([]string{"\n0", "1\n2", "3\n4", "5\n6", "7\n8"})) - out := m.Lines() + out, _ := m.Lines() wrap, sep := "│", "╭" num := "\x1b[7m " for i := 1; i < len(out); i++ { @@ -113,7 +115,7 @@ func TestMultiLineBreaks(t *testing.T) { 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() + out, _ := m.Lines() prefix := "\x1b[7m 1╭>" for i, line := range out { if !strings.HasPrefix(line, prefix) { From 12d5016501828e3e54e93126c3aadee952bda13e Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Sat, 26 Dec 2020 20:32:43 +0100 Subject: [PATCH 67/73] Fixed cursor changing bug within RemoveIndex --- list/list.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/list/list.go b/list/list.go index e73c4fabe..95778d12f 100644 --- a/list/list.go +++ b/list/list.go @@ -455,9 +455,9 @@ func (m *Model) ResetItems(newStringers []fmt.Stringer) tea.Cmd { // 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) { - index, err := m.ValidIndex(index) - if err != nil { + if _, err := m.ValidIndex(index); err != nil { return nil, func() tea.Msg { return err } } var rest []item @@ -467,8 +467,9 @@ func (m *Model) RemoveIndex(index int) (fmt.Stringer, tea.Cmd) { } m.listItems = append(m.listItems[:index], rest...) cmd := func() tea.Msg { return ItemChange{} } + oldCursor := m.viewPos.Cursor - newCursor, err := m.ValidIndex(index) + newCursor, err := m.ValidIndex(oldCursor) newOffset, _ := m.validOffset(newCursor) m.viewPos.Cursor = newCursor m.viewPos.LineOffset = newOffset @@ -607,6 +608,7 @@ func (m *Model) GetIndex(toSearch fmt.Stringer) (int, tea.Cmd) { // 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 ItemChange 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 } From cf474ccb165333af7a2ddd437b4200559c3e52b7 Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Sat, 26 Dec 2020 20:53:39 +0100 Subject: [PATCH 68/73] changed Suf- and Prefixer interfaces to be able to change the suf- and prefix width on each item, rather than to force a unified width for suffix and prefix per draw. This allows for different width for each item (not line), with the expense of performance. Now the Init*fixer becomes a copy of the item as well as its absolute index within the list. This seems ugly, but for a user its very difficult to obtain a copy of the current item in a different way, and the use of the value seems to me like a justifying use case. I.e: a tree list with per item padding depending of the level of each node. - changed dependent files accordingly but crude. --- list/item.go | 12 ++++++------ list/list.go | 49 +++++++++++++++++++++++++++++++----------------- list/prefixer.go | 24 ++++++++++++------------ list/suffixer.go | 12 +++++++----- 4 files changed, 57 insertions(+), 40 deletions(-) diff --git a/list/item.go b/list/item.go index f2b797fb4..e12d78747 100644 --- a/list/item.go +++ b/list/item.go @@ -16,13 +16,13 @@ type item struct { // 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) []string { +func (m *Model) itemLines(i item, index int) []string { var preWidth, sufWidth int if m.PrefixGen != nil { - preWidth = m.PrefixGen.InitPrefixer(m.viewPos, m.Screen) + preWidth = m.PrefixGen.InitPrefixer(i.value, index, m.viewPos, m.Screen) } if m.SuffixGen != nil { - sufWidth = m.SuffixGen.InitSuffixer(m.viewPos, m.Screen) + sufWidth = m.SuffixGen.InitSuffixer(i.value, index, m.viewPos, m.Screen) } contentWith := m.Screen.Width - preWidth - sufWidth // TODO hard limit the string length @@ -40,7 +40,7 @@ func (m *Model) getItemLines(index, contentWidth int) ([]string, error) { return nil, err } item := m.listItems[index] - lines := m.itemLines(item) + lines := m.itemLines(item, index) completLines := make([]string, len(lines)) for c := 0; c < len(lines); c++ { @@ -48,14 +48,14 @@ func (m *Model) getItemLines(index, contentWidth int) ([]string, error) { // Surrounding content var linePrefix, lineSuffix string if m.PrefixGen != nil { - linePrefix = m.PrefixGen.Prefix(index, c, item.value) + linePrefix = m.PrefixGen.Prefix(c) } if m.SuffixGen != nil { free := contentWidth - ansi.PrintableRuneWidth(lineContent) if free < 0 { free = 0 // TODO is this nessecary? } - suffix := m.SuffixGen.Suffix(index, c, item.value) + suffix := m.SuffixGen.Suffix(c) if suffix != "" { lineSuffix = fmt.Sprintf("%s%s", strings.Repeat(" ", free), suffix) } diff --git a/list/list.go b/list/list.go index 95778d12f..5b6a94637 100644 --- a/list/list.go +++ b/list/list.go @@ -150,27 +150,27 @@ func (m *Model) lines() ([]string, error) { if m.Screen.Height*m.Screen.Width <= 0 { return nil, fmt.Errorf("Can't display with zero width or hight of Viewport") } - // Get the Width of each suf/prefix - var prefixWidth, suffixWidth int - if m.PrefixGen != nil { - prefixWidth = m.PrefixGen.InitPrefixer(m.viewPos, m.Screen) - } - if m.SuffixGen != nil { - suffixWidth = m.SuffixGen.InitSuffixer(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") - } 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-- { @@ -186,6 +186,21 @@ func (m *Model) lines() ([]string, error) { // 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++ { @@ -282,7 +297,7 @@ func (m *Model) validOffset(newCursor int) (int, error) { var lineSum int for i := start; i <= stop; i++ { - lineSum += len(m.itemLines(m.listItems[m.viewPos.Cursor+i*d])) + lineSum += len(m.itemLines(m.listItems[m.viewPos.Cursor+i*d], m.viewPos.Cursor+i*d)) } newOffset = m.viewPos.LineOffset + lineSum*d } @@ -493,7 +508,7 @@ func (m *Model) Sort() tea.Cmd { if m.Len() < 1 { return nil } - var cmd tea.Cmd + var cmd tea.Cmd // TODO send a ItemChange cmd? old := m.listItems[m.viewPos.Cursor].id sort.Sort(&itemList{&(m.listItems), &(m.less)}) for i, item := range m.listItems { diff --git a/list/prefixer.go b/list/prefixer.go index 4c1e0cb6d..38755826d 100644 --- a/list/prefixer.go +++ b/list/prefixer.go @@ -10,8 +10,8 @@ import ( // 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(ViewPos, ScreenInfo) int - Prefix(currentItem, currentLine int, item fmt.Stringer) string + InitPrefixer(currentItem fmt.Stringer, currentItemIndex int, viewPos ViewPos, screenInfo ScreenInfo) int + Prefix(currentLine int) string } // DefaultPrefixer is the default struct used for Prefixing a line @@ -40,6 +40,8 @@ type DefaultPrefixer struct { sepItem string sepWrap string + + currentIndex int } // NewPrefixer returns a DefautPrefixer with default values @@ -61,7 +63,8 @@ func NewPrefixer() *DefaultPrefixer { } // InitPrefixer sets up all strings used to prefix a given line later by Prefix() -func (d *DefaultPrefixer) InitPrefixer(position ViewPos, screen ScreenInfo) int { +func (d *DefaultPrefixer) InitPrefixer(value fmt.Stringer, currentItemIndex int, position ViewPos, screen ScreenInfo) int { + d.currentIndex = currentItemIndex d.viewPos = position offset := position.Cursor - position.LineOffset @@ -100,13 +103,13 @@ func (d *DefaultPrefixer) InitPrefixer(position ViewPos, screen ScreenInfo) int } // Prefix prefixes a given line -func (d *DefaultPrefixer) Prefix(currentIndex int, wrapIndex int, value fmt.Stringer) string { +func (d *DefaultPrefixer) Prefix(lineIndex 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, currentIndex) + lineNum = lineNumber(d.NumberRelative, d.viewPos.Cursor, d.currentIndex) } number := fmt.Sprintf("%d", lineNum) // since digits are only single bytes, len is sufficient: @@ -121,17 +124,14 @@ func (d *DefaultPrefixer) Prefix(currentIndex int, wrapIndex int, value fmt.Stri // Current: handle highlighting of current item/first-line curPad := d.unmark - if currentIndex == d.viewPos.Cursor { + if d.currentIndex == d.viewPos.Cursor { curPad = d.mark } // join all prefixes - var wrapPrefix, linePrefix string - - linePrefix = strings.Join([]string{firstPad, d.sepItem, curPad}, "") - if wrapIndex > 0 { - wrapPrefix = strings.Join([]string{wrapPad, d.sepWrap, d.unmark}, "") // don't prefix wrap lines with CurrentMarker (unmark) - return wrapPrefix + 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 diff --git a/list/suffixer.go b/list/suffixer.go index 3ea6d3875..bc9989a52 100644 --- a/list/suffixer.go +++ b/list/suffixer.go @@ -9,8 +9,8 @@ import ( // 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(ViewPos, ScreenInfo) int - Suffix(currentItem, currentLine int, item fmt.Stringer) string + InitSuffixer(item fmt.Stringer, currentIndex int, viewPos ViewPos, screenInfo ScreenInfo) int + Suffix(currentLine int) string } // DefaultSuffixer is more a example than a default but still it highlights @@ -20,6 +20,7 @@ type DefaultSuffixer struct { viewPos ViewPos currentMarker string markerLenght int + item int } // NewSuffixer returns a simple suffixer @@ -28,15 +29,16 @@ func NewSuffixer() *DefaultSuffixer { } // InitSuffixer returns the visible Width of the strings used to suffix the lines -func (e *DefaultSuffixer) InitSuffixer(viewPos ViewPos, screen ScreenInfo) int { +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(item, line int, value fmt.Stringer) string { - if item == e.viewPos.Cursor && line == 0 { +func (e *DefaultSuffixer) Suffix(line 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 From 8bff7c87ff2e3bd153981129c2a52692bf93baab Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Tue, 5 Jan 2021 15:16:53 +0100 Subject: [PATCH 69/73] changed sort return Cmd and renamed a message struct - made Sort return a ListChange message since it changes the order of the items. This results in the possibility of a endless Sort loop, if the user feeds the command of the Sort make in the update and Sorts when a ListChange message was received. So in this case the command from Sort should not be feed back. - changed name "ItemChange" message struct to "ListChange" because it is issued also when the order of the list changes. - removed blocking error check in MoveCursor, which now ignores errors from validOffset, because a ConfigError would block the Cursor moving otherwise. - updated top level comments of "hidden" sort.Interface satisfying methods --- list/item.go | 15 +++------------ list/list.go | 49 ++++++++++++++++++++++++++----------------------- 2 files changed, 29 insertions(+), 35 deletions(-) diff --git a/list/item.go b/list/item.go index e12d78747..4dc2677d7 100644 --- a/list/item.go +++ b/list/item.go @@ -92,15 +92,14 @@ func MakeStringerList(list []string) []fmt.Stringer { 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 } -// Less is a Proxy to the less function, set from the user. -// since the Sort-interface demands a Less Methode without a error return value -// so we sadly have to returns silently if a index is out side the list, to not panic. - 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 { @@ -109,18 +108,10 @@ func (m *itemList) Less(i, j int) bool { return (*m.less)((*m.list)[i].value, (*m.list)[j].value) } -// Swap swaps the items position within the list -// and is used to fulfill the Sort-interface -// since the Sort-interface demands a Swap Methode without a error return value -// so we sadly have to returns silently if a index is out side the list, to not panic. - func (m *itemList) Swap(i, j int) { (*m.list)[i], (*m.list)[j] = (*m.list)[j], (*m.list)[i] } -// Len returns the amount of list-items -// and is used to fulfill the Sort-interface - func (m *itemList) Len() int { return len(*m.list) } diff --git a/list/list.go b/list/list.go index 5b6a94637..4d069f7a1 100644 --- a/list/list.go +++ b/list/list.go @@ -242,9 +242,10 @@ type CursorIndexChange int // Maybe caused by updating the item, changing the cursor position or deletion of the cursor item type CursorItemChange struct{} -// ItemChange signals the change of the order of the items -// or the adding, changing (updating) or deletion of items within the list -type ItemChange 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. @@ -347,10 +348,10 @@ func (m *Model) MoveCursor(amount int) (int, tea.Cmd) { // 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, err1 := m.ValidIndex(target) - newOffset, err2 := m.validOffset(target) - if err1 != nil || err2 != nil { - return target, tea.Batch(func() tea.Msg { return err1 }, func() tea.Msg { return err2 }) + 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 @@ -396,7 +397,7 @@ func (m *Model) Bottom() tea.Cmd { // 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 ItemChange Msg. +// 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 { @@ -416,7 +417,7 @@ func (m *Model) AddItems(itemList []fmt.Stringer) tea.Cmd { var err error var cmd tea.Cmd if oldLenght != m.Len() { - cmd = func() tea.Msg { return ItemChange{} } + 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))) @@ -429,7 +430,7 @@ func (m *Model) AddItems(itemList []fmt.Stringer) tea.Cmd { // 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 ItemChange is returned through the tea.Cmd. +// 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 @@ -464,7 +465,7 @@ func (m *Model) ResetItems(newStringers []fmt.Stringer) tea.Cmd { // Sort will take care of the correct position of Cursor and Offset cmd = m.Sort() } - return tea.Batch(cmd, func() tea.Msg { return ItemChange{} }) + return tea.Batch(cmd, func() tea.Msg { return ListChange{} }) } // RemoveIndex removes and returns the item at the given index if it exists, @@ -481,7 +482,7 @@ func (m *Model) RemoveIndex(index int) (fmt.Stringer, tea.Cmd) { rest = m.listItems[index+1:] } m.listItems = append(m.listItems[:index], rest...) - cmd := func() tea.Msg { return ItemChange{} } + cmd := func() tea.Msg { return ListChange{} } oldCursor := m.viewPos.Cursor newCursor, err := m.ValidIndex(oldCursor) @@ -498,23 +499,25 @@ func (m *Model) RemoveIndex(index int) (fmt.Stringer, tea.Cmd) { return itemValue, cmd } -// Sort sorts the list items according to the set less-function -// If its not set than order after string. -// Internally the Sort method uses the sort.Sort interface, so this is not guaranteed to be a stable sort. +// 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. +// 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 } - var cmd tea.Cmd // TODO send a ItemChange cmd? + 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 = func() tea.Msg { return CursorIndexChange(i) } + cmd = tea.Batch(cmd, func() tea.Msg { return CursorIndexChange(i) }) } m.viewPos.Cursor = i break @@ -549,7 +552,7 @@ func (m *Model) GetEquals() func(first, second fmt.Stringer) bool { // 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 ItemChange and a CursorIndexChange is returned. +// 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) @@ -569,7 +572,7 @@ func (m *Model) MoveItem(amount int) tea.Cmd { m.viewPos.LineOffset = linOff m.viewPos.Cursor = target - return tea.Batch(func() tea.Msg { return ItemChange{} }, func() tea.Msg { return CursorIndexChange(target) }) + return tea.Batch(func() tea.Msg { return ListChange{} }, func() tea.Msg { return CursorIndexChange(target) }) } // Focus sets the list Model according to focus. @@ -621,7 +624,7 @@ func (m *Model) GetIndex(toSearch fmt.Stringer) (int, tea.Cmd) { // 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 ItemChange or CursorItemChange through tea.Cmd. +// 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) @@ -630,7 +633,7 @@ func (m *Model) UpdateItem(index int, updater func(fmt.Stringer) (fmt.Stringer, } v, cmd := updater(m.listItems[index].value) - cmd = tea.Batch(func() tea.Msg { return ItemChange{} }, cmd) + cmd = tea.Batch(func() tea.Msg { return ListChange{} }, cmd) if index == m.viewPos.Cursor { cmd = tea.Batch(func() tea.Msg { return CursorItemChange{} }, cmd) } From 1ae4865c9bfbeb2fdf169f34dbf8f0928da5b34a Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Thu, 7 Jan 2021 09:34:45 +0100 Subject: [PATCH 70/73] added some more (simple) examples --- list/example/checkboxes/main.go | 198 +++++++++++++++++++++++ list/example/checkboxes/prefixer.go | 180 +++++++++++++++++++++ list/example/{ => editable}/main.go | 16 +- list/example/tree/less_test.go | 48 ++++++ list/example/tree/main.go | 237 ++++++++++++++++++++++++++++ list/example/tree/prefixer.go | 166 +++++++++++++++++++ list/list.go | 5 + list/prefixer.go | 13 +- 8 files changed, 846 insertions(+), 17 deletions(-) create mode 100644 list/example/checkboxes/main.go create mode 100644 list/example/checkboxes/prefixer.go rename list/example/{ => editable}/main.go (95%) create mode 100644 list/example/tree/less_test.go create mode 100644 list/example/tree/main.go create mode 100644 list/example/tree/prefixer.go diff --git a/list/example/checkboxes/main.go b/list/example/checkboxes/main.go new file mode 100644 index 000000000..b256f00f1 --- /dev/null +++ b/list/example/checkboxes/main.go @@ -0,0 +1,198 @@ +package main + +import ( + "fmt" + "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) + p.Start() + +} + +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..5efaaf739 --- /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 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/main.go b/list/example/editable/main.go similarity index 95% rename from list/example/main.go rename to list/example/editable/main.go index 7ac25886c..6b5d97f4f 100644 --- a/list/example/main.go +++ b/list/example/editable/main.go @@ -116,12 +116,6 @@ func (s stringItem) String() string { } func main() { - //f, err := os.OpenFile("list.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) - //if err != nil { - // log.Fatal(err) - //} - //defer f.Close() - //log.SetOutput(f) m := newModel() itemList := []string{ @@ -167,7 +161,7 @@ func main() { p := tea.NewProgram(m) // Use the full size of the terminal in its "alternate screen buffer" - fullScreen := true // change to true if you want fullscreen + fullScreen := true // change to false if you dont want fullscreen if fullScreen { p.EnterAltScreen() @@ -253,6 +247,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 @@ -361,13 +356,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.list.MoveCursor(-j) return m, nil - // case "t": - // m.lastViews = append(m.lastViews, m.View()) - // return m, nil - // case "T": - // f, _ := os.OpenFile("test_cases.txt", os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) - // f.WriteString(strings.Join(m.lastViews, "\n##########################\n")) - // return m, tea.Quit case "w": if m.jump != "" { j, _ := strconv.Atoi(m.jump) 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..d1ecbb81c --- /dev/null +++ b/list/example/tree/main.go @@ -0,0 +1,237 @@ +package main + +import ( + "fmt" + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "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) + p.Start() +} + +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..9d3b7ca18 --- /dev/null +++ b/list/example/tree/prefixer.go @@ -0,0 +1,166 @@ +package main + +import ( + "fmt" + "github.com/charmbracelet/bubbles/list" + "github.com/muesli/reflow/ansi" + "github.com/muesli/termenv" + "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 list.ViewPos, screenInfo list.ScreenInfo) int + Prefix(currentLine int) string +} + +// 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 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/list.go b/list/list.go index 4d069f7a1..820244c12 100644 --- a/list/list.go +++ b/list/list.go @@ -112,6 +112,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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: @@ -235,6 +237,9 @@ 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 diff --git a/list/prefixer.go b/list/prefixer.go index 38755826d..526d18678 100644 --- a/list/prefixer.go +++ b/list/prefixer.go @@ -19,6 +19,7 @@ type DefaultPrefixer struct { PrefixWrap bool // Make clear where a item begins and where it ends + FirstSep string Seperator string SeperatorWrap string @@ -50,7 +51,8 @@ func NewPrefixer() *DefaultPrefixer { PrefixWrap: true, // Make clear where a item begins and where it ends - Seperator: "╭", + FirstSep: "╭", + Seperator: "├", SeperatorWrap: "│", // Mark it so that even without color support all is explicit @@ -64,6 +66,7 @@ func NewPrefixer() *DefaultPrefixer { // 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 @@ -71,9 +74,13 @@ func (d *DefaultPrefixer) InitPrefixer(value fmt.Stringer, currentItemIndex int, if offset < 0 { offset = 0 } + seperator := d.Seperator + if currentItemIndex == 0 { + seperator = d.FirstSep + } // Get separators width - widthItem := ansi.PrintableRuneWidth(d.Seperator) + widthItem := ansi.PrintableRuneWidth(seperator) widthWrap := ansi.PrintableRuneWidth(d.SeperatorWrap) // Find max width @@ -88,7 +95,7 @@ func (d *DefaultPrefixer) InitPrefixer(value fmt.Stringer, currentItemIndex int, // 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) + d.Seperator + d.sepItem = strings.Repeat(" ", sepWidth-widthItem) + seperator d.sepWrap = strings.Repeat(" ", sepWidth-widthWrap) + d.SeperatorWrap // pad right of prefix, with length of current pointer From b29aabac81214e6ff292029181ba9db31ccf4cf3 Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Thu, 7 Jan 2021 10:05:50 +0100 Subject: [PATCH 71/73] handled errors within main method and removed redundant interface --- list/example/checkboxes/main.go | 7 +++++-- list/example/tree/main.go | 6 +++++- list/example/tree/prefixer.go | 8 -------- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/list/example/checkboxes/main.go b/list/example/checkboxes/main.go index b256f00f1..ab483578a 100644 --- a/list/example/checkboxes/main.go +++ b/list/example/checkboxes/main.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "os" "strconv" "github.com/charmbracelet/bubbles/list" @@ -25,8 +26,10 @@ func main() { }) m.tail = "============================================\nuse ' ' to change the done state of a item\nuse 'q' or 'ctrl+c' to exit" p := tea.NewProgram(m) - p.Start() - + if err := p.Start(); err != nil { + fmt.Println("could not run program:", err) + os.Exit(1) + } } type item struct { diff --git a/list/example/tree/main.go b/list/example/tree/main.go index d1ecbb81c..661953220 100644 --- a/list/example/tree/main.go +++ b/list/example/tree/main.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" + "os" "strings" ) @@ -57,7 +58,10 @@ func main() { m.visible.PrefixGen = NewPrefixer() p := tea.NewProgram(m) - p.Start() + if err := p.Start(); err != nil { + fmt.Println("could not run program:", err) + os.Exit(1) + } } type model struct { diff --git a/list/example/tree/prefixer.go b/list/example/tree/prefixer.go index 9d3b7ca18..7ffbb962d 100644 --- a/list/example/tree/prefixer.go +++ b/list/example/tree/prefixer.go @@ -8,14 +8,6 @@ import ( "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 list.ViewPos, screenInfo list.ScreenInfo) int - Prefix(currentLine int) string -} - // TreePrefixer is the default struct used for Prefixing a line type TreePrefixer struct { PrefixWrap bool From 840b01ac9562fdc2b49ace3bf580e3a801021257 Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Thu, 7 Jan 2021 12:15:41 +0100 Subject: [PATCH 72/73] added and changed tests to accommodate change of prefix runes --- list/list_test.go | 79 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 75 insertions(+), 4 deletions(-) diff --git a/list/list_test.go b/list/list_test.go index d8b8a9cf9..dc31c1513 100644 --- a/list/list_test.go +++ b/list/list_test.go @@ -71,14 +71,16 @@ func TestBasicsLines(t *testing.T) { 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 + "╭" + cur + 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 = "" } } @@ -92,19 +94,22 @@ func TestWrappedLines(t *testing.T) { m.AddItems(MakeStringerList([]string{"\n0", "1\n2", "3\n4", "5\n6", "7\n8"})) out, _ := m.Lines() - wrap, sep := "│", "╭" + 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) } - prefix := fmt.Sprintf("%s%s %d", num, wrap, i-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) } - wrap, sep = sep, wrap num = " " + sep = "├" } } @@ -358,3 +363,69 @@ func TestMoveItem(t *testing.T) { 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") + } +} From d3b756e99e03536d032a97c4f916603cfa72873a Mon Sep 17 00:00:00 2001 From: "treilik@posteo.de" Date: Thu, 7 Jan 2021 12:37:56 +0100 Subject: [PATCH 73/73] changed Prefix Methode of *fixer interfaces to be able to change Prefix of line depending according to item line count. For example if one would like to change the prefix (or suffix) of each last line of an item. --- list/example/checkboxes/prefixer.go | 2 +- list/example/tree/prefixer.go | 2 +- list/item.go | 9 +++++---- list/prefixer.go | 4 ++-- list/suffixer.go | 4 ++-- 5 files changed, 11 insertions(+), 10 deletions(-) diff --git a/list/example/checkboxes/prefixer.go b/list/example/checkboxes/prefixer.go index 5efaaf739..e7c8d2200 100644 --- a/list/example/checkboxes/prefixer.go +++ b/list/example/checkboxes/prefixer.go @@ -123,7 +123,7 @@ func (s *SelectPrefixer) InitPrefixer(value fmt.Stringer, currentItemIndex int, } // Prefix prefixes a given line -func (s *SelectPrefixer) Prefix(lineIndex int) string { +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 diff --git a/list/example/tree/prefixer.go b/list/example/tree/prefixer.go index 7ffbb962d..0d08c0784 100644 --- a/list/example/tree/prefixer.go +++ b/list/example/tree/prefixer.go @@ -91,7 +91,7 @@ func (d *TreePrefixer) InitPrefixer(value fmt.Stringer, currentItemIndex int, po } // Prefix prefixes a given line -func (d *TreePrefixer) Prefix(lineIndex int) string { +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 diff --git a/list/item.go b/list/item.go index 4dc2677d7..3ef090618 100644 --- a/list/item.go +++ b/list/item.go @@ -41,21 +41,22 @@ func (m *Model) getItemLines(index, contentWidth int) ([]string, error) { } item := m.listItems[index] lines := m.itemLines(item, index) - completLines := make([]string, len(lines)) + lenLines := len(lines) + completLines := make([]string, lenLines) - for c := 0; c < len(lines); c++ { + for c := 0; c < lenLines; c++ { lineContent := lines[c] // Surrounding content var linePrefix, lineSuffix string if m.PrefixGen != nil { - linePrefix = m.PrefixGen.Prefix(c) + 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) + suffix := m.SuffixGen.Suffix(c, lenLines) if suffix != "" { lineSuffix = fmt.Sprintf("%s%s", strings.Repeat(" ", free), suffix) } diff --git a/list/prefixer.go b/list/prefixer.go index 526d18678..3d7960e41 100644 --- a/list/prefixer.go +++ b/list/prefixer.go @@ -11,7 +11,7 @@ import ( // 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 int) string + Prefix(currentLine, allLines int) string } // DefaultPrefixer is the default struct used for Prefixing a line @@ -110,7 +110,7 @@ func (d *DefaultPrefixer) InitPrefixer(value fmt.Stringer, currentItemIndex int, } // Prefix prefixes a given line -func (d *DefaultPrefixer) Prefix(lineIndex int) string { +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 diff --git a/list/suffixer.go b/list/suffixer.go index bc9989a52..7c96cd0e3 100644 --- a/list/suffixer.go +++ b/list/suffixer.go @@ -10,7 +10,7 @@ import ( // 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 int) string + Suffix(currentLine, allLines int) string } // DefaultSuffixer is more a example than a default but still it highlights @@ -37,7 +37,7 @@ func (e *DefaultSuffixer) InitSuffixer(_ fmt.Stringer, currentItemIndex int, vie } // Suffix returns a suffix string for the given line -func (e *DefaultSuffixer) Suffix(line int) string { +func (e *DefaultSuffixer) Suffix(line, allLines int) string { if e.item == e.viewPos.Cursor && line == 0 { return e.currentMarker }