├── carousel.gif ├── .golangci.yml ├── carousel.tape ├── .github └── workflows │ ├── check.yml │ └── build.yml ├── README.md ├── go.mod ├── LICENSE ├── examples └── main.go ├── go.sum └── carousel.go /carousel.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xaviergodart/bubble-carousel/HEAD/carousel.gif -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters: 2 | enable: 3 | - thelper 4 | - gofumpt 5 | - tparallel 6 | - unconvert 7 | - unparam 8 | - revive -------------------------------------------------------------------------------- /carousel.tape: -------------------------------------------------------------------------------- 1 | Type "go run examples/main.go" 2 | Sleep 100ms 3 | Enter 4 | Sleep 500ms 5 | Right 20 6 | Left 21 7 | Sleep 1.5s 8 | Ctrl+C 9 | Sleep 1.5s 10 | 11 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: checks 2 | on: 3 | push: 4 | branches: [ "main" ] 5 | tags: ["v*"] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | jobs: 10 | golangci-lint: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v3 15 | 16 | - name: golangci-lint 17 | uses: golangci/golangci-lint-action@v3 18 | with: 19 | args: -c .golangci.yml 20 | only-new-issues: true -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🎠 bubble-carousel 2 | 3 | [![Release](https://img.shields.io/github/v/release/xaviergodart/bubble-carousel)](https://github.com/xaviergodart/bubble-carousel/releases/latest) 4 | [![Software License](https://img.shields.io/github/license/xaviergodart/bubble-carousel)](LICENSE.md) 5 | [![Go Reference](https://pkg.go.dev/badge/github.com/xaviergodart/bubble-carousel.svg)](https://pkg.go.dev/github.com/xaviergodart/bubble-carousel) 6 | 7 | A carousel component for bubbletea applications. 8 | 9 | ![carousel gif](carousel.gif) 10 | 11 | Check the [example](examples/main.go) and the [documentation](https://pkg.go.dev/github.com/xaviergodart/bubble-carousel). -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | tags: ["v*"] 7 | pull_request: 8 | branches: [ "main" ] 9 | 10 | permissions: 11 | contents: write 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | 19 | - name: Install Go 20 | uses: actions/setup-go@v3 21 | with: 22 | go-version: 1.19 23 | 24 | - name: Go build 25 | run: go build ./... 26 | 27 | release: 28 | needs: build 29 | if: startsWith(github.ref, 'refs/tags/v') 30 | runs-on: ubuntu-latest 31 | steps: 32 | - name: Checkout 33 | uses: actions/checkout@v3 34 | - name: Create release 35 | uses: softprops/action-gh-release@v1 36 | - name: Refresh doc 37 | run: curl https://sum.golang.org/lookup/github.com/${{ github.repository }}@${{ github.ref_name }} 38 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/xaviergodart/bubble-carousel 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/charmbracelet/bubbles v0.15.0 7 | github.com/charmbracelet/bubbletea v0.23.1 8 | github.com/charmbracelet/lipgloss v0.6.0 9 | ) 10 | 11 | require ( 12 | github.com/aymanbagabas/go-osc52 v1.0.3 // indirect 13 | github.com/containerd/console v1.0.3 // indirect 14 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 15 | github.com/mattn/go-isatty v0.0.16 // indirect 16 | github.com/mattn/go-localereader v0.0.1 // indirect 17 | github.com/mattn/go-runewidth v0.0.14 // indirect 18 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect 19 | github.com/muesli/cancelreader v0.2.2 // indirect 20 | github.com/muesli/reflow v0.3.0 // indirect 21 | github.com/muesli/termenv v0.13.0 // indirect 22 | github.com/rivo/uniseg v0.2.0 // indirect 23 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab // indirect 24 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect 25 | golang.org/x/text v0.3.8 // indirect 26 | ) 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Xavier Godart 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /examples/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | carousel "github.com/xaviergodart/bubble-carousel" 8 | 9 | tea "github.com/charmbracelet/bubbletea" 10 | "github.com/charmbracelet/lipgloss" 11 | ) 12 | 13 | var baseStyle = lipgloss.NewStyle(). 14 | Margin(1, 1) 15 | 16 | type model struct { 17 | carousel carousel.Model 18 | } 19 | 20 | func (m model) Init() tea.Cmd { return nil } 21 | 22 | func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 23 | var cmd tea.Cmd 24 | switch msg := msg.(type) { 25 | case tea.WindowSizeMsg: 26 | m.carousel.SetWidth(msg.Width - 2) 27 | return m, nil 28 | 29 | case tea.KeyMsg: 30 | switch msg.String() { 31 | case "esc": 32 | if m.carousel.Focused() { 33 | m.carousel.Blur() 34 | } else { 35 | m.carousel.Focus() 36 | } 37 | case "q", "ctrl+c": 38 | return m, tea.Quit 39 | case "enter": 40 | return m, tea.Batch( 41 | tea.Printf("Let's go to %s!", m.carousel.SelectedItem()), 42 | ) 43 | } 44 | } 45 | m.carousel, cmd = m.carousel.Update(msg) 46 | return m, cmd 47 | } 48 | 49 | func (m model) View() string { 50 | var left, right string 51 | if m.carousel.HasLeftItems() { 52 | left = "◀" 53 | } 54 | if m.carousel.HasRightItems() { 55 | right = "▶" 56 | } 57 | return lipgloss.JoinHorizontal( 58 | lipgloss.Center, 59 | left, 60 | baseStyle.Render(m.carousel.View()), 61 | right, 62 | ) 63 | } 64 | 65 | func main() { 66 | nb := 20 67 | items := make([]string, 0, nb) 68 | for i := 0; i < nb; i++ { 69 | items = append(items, fmt.Sprintf("ITEM %d", i+1)) 70 | } 71 | 72 | t := carousel.New( 73 | carousel.WithItems(items), 74 | carousel.WithFocused(true), 75 | carousel.WithEvenlySpacedItems(), 76 | ) 77 | 78 | s := carousel.DefaultStyles() 79 | s.Item = s.Item.Padding(1, 1) 80 | s.Selected = s.Selected. 81 | Padding(1, 1). 82 | Foreground(lipgloss.Color("229")). 83 | Background(lipgloss.Color("57")) 84 | t.SetStyles(s) 85 | 86 | m := model{t} 87 | if _, err := tea.NewProgram(m).Run(); err != nil { 88 | fmt.Println("Error running program:", err) 89 | os.Exit(1) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 2 | github.com/aymanbagabas/go-osc52 v1.0.3 h1:DTwqENW7X9arYimJrPeGZcV0ln14sGMt3pHZspWD+Mg= 3 | github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4= 4 | github.com/charmbracelet/bubbles v0.15.0 h1:c5vZ3woHV5W2b8YZI1q7v4ZNQaPetfHuoHzx+56Z6TI= 5 | github.com/charmbracelet/bubbles v0.15.0/go.mod h1:Y7gSFbBzlMpUDR/XM9MhZI374Q+1p1kluf1uLl8iK74= 6 | github.com/charmbracelet/bubbletea v0.23.1 h1:CYdteX1wCiCzKNUlwm25ZHBIc1GXlYFyUIte8WPvhck= 7 | github.com/charmbracelet/bubbletea v0.23.1/go.mod h1:JAfGK/3/pPKHTnAS8JIE2u9f61BjWTQY57RbT25aMXU= 8 | github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= 9 | github.com/charmbracelet/lipgloss v0.6.0 h1:1StyZB9vBSOyuZxQUcUwGr17JmojPNm87inij9N3wJY= 10 | github.com/charmbracelet/lipgloss v0.6.0/go.mod h1:tHh2wr34xcHjC2HCXIlGSG1jaDF0S0atAUvBMP6Ppuk= 11 | github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= 12 | github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= 13 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 14 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 15 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 16 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 17 | github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= 18 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 19 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 20 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 21 | github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 22 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 23 | github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 24 | github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= 25 | github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 26 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= 27 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= 28 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 29 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 30 | github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= 31 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 32 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 33 | github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= 34 | github.com/muesli/termenv v0.13.0 h1:wK20DRpJdDX8b7Ek2QfhvqhRQFZ237RGRO0RQ/Iqdy0= 35 | github.com/muesli/termenv v0.13.0/go.mod h1:sP1+uffeLaEYpyOTb8pLCUctGcGLnoFjSn4YJK5e2bc= 36 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 37 | github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= 38 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 39 | github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 40 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 41 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 42 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 43 | golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 44 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab h1:2QkjZIsXupsJbJIdSjjUOgWK3aEtzyuh2mPt3l/CkeU= 45 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 46 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= 47 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 48 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 49 | golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= 50 | golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= 51 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 52 | -------------------------------------------------------------------------------- /carousel.go: -------------------------------------------------------------------------------- 1 | package carousel 2 | 3 | import ( 4 | "github.com/charmbracelet/bubbles/key" 5 | tea "github.com/charmbracelet/bubbletea" 6 | "github.com/charmbracelet/lipgloss" 7 | ) 8 | 9 | // Model defines a state for the carousel widget. 10 | type Model struct { 11 | KeyMap KeyMap 12 | 13 | items []string 14 | cursor int 15 | width int 16 | height int 17 | focus bool 18 | evenlySpaced bool 19 | styles Styles 20 | 21 | content string 22 | itemWidth int 23 | start int 24 | end int 25 | } 26 | 27 | // KeyMap defines keybindings. It satisfies to the help.KeyMap interface, which 28 | // is used to render the menu. 29 | type KeyMap struct { 30 | SelectLeft key.Binding 31 | SelectRight key.Binding 32 | } 33 | 34 | // DefaultKeyMap returns a default set of keybindings. 35 | func DefaultKeyMap() KeyMap { 36 | return KeyMap{ 37 | SelectLeft: key.NewBinding( 38 | key.WithKeys("left", "h"), 39 | key.WithHelp("←/h", "h"), 40 | ), 41 | SelectRight: key.NewBinding( 42 | key.WithKeys("right", "l"), 43 | key.WithHelp("→/l", "right"), 44 | ), 45 | } 46 | } 47 | 48 | // Styles contains style definitions for this carousel component. By default, 49 | // vthese alues are generated by DefaultStyles. 50 | type Styles struct { 51 | Item lipgloss.Style 52 | Selected lipgloss.Style 53 | } 54 | 55 | // DefaultStyles returns a set of default style definitions for this carousel. 56 | func DefaultStyles() Styles { 57 | return Styles{ 58 | Item: lipgloss.NewStyle().Padding(0, 1), 59 | Selected: lipgloss.NewStyle(). 60 | Padding(0, 1). 61 | Foreground(lipgloss.Color("212")), 62 | } 63 | } 64 | 65 | // SetStyles sets the table styles. 66 | func (m *Model) SetStyles(s Styles) { 67 | m.styles = s 68 | m.UpdateSize() 69 | } 70 | 71 | // Option is used to set options in New. For example: 72 | // 73 | // carousel := New(WithItems([]string{"Item 1", "Item 2", "Item 3"})) 74 | type Option func(*Model) 75 | 76 | // New creates a new model for the carousel widget. 77 | func New(opts ...Option) Model { 78 | m := Model{ 79 | cursor: 0, 80 | 81 | KeyMap: DefaultKeyMap(), 82 | styles: DefaultStyles(), 83 | } 84 | 85 | for _, opt := range opts { 86 | opt(&m) 87 | } 88 | 89 | m.UpdateSize() 90 | 91 | return m 92 | } 93 | 94 | // WithItems sets the carousel items (data). 95 | func WithItems(items []string) Option { 96 | return func(m *Model) { 97 | m.SetItems(items) 98 | } 99 | } 100 | 101 | // WithEvenlySpacedItems sets all items with the same width. 102 | func WithEvenlySpacedItems() Option { 103 | return func(m *Model) { 104 | m.evenlySpaced = true 105 | } 106 | } 107 | 108 | // WithHeight sets the height of the carousel. 109 | func WithHeight(h int) Option { 110 | return func(m *Model) { 111 | m.height = h 112 | } 113 | } 114 | 115 | // WithWidth sets the width of the carousel. 116 | func WithWidth(w int) Option { 117 | return func(m *Model) { 118 | m.width = w 119 | } 120 | } 121 | 122 | // WithFocused sets the focus state of the carousel. 123 | func WithFocused(f bool) Option { 124 | return func(m *Model) { 125 | m.focus = f 126 | } 127 | } 128 | 129 | // WithStyles sets the carousel styles. 130 | func WithStyles(s Styles) Option { 131 | return func(m *Model) { 132 | m.styles = s 133 | } 134 | } 135 | 136 | // WithKeyMap sets the key map. 137 | func WithKeyMap(km KeyMap) Option { 138 | return func(m *Model) { 139 | m.KeyMap = km 140 | } 141 | } 142 | 143 | // Update is the Bubble Tea update loop. 144 | func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { 145 | if !m.focus { 146 | return m, nil 147 | } 148 | 149 | switch msg := msg.(type) { 150 | case tea.KeyMsg: 151 | switch { 152 | case key.Matches(msg, m.KeyMap.SelectLeft): 153 | m.MoveLeft() 154 | case key.Matches(msg, m.KeyMap.SelectRight): 155 | m.MoveRight() 156 | } 157 | } 158 | 159 | return m, nil 160 | } 161 | 162 | // Focused returns the focus state of the carousel. 163 | func (m Model) Focused() bool { 164 | return m.focus 165 | } 166 | 167 | // Focus focuses the carousel, allowing the user to move around the items and 168 | // interact. 169 | func (m *Model) Focus() { 170 | m.focus = true 171 | m.UpdateSize() 172 | } 173 | 174 | // Blur blurs the carousel, preventing selection or movement. 175 | func (m *Model) Blur() { 176 | m.focus = false 177 | m.UpdateSize() 178 | } 179 | 180 | // View renders the component. 181 | func (m Model) View() string { 182 | return m.content 183 | } 184 | 185 | // UpdateSize updates the carousel size based on the previously defined 186 | // items and width. 187 | func (m *Model) UpdateSize() { 188 | items := make([]string, 0, len(m.items)) 189 | width := 0 190 | m.end = len(m.items) 191 | 192 | for i := range m.items { 193 | item := m.renderItem(i) 194 | 195 | if i >= m.start { 196 | width += lipgloss.Width(item) 197 | } 198 | 199 | items = append(items, item) 200 | 201 | if i <= m.cursor && width > m.width { 202 | m.start++ 203 | } 204 | 205 | if i == m.cursor && i < m.start { 206 | m.start-- 207 | 208 | width += lipgloss.Width(item) 209 | } 210 | 211 | if i > m.cursor && width > m.width { 212 | m.end = i 213 | 214 | break 215 | } 216 | } 217 | 218 | m.content = lipgloss.JoinHorizontal( 219 | lipgloss.Center, 220 | items[m.start:m.end]..., 221 | ) 222 | } 223 | 224 | // SelectedItem returns the selected item. 225 | func (m Model) SelectedItem() string { 226 | return m.items[m.cursor] 227 | } 228 | 229 | // Items returns the current items. 230 | func (m Model) Items() []string { 231 | return m.items 232 | } 233 | 234 | // SetItems sets a new items state. 235 | func (m *Model) SetItems(items []string) { 236 | m.items = items 237 | m.itemWidth = 0 238 | m.cursor = 0 239 | m.start = 0 240 | for i := range m.items { 241 | item := m.renderItem(i) 242 | m.itemWidth = max(m.itemWidth, lipgloss.Width(item)) 243 | } 244 | m.UpdateSize() 245 | } 246 | 247 | // SetWidth sets the width of the carousel. 248 | func (m *Model) SetWidth(w int) { 249 | m.width = w 250 | m.UpdateSize() 251 | } 252 | 253 | // SetHeight sets the height of the carousel. 254 | func (m *Model) SetHeight(h int) { 255 | m.height = h 256 | m.UpdateSize() 257 | } 258 | 259 | // Height returns the height of the carousel. 260 | func (m Model) Height() int { 261 | return m.height 262 | } 263 | 264 | // Width returns the width of the carousel. 265 | func (m Model) Width() int { 266 | return m.width 267 | } 268 | 269 | // Cursor returns the index of the selected row. 270 | func (m Model) Cursor() int { 271 | return m.cursor 272 | } 273 | 274 | // HasRightItems returns true if there's items left on the right. 275 | func (m Model) HasRightItems() bool { 276 | return m.end < len(m.items) 277 | } 278 | 279 | // HasLeftItems returns true if there's items left on the left. 280 | func (m Model) HasLeftItems() bool { 281 | return m.start > 0 282 | } 283 | 284 | // SetCursor sets the cursor position in the carousel. 285 | func (m *Model) SetCursor(n int) { 286 | m.cursor = clamp(n, 0, len(m.items)-1) 287 | m.UpdateSize() 288 | } 289 | 290 | // MoveLeft moves the selection left by one item.. 291 | // It can not go before the first item. 292 | func (m *Model) MoveLeft() { 293 | m.cursor = clamp(m.cursor-1, 0, len(m.items)-1) 294 | m.UpdateSize() 295 | } 296 | 297 | // MoveDown moves the selection right by one item. 298 | // It can not go after the last row. 299 | func (m *Model) MoveRight() { 300 | m.cursor = clamp(m.cursor+1, 0, len(m.items)-1) 301 | m.UpdateSize() 302 | } 303 | 304 | func (m *Model) renderItem(itemID int) string { 305 | var item string 306 | if itemID == m.cursor { 307 | item = m.styles.Selected.Render(m.items[itemID]) 308 | } else { 309 | item = m.styles.Item.Render(m.items[itemID]) 310 | } 311 | 312 | if m.evenlySpaced { 313 | return lipgloss.Place( 314 | m.itemWidth, 315 | m.height, 316 | lipgloss.Left, 317 | lipgloss.Center, 318 | item, 319 | ) 320 | } 321 | 322 | return item 323 | } 324 | 325 | func max(a, b int) int { 326 | if a > b { 327 | return a 328 | } 329 | 330 | return b 331 | } 332 | 333 | func min(a, b int) int { 334 | if a < b { 335 | return a 336 | } 337 | 338 | return b 339 | } 340 | 341 | func clamp(v, low, high int) int { 342 | return min(max(v, low), high) 343 | } 344 | --------------------------------------------------------------------------------