Repository: charmbracelet/kancli Branch: main Commit: 00dafa62ac8c Files: 12 Total size: 16.0 KB Directory structure: gitextract_bfn0vuyb/ ├── .github/ │ └── CODEOWNERS ├── .gitignore ├── README.md ├── column.go ├── data.go ├── form.go ├── go.mod ├── go.sum ├── keys.go ├── main.go ├── model.go └── task.go ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/CODEOWNERS ================================================ * @bashbunni ================================================ FILE: .gitignore ================================================ kancli .DS_Store debug.log ================================================ FILE: README.md ================================================ # Kancli Welcome to our demo repo for a kanban board for the command line. There is a video to go along with this repo on our [YouTube channel](https://youtube.com/c/charmcli) if you would like a full walk through tutorial on the topic. ## Feedback We'd love to hear your thoughts on this tutorial. Feel free to drop us a note! * [Twitter](https://twitter.com/charmcli) * [The Fediverse](https://mastodon.social/@charmcli) * [Discord](https://charm.sh/chat) ## License [MIT](https://github.com/charmbracelet/bubbletea/raw/master/LICENSE) *** Part of [Charm](https://charm.sh). The Charm logo Charm热爱开源 • Charm loves open source ================================================ FILE: column.go ================================================ package main import ( "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) const APPEND = -1 type column struct { focus bool status status list list.Model height int width int } func (c *column) Focus() { c.focus = true } func (c *column) Blur() { c.focus = false } func (c *column) Focused() bool { return c.focus } func newColumn(status status) column { var focus bool if status == todo { focus = true } defaultList := list.New([]list.Item{}, list.NewDefaultDelegate(), 0, 0) defaultList.SetShowHelp(false) return column{focus: focus, status: status, list: defaultList} } // Init does initial setup for the column. func (c column) Init() tea.Cmd { return nil } // Update handles all the I/O for columns. func (c column) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd switch msg := msg.(type) { case tea.WindowSizeMsg: c.setSize(msg.Width, msg.Height) c.list.SetSize(msg.Width/margin, msg.Height/2) case tea.KeyMsg: switch { case key.Matches(msg, keys.Edit): if len(c.list.VisibleItems()) != 0 { task := c.list.SelectedItem().(Task) f := NewForm(task.title, task.description) f.index = c.list.Index() f.col = c return f.Update(nil) } case key.Matches(msg, keys.New): f := newDefaultForm() f.index = APPEND f.col = c return f.Update(nil) case key.Matches(msg, keys.Delete): return c, c.DeleteCurrent() case key.Matches(msg, keys.Enter): return c, c.MoveToNext() } } c.list, cmd = c.list.Update(msg) return c, cmd } func (c column) View() string { return c.getStyle().Render(c.list.View()) } func (c *column) DeleteCurrent() tea.Cmd { if len(c.list.VisibleItems()) > 0 { c.list.RemoveItem(c.list.Index()) } var cmd tea.Cmd c.list, cmd = c.list.Update(nil) return cmd } func (c *column) Set(i int, t Task) tea.Cmd { if i != APPEND { return c.list.SetItem(i, t) } return c.list.InsertItem(APPEND, t) } func (c *column) setSize(width, height int) { c.width = width / margin } func (c *column) getStyle() lipgloss.Style { if c.Focused() { return lipgloss.NewStyle(). Padding(1, 2). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("62")). Height(c.height). Width(c.width) } return lipgloss.NewStyle(). Padding(1, 2). Border(lipgloss.HiddenBorder()). Height(c.height). Width(c.width) } type moveMsg struct { Task } func (c *column) MoveToNext() tea.Cmd { var task Task var ok bool // If nothing is selected, the SelectedItem will return Nil. if task, ok = c.list.SelectedItem().(Task); !ok { return nil } // move item c.list.RemoveItem(c.list.Index()) task.status = c.status.getNext() // refresh list var cmd tea.Cmd c.list, cmd = c.list.Update(nil) return tea.Sequence(cmd, func() tea.Msg { return moveMsg{task} }) } ================================================ FILE: data.go ================================================ package main import "github.com/charmbracelet/bubbles/list" // Provides the mock data to fill the kanban board func (b *Board) initLists() { b.cols = []column{ newColumn(todo), newColumn(inProgress), newColumn(done), } // Init To Do b.cols[todo].list.Title = "To Do" b.cols[todo].list.SetItems([]list.Item{ Task{status: todo, title: "buy milk", description: "strawberry milk"}, Task{status: todo, title: "eat sushi", description: "negitoro roll, miso soup, rice"}, Task{status: todo, title: "fold laundry", description: "or wear wrinkly t-shirts"}, }) // Init in progress b.cols[inProgress].list.Title = "In Progress" b.cols[inProgress].list.SetItems([]list.Item{ Task{status: inProgress, title: "write code", description: "don't worry, it's Go"}, }) // Init done b.cols[done].list.Title = "Done" b.cols[done].list.SetItems([]list.Item{ Task{status: done, title: "stay cool", description: "as a cucumber"}, }) } ================================================ FILE: form.go ================================================ package main import ( "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/textarea" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) type Form struct { help help.Model title textinput.Model description textarea.Model col column index int } func newDefaultForm() *Form { return NewForm("task name", "") } func NewForm(title, description string) *Form { form := Form{ help: help.New(), title: textinput.New(), description: textarea.New(), } form.title.Placeholder = title form.description.Placeholder = description form.title.Focus() return &form } func (f Form) CreateTask() Task { return Task{f.col.status, f.title.Value(), f.description.Value()} } func (f Form) Init() tea.Cmd { return nil } func (f Form) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd switch msg := msg.(type) { case column: f.col = msg f.col.list.Index() case tea.KeyMsg: switch { case key.Matches(msg, keys.Quit): return f, tea.Quit case key.Matches(msg, keys.Back): return board.Update(nil) case key.Matches(msg, keys.Enter): if f.title.Focused() { f.title.Blur() f.description.Focus() return f, textarea.Blink } // Return the completed form as a message. return board.Update(f) } } if f.title.Focused() { f.title, cmd = f.title.Update(msg) return f, cmd } f.description, cmd = f.description.Update(msg) return f, cmd } func (f Form) View() string { return lipgloss.JoinVertical( lipgloss.Left, "Create a new task", f.title.View(), f.description.View(), f.help.View(keys)) } ================================================ FILE: go.mod ================================================ module github.com/charmbracelet/kancli go 1.19 require ( github.com/charmbracelet/bubbles v0.16.1 github.com/charmbracelet/bubbletea v0.24.2 github.com/charmbracelet/lipgloss v0.7.1 ) require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.19 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.14 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.15.1 // indirect github.com/rivo/uniseg v0.4.4 // indirect github.com/sahilm/fuzzy v0.1.0 // indirect golang.org/x/sync v0.3.0 // indirect golang.org/x/sys v0.9.0 // indirect golang.org/x/term v0.9.0 // indirect golang.org/x/text v0.10.0 // indirect ) ================================================ FILE: go.sum ================================================ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/charmbracelet/bubbles v0.16.1 h1:6uzpAAaT9ZqKssntbvZMlksWHruQLNxg49H5WdeuYSY= github.com/charmbracelet/bubbles v0.16.1/go.mod h1:2QCp9LFlEsBQMvIYERr7Ww2H2bA7xen1idUDIzm/+Xc= github.com/charmbracelet/bubbletea v0.24.2 h1:uaQIKx9Ai6Gdh5zpTbGiWpytMU+CfsPp06RaW2cx/SY= github.com/charmbracelet/bubbletea v0.24.2/go.mod h1:XdrNrV4J8GiyshTtx3DNuYkR1FDaJmO3l2nejekbsgg= github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZpc5Kc1E= github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c= github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.15.1 h1:UzuTb/+hhlBugQz28rpzey4ZuKcZ03MeKsoG7IJZIxs= github.com/muesli/termenv v0.15.1/go.mod h1:HeAQPTzpfs016yGtA4g00CsdYnVLJvxsS4ANqrZs2sQ= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI= github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s= golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.9.0 h1:GRRCnKYhdQrD8kfRAdQ6Zcw1P0OcELxGLKJvtjVMZ28= golang.org/x/term v0.9.0/go.mod h1:M6DEAAIenWoTxdKrOltXcmDY3rSplQUkrvaDU5FcQyo= golang.org/x/text v0.10.0 h1:UpjohKhiEgNc0CSauXmwYftY1+LlaC75SJwh0SgCX58= golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= ================================================ FILE: keys.go ================================================ package main import "github.com/charmbracelet/bubbles/key" // ShortHelp returns keybindings to be shown in the mini help view. It's part // of the key.Map interface. func (k keyMap) ShortHelp() []key.Binding { return []key.Binding{k.Help, k.Quit} } // FullHelp returns keybindings for the expanded help view. It's part of the // key.Map interface. func (k keyMap) FullHelp() [][]key.Binding { return [][]key.Binding{ {k.Up, k.Down, k.Left, k.Right}, // first column {k.Help, k.Quit}, // second column } } type keyMap struct { New key.Binding Edit key.Binding Delete key.Binding Up key.Binding Down key.Binding Right key.Binding Left key.Binding Enter key.Binding Help key.Binding Quit key.Binding Back key.Binding } var keys = keyMap{ New: key.NewBinding( key.WithKeys("n"), key.WithHelp("n", "new"), ), Edit: key.NewBinding( key.WithKeys("e"), key.WithHelp("e", "edit"), ), Delete: key.NewBinding( key.WithKeys("d"), key.WithHelp("d", "delete"), ), Up: key.NewBinding( key.WithKeys("up", "k"), key.WithHelp("↑/k", "move up"), ), Down: key.NewBinding( key.WithKeys("down", "j"), key.WithHelp("↓/j", "move down"), ), Right: key.NewBinding( key.WithKeys("right", "l"), key.WithHelp("→/l", "move right"), ), Left: key.NewBinding( key.WithKeys("left", "h"), key.WithHelp("←/h", "move left"), ), Enter: key.NewBinding( key.WithKeys("enter"), key.WithHelp("enter", "enter"), ), Help: key.NewBinding( key.WithKeys("?"), key.WithHelp("?", "toggle help"), ), Quit: key.NewBinding( key.WithKeys("q", "ctrl+c"), key.WithHelp("q/ctrl+c", "quit"), ), Back: key.NewBinding( key.WithKeys("esc"), key.WithHelp("esc", "back"), ), } ================================================ FILE: main.go ================================================ package main import ( "fmt" "os" tea "github.com/charmbracelet/bubbletea" ) type status int func (s status) getNext() status { if s == done { return todo } return s + 1 } func (s status) getPrev() status { if s == todo { return done } return s - 1 } const margin = 4 var board *Board const ( todo status = iota inProgress done ) func main() { f, err := tea.LogToFile("debug.log", "debug") if err != nil { fmt.Println(err) os.Exit(1) } defer f.Close() board = NewBoard() board.initLists() p := tea.NewProgram(board) if _, err := p.Run(); err != nil { fmt.Println(err) os.Exit(1) } } ================================================ FILE: model.go ================================================ package main import ( "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) type Board struct { help help.Model loaded bool focused status cols []column quitting bool } func NewBoard() *Board { help := help.New() help.ShowAll = true return &Board{help: help, focused: todo} } func (m *Board) Init() tea.Cmd { return nil } func (m *Board) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: var cmd tea.Cmd var cmds []tea.Cmd m.help.Width = msg.Width - margin for i := 0; i < len(m.cols); i++ { var res tea.Model res, cmd = m.cols[i].Update(msg) m.cols[i] = res.(column) cmds = append(cmds, cmd) } m.loaded = true return m, tea.Batch(cmds...) case Form: return m, m.cols[m.focused].Set(msg.index, msg.CreateTask()) case moveMsg: return m, m.cols[m.focused.getNext()].Set(APPEND, msg.Task) case tea.KeyMsg: switch { case key.Matches(msg, keys.Quit): m.quitting = true return m, tea.Quit case key.Matches(msg, keys.Left): m.cols[m.focused].Blur() m.focused = m.focused.getPrev() m.cols[m.focused].Focus() case key.Matches(msg, keys.Right): m.cols[m.focused].Blur() m.focused = m.focused.getNext() m.cols[m.focused].Focus() } } res, cmd := m.cols[m.focused].Update(msg) if _, ok := res.(column); ok { m.cols[m.focused] = res.(column) } else { return res, cmd } return m, cmd } // Changing to pointer receiver to get back to this model after adding a new task via the form... Otherwise I would need to pass this model along to the form and it becomes highly coupled to the other models. func (m *Board) View() string { if m.quitting { return "" } if !m.loaded { return "loading..." } board := lipgloss.JoinHorizontal( lipgloss.Left, m.cols[todo].View(), m.cols[inProgress].View(), m.cols[done].View(), ) return lipgloss.JoinVertical(lipgloss.Left, board, m.help.View(keys)) } ================================================ FILE: task.go ================================================ package main type Task struct { status status title string description string } func NewTask(status status, title, description string) Task { return Task{status: status, title: title, description: description} } func (t *Task) Next() { if t.status == done { t.status = todo } else { t.status++ } } // implement the list.Item interface func (t Task) FilterValue() string { return t.title } func (t Task) Title() string { return t.title } func (t Task) Description() string { return t.description }