├── theHermit.gif ├── examples └── list │ ├── list │ └── main.go ├── .gitignore ├── list ├── item.go ├── misc.go ├── model.go └── views.go ├── LICENSE ├── go.mod ├── README.md └── go.sum /theHermit.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Genekkion/theHermit/HEAD/theHermit.gif -------------------------------------------------------------------------------- /examples/list/list: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Genekkion/theHermit/HEAD/examples/list/list -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .main.go 2 | .main2.go 3 | theHermit 4 | Makefile 5 | area 6 | input 7 | list2 8 | fzf 9 | -------------------------------------------------------------------------------- /list/item.go: -------------------------------------------------------------------------------- 1 | package list 2 | 3 | // Based off bubble's list.item interface 4 | type Item interface { 5 | Title() string 6 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Genekkion 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 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/genekkion/theHermit 2 | 3 | go 1.22.2 4 | 5 | require ( 6 | github.com/charmbracelet/bubbles v0.18.0 7 | github.com/charmbracelet/bubbletea v0.25.0 8 | github.com/charmbracelet/lipgloss v0.10.0 9 | github.com/sahilm/fuzzy v0.1.1 10 | ) 11 | 12 | require ( 13 | github.com/atotto/clipboard v0.1.4 // indirect 14 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 15 | github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect 16 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 17 | github.com/mattn/go-isatty v0.0.18 // indirect 18 | github.com/mattn/go-localereader v0.0.1 // indirect 19 | github.com/mattn/go-runewidth v0.0.15 // indirect 20 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect 21 | github.com/muesli/cancelreader v0.2.2 // indirect 22 | github.com/muesli/reflow v0.3.0 // indirect 23 | github.com/muesli/termenv v0.15.2 // indirect 24 | github.com/rivo/uniseg v0.4.7 // indirect 25 | golang.org/x/sync v0.1.0 // indirect 26 | golang.org/x/sys v0.12.0 // indirect 27 | golang.org/x/term v0.6.0 // indirect 28 | golang.org/x/text v0.3.8 // indirect 29 | ) 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # The Hermit 🐚 2 | 3 | Insipired by quick fix lists used in Neovim, The Hermit is intended to provide a similar experience for use within the BubbleTea environment. 4 | 5 | It works by wrapping the main view, replacing the characters at certain areas with the content of the list. This enables the background view to continue updating while the list is shown. 6 | 7 | ![Demo with Moai app](./theHermit.gif) 8 | 9 | To use this module, download it using 10 | 11 | ``` 12 | go get github.com/genekkion/theHermit/list 13 | ``` 14 | 15 | And import it into your code as such 16 | 17 | ``` 18 | import listy "github.com/genekkion/theHermit/list" 19 | ``` 20 | 21 | You may want to import it under a different name than `list` as Bubble uses `list` as its package name as well. 22 | 23 | 24 | At the moment, it is tested with wrapping `fullscreen` views only, but further improvements will make use of relative sizing based off the child view. An example of how to use it is shown below. 25 | 26 | ```Go 27 | func (model Model) View() string { 28 | // Always set the child view of the list model before returning the view 29 | // The View() function will automatically render the list or not depending 30 | // on the boolean flag isShown. 31 | model.list.SetView("") 32 | return model.list.View() 33 | } 34 | ``` 35 | 36 | Examples are available in the examples folder. Clone this repository, and run `go build` before running the binaries to try them out. 37 | 38 | Feel free to raise issues or pull requests if you would like to contribute towards this project, thanks! 39 | 40 | ## Coming Soon: 41 | 42 | 1. Fuzzy finder plugin 43 | 44 | 45 | ## TODO List: 46 | 47 | 1. Make it flexible for child component dimensions instead of fullscreen 48 | 49 | 1. More testing for bugs 50 | 51 | -------------------------------------------------------------------------------- /examples/list/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "strings" 6 | 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/charmbracelet/lipgloss" 9 | "github.com/genekkion/theHermit/list" 10 | ) 11 | 12 | type MainModel struct { 13 | list list.Model 14 | selectedText string 15 | style lipgloss.Style 16 | } 17 | 18 | type ListItem struct { 19 | title string 20 | } 21 | 22 | func (item ListItem) Title() string { 23 | return item.title 24 | } 25 | 26 | func (item ListItem) FilterValue() string { 27 | return item.title 28 | } 29 | 30 | var defaultListItems = []list.Item{ 31 | ListItem{ 32 | title: "hello world!", 33 | }, 34 | ListItem{ 35 | title: "these are all items!", 36 | }, 37 | ListItem{ 38 | title: "press 'enter' to select an item", 39 | }, 40 | } 41 | 42 | func DefaultModel() MainModel { 43 | return MainModel{ 44 | selectedText: "Nothing is selected", 45 | style: lipgloss.NewStyle(). 46 | Background(lipgloss.Color("#23283B")), 47 | 48 | list: list.NewDefault(defaultListItems), 49 | } 50 | } 51 | 52 | func (model MainModel) Init() tea.Cmd { 53 | return nil 54 | } 55 | 56 | func (model MainModel) Update(message tea.Msg) (tea.Model, tea.Cmd) { 57 | switch message := message.(type) { 58 | case tea.WindowSizeMsg: 59 | // log.Println(message.Width, message.Height) 60 | model.style = model.style. 61 | Width(message.Width). 62 | Height(message.Height) 63 | listModel, _ := model.list.Update(message) 64 | model.list = listModel.(list.Model) 65 | return model, nil 66 | 67 | case tea.KeyMsg: 68 | switch message.String() { 69 | case "ctrl+c": 70 | return model, tea.Quit 71 | 72 | case "ctrl+e": 73 | model.list.SetIsShown(!model.list.GetIsShown()) 74 | return model, nil 75 | 76 | case "ctrl+r": 77 | model.list.SetIsNumbered(!model.list.GetIsNumbered()) 78 | return model, nil 79 | 80 | } 81 | 82 | if model.list.GetIsShown() { 83 | listModel, command := model.list.Update(message) 84 | model.list = listModel.(list.Model) 85 | if message.String() == "enter" { 86 | model.selectedText = model.list.GetSelectedItem().Title() 87 | } 88 | return model, command 89 | } 90 | } 91 | 92 | return model, nil 93 | } 94 | 95 | func (model MainModel) View() string { 96 | builder := strings.Builder{} 97 | builder.WriteString("Selected text: " + model.selectedText + "\n\n") 98 | builder.WriteString("Press 'ctrl+e' to toggle the simple list\n") 99 | builder.WriteString("Press 'ctrl+r' to toggle the numbering\n") 100 | builder.WriteString("Press 'ctrl+c' to quit!\n") 101 | 102 | currentHeight := lipgloss.Height(builder.String()) 103 | for range model.style.GetHeight() - currentHeight { 104 | builder.WriteString("-\n") 105 | } 106 | builder.WriteString("-") 107 | 108 | model.list.SetView(model.style.Render(builder.String())) 109 | return model.list.View() 110 | } 111 | 112 | func main() { 113 | program := tea.NewProgram( 114 | DefaultModel(), 115 | tea.WithAltScreen(), 116 | ) 117 | _, err := program.Run() 118 | if err != nil { 119 | log.Fatal(err) 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /list/misc.go: -------------------------------------------------------------------------------- 1 | package list 2 | 3 | import ( 4 | "github.com/charmbracelet/lipgloss" 5 | ) 6 | 7 | func (model Model) GetIsNumbered() bool { 8 | return model.isNumbered 9 | } 10 | 11 | func (model *Model) SetIsNumbered(isNumbered bool) { 12 | model.isNumbered = isNumbered 13 | } 14 | 15 | func (model Model) GetIsShown() bool { 16 | return model.isShown 17 | } 18 | 19 | func (model *Model) SetIsShown(isShown bool) { 20 | model.isShown = isShown 21 | } 22 | 23 | func (model Model) GetHeight() int { 24 | return model.height 25 | } 26 | 27 | func (model *Model) SetHeight(height int) { 28 | model.height = height 29 | } 30 | 31 | func (model Model) GetWidth() int { 32 | return model.width 33 | } 34 | 35 | func (model *Model) SetWidth(width int) { 36 | model.width = width 37 | } 38 | 39 | func (model Model) GetMaxHeight() int { 40 | return model.maxHeight 41 | } 42 | 43 | func (model *Model) SetMaxHeight(maxHeight int) { 44 | model.maxHeight = maxHeight 45 | } 46 | 47 | func (model Model) GetMaxWidth() int { 48 | return model.maxWidth 49 | } 50 | 51 | func (model *Model) SetMaxWidth(maxWidth int) { 52 | model.maxWidth = maxWidth 53 | } 54 | 55 | func (model Model) GetSelectedItem() Item { 56 | return model.items[model.cursor] 57 | } 58 | 59 | func (model Model) Cursor() int { 60 | return model.cursor 61 | } 62 | 63 | func (model Model) SetCursor(cursor int) Model { 64 | model.cursor = max(min(cursor, len(model.items)-1), 0) 65 | return model 66 | } 67 | 68 | func (model Model) GetItems() []Item { 69 | return model.items 70 | } 71 | 72 | func (model *Model) SetItems(items []Item) { 73 | model.items = items 74 | } 75 | 76 | func (model Model) Title() string { 77 | return model.title 78 | } 79 | 80 | func (model *Model) SetTitle(title string) { 81 | model.title = title 82 | } 83 | 84 | func (model *Model) SetView(view string) { 85 | model.view = view 86 | } 87 | 88 | func (model Model) GetView() string { 89 | return model.view 90 | } 91 | 92 | func (model *Model) SetBorder(border lipgloss.Border) { 93 | model.border = border 94 | } 95 | 96 | func (model Model) GetBorder() lipgloss.Border { 97 | return model.border 98 | } 99 | 100 | func (model *Model) SetBorderForeground(color lipgloss.Color) { 101 | model.borderStyle = model.borderStyle.Foreground(color) 102 | } 103 | 104 | func (model *Model) SetBorderBackground(color lipgloss.Color) { 105 | // model.borderStyle = model.borderStyle.BorderBackground(color) 106 | model.borderStyle = model.borderStyle.Background(color) 107 | } 108 | 109 | func (model *Model) SetTitleForeground(color lipgloss.Color) { 110 | model.titleStyle = model.titleStyle.Foreground(color) 111 | } 112 | 113 | func (model *Model) SetTitleBackground(color lipgloss.Color) { 114 | model.titleStyle = model.titleStyle.Background(color) 115 | } 116 | 117 | func (model *Model) SetTitleBold(bold bool) { 118 | model.titleStyle = model.titleStyle.Bold(bold) 119 | } 120 | 121 | func (model *Model) SetItemForeground(color lipgloss.Color) { 122 | model.itemStyle = model.itemStyle.Foreground(color) 123 | } 124 | 125 | func (model *Model) SetItemBackground(color lipgloss.Color) { 126 | model.itemStyle = model.itemStyle.Background(color) 127 | } 128 | 129 | func (model *Model) SetItemBold(bold bool) { 130 | model.itemStyle = model.itemStyle.Bold(bold) 131 | } 132 | 133 | func (model *Model) SetSelectedForeground(color lipgloss.Color) { 134 | model.selectedStyle = model.selectedStyle.Foreground(color) 135 | } 136 | 137 | func (model *Model) SetSelectedBackground(color lipgloss.Color) { 138 | model.selectedStyle = model.selectedStyle.Background(color) 139 | } 140 | 141 | func (model *Model) SetSelectedBold(bold bool) { 142 | model.selectedStyle = model.selectedStyle.Bold(bold) 143 | } 144 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 2 | github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 3 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 4 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 5 | github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0= 6 | github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw= 7 | github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM= 8 | github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg= 9 | github.com/charmbracelet/lipgloss v0.10.0 h1:KWeXFSexGcfahHX+54URiZGkBFazf70JNMtwg/AFW3s= 10 | github.com/charmbracelet/lipgloss v0.10.0/go.mod h1:Wig9DSfvANsxqkRsqj6x87irdy123SR4dOXlKa91ciE= 11 | github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= 12 | github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= 13 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 14 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 15 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 16 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 17 | github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= 18 | github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 22 | github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= 23 | github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 24 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= 25 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= 26 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 27 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 28 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 29 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 30 | github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= 31 | github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= 32 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 33 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 34 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 35 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 36 | github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= 37 | github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 38 | golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= 39 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 40 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 41 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 42 | golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= 43 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 44 | golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw= 45 | golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= 46 | golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= 47 | golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= 48 | -------------------------------------------------------------------------------- /list/model.go: -------------------------------------------------------------------------------- 1 | package list 2 | 3 | import ( 4 | tea "github.com/charmbracelet/bubbletea" 5 | "github.com/charmbracelet/lipgloss" 6 | ) 7 | 8 | type Model struct { 9 | isShown bool 10 | 11 | // Height & width of the entire model 12 | height int 13 | width int 14 | 15 | // Max limits on the height & width 16 | maxHeight int 17 | maxWidth int 18 | 19 | // Window dimensions which are updated during 20 | // the Update function 21 | windowHeight int 22 | windowWidth int 23 | 24 | // Precalculated leftPadding length during 25 | // window resizes 26 | leftPadding int 27 | 28 | // Misc details 29 | title string 30 | isNumbered bool 31 | 32 | // Working mechanisms for the list 33 | cursor int 34 | offset int 35 | items []Item 36 | view string 37 | 38 | // Styles 39 | borderStyle lipgloss.Style 40 | titleStyle lipgloss.Style 41 | selectedStyle lipgloss.Style 42 | itemStyle lipgloss.Style 43 | 44 | // Border is specificied seperately and DOES NOT 45 | // come from any of the above styles 46 | border lipgloss.Border 47 | } 48 | 49 | // Initialises a List with sensible defaults. 50 | func NewDefault(items []Item) Model { 51 | width := 81 52 | 53 | return Model{ 54 | cursor: 0, 55 | height: 14, 56 | width: width, 57 | maxHeight: 14, 58 | maxWidth: width, 59 | isNumbered: true, 60 | isShown: false, 61 | 62 | items: items, 63 | offset: 0, 64 | 65 | border: lipgloss.NormalBorder(), 66 | borderStyle: lipgloss.NewStyle(). 67 | Background(lipgloss.Color("#16161D")). 68 | BorderBackground(lipgloss.Color("#16161D")). 69 | Foreground(lipgloss.Color("#2D4F67")), 70 | titleStyle: lipgloss.NewStyle(). 71 | Foreground(lipgloss.Color("#7FB4CA")). 72 | Bold(true), 73 | itemStyle: lipgloss.NewStyle(). 74 | Width(width - 2). 75 | Background(lipgloss.Color("#16161D")). 76 | Foreground(lipgloss.Color("#DCD7BA")), 77 | selectedStyle: lipgloss.NewStyle(). 78 | Width(width - 2). 79 | Background(lipgloss.Color("#DCD7BA")). 80 | Foreground(lipgloss.Color("#1F1F28")). 81 | Bold(true), 82 | } 83 | } 84 | 85 | // Initialises a List instance 86 | func New(height int, width int, items []Item) Model { 87 | return Model{ 88 | cursor: 0, 89 | height: height, 90 | width: width, 91 | maxHeight: height, 92 | maxWidth: width, 93 | isNumbered: true, 94 | isShown: false, 95 | 96 | items: items, 97 | offset: 0, 98 | 99 | border: lipgloss.NormalBorder(), 100 | borderStyle: lipgloss.NewStyle(). 101 | Background(lipgloss.Color("#16161D")). 102 | BorderBackground(lipgloss.Color("#16161D")). 103 | BorderForeground(lipgloss.Color("#2D4F67")), 104 | titleStyle: lipgloss.NewStyle(). 105 | Foreground(lipgloss.Color("#7FB4CA")). 106 | Bold(true), 107 | itemStyle: lipgloss.NewStyle(). 108 | Width(width - 2). 109 | BorderBackground(lipgloss.Color("#16161D")). 110 | BorderForeground(lipgloss.Color("#2D4F67")), 111 | selectedStyle: lipgloss.NewStyle(). 112 | Width(width - 2). 113 | Background(lipgloss.Color("#DCD7BA")). 114 | Foreground(lipgloss.Color("#1F1F28")). 115 | Bold(true), 116 | } 117 | } 118 | 119 | func (model Model) Init() tea.Cmd { 120 | return nil 121 | } 122 | 123 | func (model Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { 124 | switch message := message.(type) { 125 | case tea.WindowSizeMsg: 126 | // Updates the height and width accordingly to fit 127 | // fit all screen sizes. 128 | // WARN: May be buggy for really tiny screens :( 129 | if message.Height >= model.height { 130 | model.height = min(model.maxHeight, message.Height) 131 | } else { 132 | model.height = min(model.height, message.Height) 133 | } 134 | 135 | if message.Width >= model.width { 136 | model.width = min(model.maxWidth, message.Width) 137 | } else { 138 | model.width = min(model.width, message.Width) 139 | } 140 | 141 | model.leftPadding = (message.Width - model.width) / 2 142 | model.windowHeight = message.Height 143 | model.windowWidth = message.Width 144 | 145 | case tea.KeyMsg: 146 | switch message.String() { 147 | case "up": 148 | if model.cursor > 0 { 149 | model.cursor-- 150 | if model.cursor < model.offset { 151 | model.offset-- 152 | } 153 | } 154 | case "down": 155 | availableHeight := model.height - 2 156 | if len(model.items) <= availableHeight { 157 | if model.cursor < len(model.items)-1 { 158 | model.cursor++ 159 | } 160 | } else { 161 | limit := model.offset + availableHeight 162 | if model.cursor >= model.offset && model.cursor < limit { 163 | model.cursor++ 164 | } else if model.cursor >= limit && limit < len(model.items)-1 { 165 | model.cursor++ 166 | model.offset++ 167 | } 168 | } 169 | } 170 | } 171 | return model, nil 172 | } 173 | -------------------------------------------------------------------------------- /list/views.go: -------------------------------------------------------------------------------- 1 | package list 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | 8 | "github.com/charmbracelet/lipgloss" 9 | ) 10 | 11 | var ( 12 | // Regex for identifiying styles used. Mainly used for 13 | // splitting the styles within a single line. 14 | colorRegex = regexp.MustCompile("\033\\[[0-9;]+m") 15 | ) 16 | 17 | // Writes the left padding for the list to the string builder. 18 | func (model *Model) writeLeftPadding(stringBuilder *strings.Builder, chars *[]string) { 19 | index := 0 20 | limit := min(model.windowWidth, model.leftPadding) 21 | 22 | // Iterate through the list by width due to 23 | // ANSI codes placed within the line 24 | for lipgloss.Width(stringBuilder.String()) < limit { 25 | stringBuilder.WriteString((*chars)[index]) 26 | index++ 27 | } 28 | // When content is less than the spacer 29 | for lipgloss.Width(stringBuilder.String()) < model.leftPadding { 30 | stringBuilder.WriteByte(' ') 31 | } 32 | } 33 | 34 | // Auxillary function used by writeRightPadding to find the 35 | // valid chars at the end of the line. 36 | func paddingLength(array []string) int { 37 | stringBuilder := strings.Builder{} 38 | for _, char := range array { 39 | stringBuilder.WriteString(char) 40 | } 41 | return lipgloss.Width(stringBuilder.String()) 42 | } 43 | 44 | // Searches through an array produced by a regex's 45 | // FindAllStringIndex function, to check if the extra 46 | // bytes are from ANSI codes. 47 | func isCode(regexMatches [][]int, index int) bool { 48 | for _, match := range regexMatches { 49 | if index >= match[0] && index <= match[1] { 50 | return true 51 | } 52 | } 53 | return false 54 | } 55 | 56 | // Writes the right padding for the list to the string builder. 57 | func (model *Model) writeRightPadding(stringBuilder *strings.Builder, chars *[]string, 58 | line *string) { 59 | 60 | currentWidth := lipgloss.Width(stringBuilder.String()) 61 | if currentWidth == model.windowWidth { 62 | stringBuilder.WriteByte('\n') 63 | return 64 | } 65 | 66 | colorCodes := colorRegex.FindAllStringIndex(*line, -1) 67 | 68 | rightPaddingLength := model.windowWidth - currentWidth - 1 69 | index := len(*chars) - 1 70 | rightPadding := []string{} 71 | for isCode(colorCodes, index) || paddingLength(rightPadding) < rightPaddingLength { 72 | rightPadding = append([]string{(*chars)[index]}, rightPadding...) 73 | index-- 74 | } 75 | if len(colorCodes) != 0 { 76 | colorIndexStart := colorCodes[0][0] 77 | colorIndexEnd := colorCodes[0][1] 78 | for i, code := range colorCodes { 79 | if i == 0 { 80 | continue 81 | } else if code[0] < index { 82 | colorIndexStart = code[0] 83 | colorIndexEnd = code[1] 84 | } else { 85 | break 86 | } 87 | } 88 | for _, char := range (*chars)[colorIndexStart:colorIndexEnd] { 89 | stringBuilder.WriteString(char) 90 | } 91 | } 92 | for _, char := range (*chars)[index:] { 93 | stringBuilder.WriteString(char) 94 | } 95 | stringBuilder.WriteByte('\n') 96 | } 97 | 98 | // Returns the string for the top border of the list, 99 | // accounting for the background text. 100 | func (model *Model) topBorder(line *string) string { 101 | text := strings.Builder{} 102 | chars := strings.Split(*line, "") 103 | 104 | // Left padding from background 105 | model.writeLeftPadding(&text, &chars) 106 | 107 | // Account for border 108 | availableWidth := model.width - 2 109 | 110 | title := "" 111 | 112 | // Account for no title 113 | if len(model.title) > 0 { 114 | title = fmt.Sprintf(" %s ", model.title) 115 | } 116 | 117 | // Account for title overflow 118 | if lipgloss.Width(title) > availableWidth { 119 | title = title[:availableWidth] 120 | } 121 | 122 | stringBuilder := strings.Builder{} 123 | 124 | // Start writing left side 125 | stringBuilder.WriteString(model.border.TopLeft) 126 | for range (availableWidth - lipgloss.Width(title)) / 2 { 127 | stringBuilder.WriteString(model.border.Top) 128 | } 129 | 130 | // Write title 131 | stringBuilder.WriteString(model.titleStyle.Render(title)) 132 | text.WriteString(model.borderStyle.Render(stringBuilder.String())) 133 | 134 | // Write right side 135 | borderLimit := availableWidth - lipgloss.Width(stringBuilder.String()) 136 | borderCounter := 0 137 | stringBuilder.Reset() 138 | for borderCounter <= borderLimit { 139 | stringBuilder.WriteString(model.border.Top) 140 | borderCounter++ 141 | } 142 | stringBuilder.WriteString(model.border.TopRight) 143 | 144 | text.WriteString(model.borderStyle.Render(stringBuilder.String())) 145 | 146 | // Right padding from background 147 | model.writeRightPadding(&text, &chars, line) 148 | return text.String() 149 | } 150 | 151 | // Returns the string for the bottom border of the list, 152 | // accounting for the background text. 153 | func (model *Model) bottomBorder(line *string) string { 154 | text := strings.Builder{} 155 | chars := strings.Split(*line, "") 156 | 157 | // Left padding from background 158 | model.writeLeftPadding(&text, &chars) 159 | 160 | // Build the border 161 | text.WriteString(model.borderStyle.Render(model.border.BottomLeft)) 162 | for range model.width - 2 { 163 | text.WriteString(model.borderStyle.Render(model.border.Bottom)) 164 | } 165 | text.WriteString(model.borderStyle.Render(model.border.BottomRight)) 166 | 167 | // Right padding from background 168 | model.writeRightPadding(&text, &chars, line) 169 | return text.String() 170 | } 171 | 172 | // Returns the string for the items on the list, 173 | // accounting for background text. 174 | func (model *Model) middleBorder(line *string, item *Item, index int) string { 175 | text := strings.Builder{} 176 | chars := strings.Split(*line, "") 177 | 178 | // Left padding from background 179 | model.writeLeftPadding(&text, &chars) 180 | 181 | // Left border 182 | text.WriteString(model.borderStyle.Render(model.border.Left)) 183 | 184 | var itemText string 185 | 186 | // Account for item numbering 187 | if model.isNumbered { 188 | itemText = fmt.Sprintf("%d. %s", index+1, (*item).Title()) 189 | if lipgloss.Width(itemText) > model.width-2 { 190 | itemText = itemText[:model.width-2] 191 | } 192 | 193 | // Account for selected item 194 | if index == model.cursor { 195 | itemText = model.selectedStyle. 196 | Bold(true). 197 | Render(itemText) 198 | } else { 199 | itemText = model.itemStyle.Render(itemText) 200 | } 201 | 202 | } else { 203 | itemText = (*item).Title() 204 | if lipgloss.Width(itemText) > model.width-2 { 205 | itemText = itemText[:model.width-2] 206 | } 207 | 208 | // Account for selected item 209 | if index == model.cursor { 210 | itemText = model.selectedStyle. 211 | Bold(true). 212 | Render(itemText) 213 | } else { 214 | itemText = model.itemStyle.Render(itemText) 215 | } 216 | } 217 | 218 | text.WriteString(itemText) 219 | 220 | // Build the empty space remaining in the row 221 | spacer := strings.Builder{} 222 | for range model.width - lipgloss.Width(itemText) - 2 { 223 | spacer.WriteByte(' ') 224 | } 225 | text.WriteString(model.borderStyle.Render(spacer.String())) 226 | 227 | // Right border 228 | text.WriteString(model.borderStyle.Render(model.border.Right)) 229 | 230 | // Right padding from background 231 | model.writeRightPadding(&text, &chars, line) 232 | 233 | return text.String() 234 | } 235 | 236 | // FOr use when there are lesser items than there are rows. 237 | func (model Model) middleSpacer(line *string) string { 238 | text := strings.Builder{} 239 | chars := strings.Split(*line, "") 240 | 241 | // Left padding from background 242 | model.writeLeftPadding(&text, &chars) 243 | 244 | // Left border 245 | text.WriteString(model.borderStyle.Render(model.border.Left)) 246 | 247 | // Build the spacer 248 | spacer := strings.Builder{} 249 | for range model.width - 2 { 250 | spacer.WriteByte(' ') 251 | } 252 | text.WriteString(model.borderStyle.Render(spacer.String())) 253 | 254 | // Right border 255 | text.WriteString(model.borderStyle.Render(model.border.Right)) 256 | 257 | // Right padding from background 258 | model.writeRightPadding(&text, &chars, line) 259 | 260 | return text.String() 261 | } 262 | 263 | // The main view for the list. 264 | func (model Model) View() string { 265 | if !model.isShown { 266 | return model.GetView() 267 | } 268 | if model.windowWidth == 0 || model.windowHeight == 0 { 269 | return "" 270 | } 271 | 272 | text := strings.Builder{} 273 | 274 | // Needs to be converted to array and processed 275 | // line by line 276 | arrayView := strings.Split(model.view, "\n") 277 | 278 | // Calculate where to insert the list 279 | midPoint1 := model.windowHeight/2 - model.height/2 + 1 280 | midPoint2 := midPoint1 + model.height 281 | 282 | // Get items based on offset, due to cursor going 283 | // beyond the initial list 284 | var items []Item 285 | if len(model.items) <= model.height-2 { 286 | items = model.items 287 | } else { 288 | items = model.items[model.offset : model.offset+model.height-1] 289 | } 290 | itemLength := len(items) 291 | 292 | itemIndex := 0 293 | length := len(arrayView) - 1 294 | for i, line := range arrayView { 295 | switch { 296 | case i == midPoint1: 297 | text.WriteString(model.topBorder(&line)) 298 | case i == midPoint2: 299 | text.WriteString(model.bottomBorder(&line)) 300 | case i > midPoint1 && i < midPoint2: 301 | if itemIndex < itemLength { 302 | text.WriteString( 303 | model.middleBorder( 304 | &line, 305 | &items[itemIndex], 306 | itemIndex+model.offset, 307 | ), 308 | ) 309 | itemIndex++ 310 | } else { 311 | text.WriteString(model.middleSpacer(&line)) 312 | } 313 | default: 314 | text.WriteString(line) 315 | if i < length { 316 | text.WriteByte('\n') 317 | } 318 | } 319 | } 320 | return text.String() 321 | } 322 | --------------------------------------------------------------------------------