// The package defines an extensible TUI via the bubbletea framework. // // TODO enable collection recursing (i.e, embeded collections) // // TODO enable scroll/viewport logic on issues!!! // // While the package remains in v0.0.X releases, this TUI may be undocumented. package issues import ( "fmt" "strings" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) // Type and Style definitions ------------------------------------------------- // ---------------------------------------------------------------------------- // [lipgloss] style definitions, stores the currently displayed "widget" var ( titleStyle = lipgloss.NewStyle(). Bold(true). Underline(true) statusStyle = lipgloss.NewStyle(). Faint(true). Italic(true) variadicTitleStyle = lipgloss.NewStyle(). Align(lipgloss.Left). Italic(true) variadicDataStyle = lipgloss.NewStyle(). Width(40). BorderStyle(lipgloss.ASCIIBorder()) borderStyle = lipgloss.NewStyle(). Padding(1, 2). Margin(1). BorderStyle(lipgloss.NormalBorder()) indexStyle = lipgloss.NewStyle(). Italic(true) pointerStyle = lipgloss.NewStyle(). Faint(true) collectionStyleLeft = lipgloss.NewStyle(). Align(lipgloss.Left) ) // MAIN MODEL DEFINITIONS ----------------------------------------------------- // ---------------------------------------------------------------------------- // The main bubbletea Model type Model struct { widget widget content string Path string // viewport viewport.Model } // The bubbletea init function func (m Model) Init() tea.Cmd { return m.load } // Handles quit logic and viewport scroll and size updates func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // widget specifc keyhandling var cmds []tea.Cmd switch m.widget.(type) { case IssueCollection: if msg, ok := msg.(tea.KeyMsg); ok { switch msg.String() { case "j": if collection, ok := m.widget.(IssueCollection); ok { if collection.selection+1 < len(collection.Collection) { collection.selection = collection.selection + 1 } else { collection.selection = 0 } m.widget = collection return m, collection.render } case "k": // do something only if widget is collection if collection, ok := m.widget.(IssueCollection); ok { if collection.selection != 0 { collection.selection = collection.selection - 1 } else { collection.selection = len(collection.Collection) - 1 } m.widget = collection return m, collection.render } case "enter": if _, ok := m.widget.(IssueCollection); ok { m.Path = m.widget.(IssueCollection).Collection[m.widget.(IssueCollection).selection].Path return m, m.load } case "q": cmds = append(cmds, tea.Quit) } } } // general message handling switch msg := msg.(type) { case tea.KeyMsg: // keymsg capture that is always present switch msg.String() { case "ctrl+c": cmds = append(cmds, tea.Quit) } case widget: // widget is initialized from m.load() switch T := msg.(type) { case createIssue: m.widget = T cmds = append(cmds, T.render, T.init()) default: m.widget = T cmds = append(cmds, T.render) } case string: m.content = msg } // finally, handle input updates if any if w, ok := m.widget.(createIssue); ok { var cmd tea.Cmd m.widget, cmd = w.update(msg) cmds = append(cmds, cmd, w.render) } return m, tea.Batch(cmds...) } // Handles top level view functionality func (m Model) View() string { var output string if len(m.content) == 0 { return "loading..." } else { output = output + m.content } output = output + "\nj/k: down/up\tenter: select\tq: quit" return output } // WIDGET DEFINITIONS --------------------------------------------------------- // ---------------------------------------------------------------------------- // interface definition for widgets type widget interface { render() tea.Msg } // -------- creatIssue definitions -------------------------------------------- // ---------------------------------------------------------------------------- // data struct for createIssue type inputField struct { input textinput.Model title string } // widget for creating an issue type createIssue struct { inputFields []inputField Path string selected int Err error // behaviour undefined } func initialInputModel(path string, placeholder string) createIssue { spawnInput := func(f bool) textinput.Model { ti := textinput.New() ti.Placeholder = placeholder if f { ti.Focus() } ti.CharLimit = 80 ti.Width = 30 return ti } var inputs []inputField for i, t := range [4]string{"title", "status", "tags", "blockers"} { if i == 0 { inputs = append(inputs, inputField{title: t, input: spawnInput(true)}) } else { inputs = append(inputs, inputField{title: t, input: spawnInput(false)}) } } return createIssue{ inputFields: inputs, Path: path, selected: 0, Err: nil, } } func (ci createIssue) init() tea.Cmd { return textinput.Blink } func (ci createIssue) update(msg tea.Msg) (createIssue, tea.Cmd) { var cmds []tea.Cmd var cmd tea.Cmd // simple anon funcs to increment the selected index incrementSelected := func() { if ci.selected < len(ci.inputFields) { ci.selected++ for i := 0; i < len(ci.inputFields); i++ { if i == ci.selected { ci.inputFields[i].input.Focus() } else { ci.inputFields[i].input.Blur() } } } else { ci.selected = 0 ci.inputFields[ci.selected].input.Focus() } } decrementSelected := func() { if ci.selected != 0 { ci.selected-- for i := 0; i < len(ci.inputFields); i++ { if i == ci.selected { ci.inputFields[i].input.Focus() } else { ci.inputFields[i].input.Blur() } } } else { for i := 0; i < len(ci.inputFields); i++ { ci.inputFields[i].input.Blur() } ci.selected = len(ci.inputFields) } } switch msg := msg.(type) { // keybinding handler case tea.KeyMsg: switch msg.String() { case "tab": incrementSelected() case "shift+tab": decrementSelected() case "enter": if ci.selected == len(ci.inputFields) { // confirm create ci.selected++ } else if ci.selected == len(ci.inputFields)+1 { // confirmed cmds = append(cmds, tea.Quit) } else { incrementSelected() } case "esc": // cancel cmds = append(cmds, tea.Quit) } } for i, ti := range ci.inputFields { ci.inputFields[i].input, cmd = ti.input.Update(msg) cmds = append(cmds, cmd) } cmds = append(cmds, cmd) return ci, tea.Batch(cmds...) } func (ci createIssue) render() tea.Msg { borderStyle := lipgloss.NewStyle(). BorderStyle(lipgloss.NormalBorder()). Margin(1). Padding(0, 1) ulStyle := lipgloss.NewStyle().Underline(true) var output string for _, field := range ci.inputFields { output = output + fmt.Sprintf( "\n%s:%s", field.title, borderStyle.Render(field.input.View()), ) } output = strings.TrimLeft(output, "\n") if ci.selected < len(ci.inputFields) { output = output + borderStyle.Render("press enter to submit...") } else if ci.selected == len(ci.inputFields) { output = output + borderStyle.Render(ulStyle.Render("press enter to submit...")) } else if ci.selected == len(ci.inputFields)+1 { confirmPrompt := fmt.Sprintf( "creating issue titled \"%s\"...\n\n%s", ulStyle.Render(ci.inputFields[0].input.Value()), ulStyle.Render("press enter to confirm..."), ) output = output + borderStyle.Render(confirmPrompt) } return output } // -------- Issue widget definitions ------------------------------------------ // ---------------------------------------------------------------------------- // Handles all render logic for Issue structs func (i Issue) render() tea.Msg { var output string // title output = output + titleStyle.Render(i.Title) // status output = output + fmt.Sprintf("\n%s", statusStyle.Render(i.Status.Data)) // variadics var tags string for _, field := range i.Tags.Fields { tags = tags + field.Path + ", " } tags = strings.TrimRight(tags, ", ") var blockedby string for _, field := range i.Blockedby.Fields { blockedby = blockedby + field.Path + ", " } blockedby = strings.TrimRight(blockedby, ", ") if len(i.Tags.Fields) > 0 { output = output + variadicTitleStyle.Render("\n\nTags:") output = output + fmt.Sprintf("\n%s", variadicDataStyle.Render(tags)) } if len(i.Blockedby.Fields) > 0 { output = output + variadicTitleStyle.Render("\n\nBlockedby:") output = output + fmt.Sprintf("\n%s", variadicDataStyle.Render(blockedby)) } // description output = output + titleStyle.Render("\n\nDescription:\n") output = output + fmt.Sprintf("\n%s", i.Description.Data) return borderStyle.Render(output) } // -------- IssueCollection widget definitions -------------------------------- // ---------------------------------------------------------------------------- // Handles all render logic for IssueCollection structs. func (ic IssueCollection) render() tea.Msg { var output string var left string output = output + "Issues in " + ic.Path + "...\n\n" for i, issue := range ic.Collection { // pointer render if i == ic.selection { left = left + pointerStyle.Render("-> ") } else { left = left + pointerStyle.Render(" ") } // index render left = left + "[" + indexStyle.Render(fmt.Sprintf("%d", i+1)) + "]: " // title render left = left + fmt.Sprintf("%s\n", titleStyle.Render(issue.Title)) } output = output + collectionStyleLeft.Render(left) return output } // tea.Cmd definitions -------------------------------------------------------- // ---------------------------------------------------------------------------- // Handles load logic func (m Model) load() tea.Msg { if IsIssue(m.Path) { issue, err := Issue{}.NewFromPath(m.Path) if err != nil { return nil } return issue } if IsIssueCollection(m.Path) { collection, err := IssueCollection{}.NewFromPath(m.Path) if err != nil { return nil } return collection } return initialInputModel(m.Path, "lorem ipsum") }