// The package defines an extensible TUI via the bubbletea framework. // // While the package remains in v0.0.X releases, this TUI may be undocumented. // // TODO enable collection recursing (i.e, embedded collections) // // TODO enable scroll/viewport logic package issues import ( "fmt" "path/filepath" "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 } // Core tea.Model update loop func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd // 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 edit: m.widget = T cmds = append(cmds, T.render, T.init()) default: m.widget = T cmds = append(cmds, T.render) } case loadPath: m.Path = string(msg) return m, m.load case createInCollectionMsg: wg := createInCollection{Path: string(msg)} i := textinput.New() i.Placeholder = "a short title" i.Focus() i.CharLimit = 80 i.Width = 30 wg.input = i return m, func() tea.Msg { return wg } case string: m.content = msg } // finally, pass msg to widget var cmd tea.Cmd switch w := m.widget.(type) { case edit: m.widget, cmd = w.update(msg) cmds = append(cmds, cmd, w.render) case Issue: m.widget, cmd = w.update(msg) cmds = append(cmds, cmd, w.render) case IssueCollection: m.widget, cmd = w.update(msg) cmds = append(cmds, cmd, w.render) case createInCollection: 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..." } output = output + m.content output = output + lipgloss.NewStyle().Faint(true).Margin(1).Render(m.widget.keyhelp()) return output } // WIDGET DEFINITIONS --------------------------------------------------------- // ---------------------------------------------------------------------------- // interface definition for widgets type widget interface { update(tea.Msg) (widget, tea.Cmd) // implements widget specific update life cycles render() tea.Msg // renders content keyhelp() string // renders key usage } // -------- edit widget definitions ----------------------------------------- // ---------------------------------------------------------------------------- // TODO(create widget) implement description field in create.create type ( // Type definitions for use in tea.Msg life cycle for create widget. createResult Issue // type wrapper for create.create() result writeResult any // type wrapper for create.write() result editorResult struct { // type wrapper for create.editor() result err error // any error returned by InvokeEditor issue Issue // the data in the template file } ) // data struct for create widget type inputField struct { input textinput.Model title string } // struct definition for edit widget type edit struct { inputFields []inputField Path string selected int err error // not implemented } // constructor for create widget func newEditBlank(path string, placeholder string) widget { // TODO DEPRECATE 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)}) } switch t { case "title": parsed := parsePathToHuman(path) if parsed == "." { parsed = "" } inputs[i].input.SetValue(parsed) case "status": inputs[i].input.SetValue("open") } } return edit{ inputFields: inputs, Path: path, selected: 0, err: nil, } } func newEditWidget(path string) widget { // data prep var e edit var issue Issue var err error if IsIssue(path) { // if path is existing issue, load Issue from path issue, err = Issue{}.NewFromPath(path) if err != nil { return e } } else { // if path is not existing issue, create new Issue with sensible defaults issue = Issue{ Path: path, Title: parsePathToHuman(path), Status: Field{Path: "/status", Data: "open"}, } } // anon function for spawning instantiated textinput.Model's spawnInput := func(f bool) textinput.Model { ti := textinput.New() if f { ti.Focus() } ti.CharLimit = 80 ti.Width = 30 return ti } // inputFields data prep var fields []inputField var input textinput.Model // title input = spawnInput(true) input.SetValue(issue.Title) input.Placeholder = "title" fields = append(fields, inputField{input: input, title: "title"}) // status input = spawnInput(false) input.SetValue(issue.Status.Data) input.Placeholder = "status" fields = append(fields, inputField{input: input, title: "status"}) // tags input = spawnInput(false) input.SetValue(issue.Tags.AsString()) input.Placeholder = "tags, separated by comma" fields = append(fields, inputField{input: input, title: "tags"}) // blockedby input = spawnInput(false) input.SetValue(issue.Blockedby.AsString()) input.Placeholder = "blockers, separated by comma" fields = append(fields, inputField{input: input, title: "blockedby"}) e.inputFields = fields return e } // init cmd for create widget func (e edit) init() tea.Cmd { return textinput.Blink } // update cmd for create widget func (e edit) update(msg tea.Msg) (widget, tea.Cmd) { var cmds []tea.Cmd var cmd tea.Cmd // simple anon functions to increment the selected index incrementSelected := func() { if e.selected < len(e.inputFields) { e.selected++ for i := 0; i < len(e.inputFields); i++ { if i == e.selected { e.inputFields[i].input.Focus() } else { e.inputFields[i].input.Blur() } } } else { e.selected = 0 e.inputFields[e.selected].input.Focus() } } decrementSelected := func() { if e.selected != 0 { e.selected-- for i := 0; i < len(e.inputFields); i++ { if i == e.selected { e.inputFields[i].input.Focus() } else { e.inputFields[i].input.Blur() } } } else { for i := 0; i < len(e.inputFields); i++ { e.inputFields[i].input.Blur() } e.selected = len(e.inputFields) } } switch msg := msg.(type) { // keybinding handler case tea.KeyMsg: switch msg.String() { case "tab": incrementSelected() case "shift+tab": decrementSelected() case "enter": if e.selected == len(e.inputFields) { // confirm create e.selected++ } else if e.selected == len(e.inputFields)+1 { // confirmed cmds = append(cmds, e.createIssueObject) } else { incrementSelected() } case "esc": // reset for i, field := range e.inputFields { field.input.Reset() switch field.title { case "title": parsed := parsePathToHuman(e.Path) if parsed == "." { parsed = "" } field.input.SetValue(parsed) case "status": field.input.SetValue("open") } e.inputFields[i] = field } } case createResult: cmds = append(cmds, e.editBlankDescription(Issue(msg))) case editorResult: if msg.err != nil { e.err = msg.err } else { cmds = append(cmds, e.write(msg.issue)) } case writeResult: switch value := msg.(type) { case bool: if !value { } else { cmds = append(cmds, func() tea.Msg { return loadPath(e.Path) }) } case error: e.selected = -100 e.err = value } } for i, ti := range e.inputFields { e.inputFields[i].input, cmd = ti.input.Update(msg) cmds = append(cmds, cmd) } cmds = append(cmds, cmd) return e, tea.Batch(cmds...) } // A tea.Cmd to translate createIssueObject.inputs to a new Issue object func (e edit) createIssueObject() tea.Msg { data := make(map[string]string) commaSplit := func(t string) []string { s := strings.Split(t, ",") for i, v := range s { s[i] = strings.TrimLeft(v, " \t\n") s[i] = strings.TrimRight(s[i], " \t\n") s[i] = parseHumanToPath(s[i]) } return s } for _, field := range e.inputFields { data[field.title] = field.input.Value() } var newIssue = Issue{ Path: e.Path, Tags: VariadicField{Path: "/tags"}, Blockedby: VariadicField{Path: "/blockedby"}, } for key, value := range data { switch key { case "title": newIssue.Title = value if parsePathToHuman(newIssue.Path) != value { dir, _ := filepath.Split(newIssue.Path) newIssue.Path = filepath.Join(dir, value) } case "status": newIssue.Status = Field{Path: "/status", Data: value} case "description": newIssue.Description = Field{Path: "/description", Data: value} case "tags": splitTags := commaSplit(value) for _, tag := range splitTags { newIssue.Tags.Fields = append(newIssue.Tags.Fields, Field{Path: tag}) } case "blockers": splitBlockedby := commaSplit(value) for _, blocker := range splitBlockedby { newIssue.Blockedby.Fields = append( newIssue.Blockedby.Fields, Field{Path: blocker}, ) } } } return createResult(newIssue) } // Wraps a tea.Cmd function, passes an initialized Issue to WriteIssue() func (e edit) write(issue Issue) tea.Cmd { return func() tea.Msg { result, err := WriteIssue(issue, false) if err != nil { return writeResult(err) } return writeResult(result) } } // Calls InvokeEditor via EditTemplate using DescriptionTemplate, reports output // and errors // // WARNING! THIS METHOD HANGS UNTIL THE USER KILLS THE EDITOR! func (e edit) editBlankDescription(issue Issue) tea.Cmd { data, err := EditTemplate(DescriptionTemplate, InvokeEditor) var output string for _, line := range data { output = output + line } issue.Description = Field{Path: "/description", Data: output} return func() tea.Msg { return editorResult{issue: issue, err: err} } } // does this just call InvokeEditor? func (e edit) editExistingDescription(issue Issue) tea.Cmd { return func() tea.Msg { return "" } } // render cmd for create widget func (e edit) render() tea.Msg { if e.err != nil { return fmt.Sprintf("failed to create issue... %s", e.err.Error()) } borderStyle := lipgloss.NewStyle(). BorderStyle(lipgloss.NormalBorder()). Margin(1). Padding(0, 1) ulStyle := lipgloss.NewStyle().Underline(true) var output string for _, field := range e.inputFields { output = output + fmt.Sprintf( "\n%s:%s", field.title, borderStyle.Render(field.input.View()), ) } output = strings.TrimLeft(output, "\n") if e.selected < len(e.inputFields) { output = output + borderStyle.Render("press enter to submit...") } else if e.selected == len(e.inputFields) { output = output + borderStyle.Render(ulStyle.Render("press enter to submit...")) } else if e.selected == len(e.inputFields)+1 { confirmPrompt := fmt.Sprintf( "create issue titled \"%s\"?\n\n%s", ulStyle.Render(e.inputFields[0].input.Value()), ulStyle.Render("press enter to write description..."), ) output = output + borderStyle.Render(confirmPrompt) } return output } // keyhelp cmd for create widget func (e edit) keyhelp() string { var output string output = output + "\ntab/shift+tab: down/up\t\tenter: input value\t\tesc: reset\t\tctrl+e: quit" return output } // -------- Issue widget definitions ------------------------------------------ // ---------------------------------------------------------------------------- // enforce widget interface compliance func (i Issue) update(tea.Msg) (widget, tea.Cmd) { return i, nil } // render cmd for Issue widget 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) } // keyhelp cmd for Issue widget func (i Issue) keyhelp() string { var output string output = output + "\nj/k: down/up\t\tctrl+c: quit" return output } // -------- IssueCollection widget definitions -------------------------------- // ---------------------------------------------------------------------------- type ( // Type definitions for use in tea.Msg life cycle for IssueCollection widget. loadPath string // thrown when user selects a path to load. createInCollectionMsg string //thrown when user opts to create new issue in collection ) // enforce widget interface compliance func (ic IssueCollection) update(msg tea.Msg) (widget, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "j": if ic.selection+1 < len(ic.Collection) { ic.selection = ic.selection + 1 } else { ic.selection = 0 } return ic, ic.render case "k": if ic.selection != 0 { ic.selection = ic.selection - 1 } else { ic.selection = len(ic.Collection) - 1 } return ic, ic.render case "enter": ic.Path = ic.Collection[ic.selection].Path return ic, ic.sendLoad case "q": return ic, tea.Quit case "c": return ic, ic.newIssueInCollection } } return ic, nil } func (ic IssueCollection) sendLoad() tea.Msg { return loadPath(ic.Path) } func (ic IssueCollection) newIssueInCollection() tea.Msg { return createInCollectionMsg(ic.Path) } // render cmd for IssueCollection widget func (ic IssueCollection) render() tea.Msg { var output string if ic.selection == -1 { } 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 } // keyhelp cmd for IssueCollection widget func (ic IssueCollection) keyhelp() string { var output string output = output + "\nj/k: down/up\t\tc: create new issue\t\tenter: select\t\tq/ctrl+c: quit" return output } // -------- createInCollection widget definitions ----------------------------- // ---------------------------------------------------------------------------- type createInCollection struct { Path string // base path for the new issue name string // the name input by the user input textinput.Model // the input widget } func (w createInCollection) update(msg tea.Msg) (widget, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "enter": cmds = append(cmds, w.create) } } var inputCmd tea.Cmd w.input, inputCmd = w.input.Update(msg) cmds = append(cmds, inputCmd) w.name = w.input.Value() return w, tea.Batch(cmds...) } func (w createInCollection) create() tea.Msg { w.Path = filepath.Join(w.Path, w.name) return newEditBlank(w.Path, "lorem ipsum") } func (w createInCollection) render() tea.Msg { return borderStyle.Render(fmt.Sprintf("Creating new issue in %s\ntitle: %s", w.Path, w.input.View())) } func (w createInCollection) keyhelp() string { return "enter: submit\t\tctrl+c: quit" } // 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 newEditWidget(m.Path) }