// 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) package issues import ( "fmt" "os" "path/filepath" "strings" "charm.land/bubbles/v2/viewport" "charm.land/lipgloss/v2" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" ) // Type and Style definitions ------------------------------------------------- // ---------------------------------------------------------------------------- // [lipgloss] style definitions, stores the currently displayed "widget" var ( titleStyle = lipgloss.NewStyle(). Width(80). Bold(true). Underline(true) detailStyle = lipgloss.NewStyle(). Width(80). Faint(true). Italic(true) variadicMarginStyle = lipgloss.NewStyle(). Padding(0, 1) variadicDataStyle = lipgloss.NewStyle(). Padding(0, 1). BorderForeground(lipgloss.Color("8")). Italic(true). BorderStyle(lipgloss.ASCIIBorder()) borderStyle = lipgloss.NewStyle(). BorderForeground(lipgloss.Color("8")). Width(80). Padding(1, 2). Margin(1). BorderStyle(lipgloss.NormalBorder()) pointerStyle = lipgloss.NewStyle(). Faint(true) collectionStyleLeft = lipgloss.NewStyle(). Width(80). Align(lipgloss.Left) ) // MAIN MODEL DEFINITIONS ----------------------------------------------------- // ---------------------------------------------------------------------------- // The main bubbletea Model type Model struct { widget widget content string Path string viewport viewport.Model viewportReady bool width int } // update signal types type ( validateMsg bool deleteResult string deletePath string ) // 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.WindowSizeMsg: m.width = msg.Width var headerHeight int headerHeight = lipgloss.Height(m.header()) var footerHeight int if len(m.content) > 0 { footerHeight = lipgloss.Height(m.footer()) } verticalMarginHeight := headerHeight + footerHeight + 5 if !m.viewportReady { m.viewport = viewport.New( viewport.WithWidth(msg.Width), viewport.WithHeight(msg.Height-verticalMarginHeight), ) m.viewport.YPosition = headerHeight m.viewport.SetContent(m.content) m.viewportReady = true } else { m.viewport.SetWidth(msg.Width) m.viewport.SetHeight(msg.Height - verticalMarginHeight) } case tea.KeyMsg: // KeyMsg capture that is always present switch msg.String() { case "ctrl+c": cmds = append(cmds, tea.Quit) } switch msg.Type { case tea.KeyUp: m.viewport.ScrollUp(1) case tea.KeyDown: m.viewport.ScrollDown(1) } 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 setTitleMsg: wg := setTitle{Path: string(msg)} i := textinput.New() i.Placeholder = "a short title" i.Focus() i.CharLimit = 80 i.Width = 30 wg.input = i cmds = append(cmds, func() tea.Msg { return wg }) case deletePath: wg := confirmDelete{Path: string(msg), prompt: "Delete", validateString: "yes"} i := textinput.New() i.Placeholder = "yes" i.Focus() i.CharLimit = 80 i.Width = 30 wg.input = i cmds = append(cmds, func() tea.Msg { return wg }) case string: m.content = msg m.viewport.SetContent(m.content) cmds = append(cmds, tea.EnterAltScreen) } // 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) case Issue: m.widget, cmd = w.update(msg) cmds = append(cmds, cmd) case IssueCollection: m.widget, cmd = w.update(msg) cmds = append(cmds, cmd) case setTitle: m.widget, cmd = w.update(msg) cmds = append(cmds, cmd) case confirmDelete: m.widget, cmd = w.update(msg) cmds = append(cmds, cmd) } 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 = lipgloss.JoinVertical(lipgloss.Left, m.header(), m.viewport.View(), m.footer(), ) return output } } // 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) } // renders a header for the program func (m Model) header() string { var borderStyle = lipgloss.NewStyle(). BorderStyle(lipgloss.NormalBorder()). BorderForeground(lipgloss.Color("8")). Margin(1, 0, 0, 0). Padding(0, 1) title := "tissues v0.0" title = borderStyle.Render(title) line := strings.Repeat("─", max(0, m.viewport.Width()-lipgloss.Width(title))) line = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render(line) return lipgloss.JoinHorizontal(lipgloss.Center, line, title) } // renders a footer for the program func (m Model) footer() string { footerStyle := lipgloss.NewStyle().Faint(true). BorderStyle(lipgloss.NormalBorder()). BorderForeground(lipgloss.Color("8")). Margin(1, 0, 0, 0). Padding(0, 1) footerLeft := footerStyle.Render(m.widget.keyhelp()) footerRight := footerStyle.Render("j/k: scroll\n\nctrl+c: quit") line := strings.Repeat("─", max(0, m.viewport.Width()-lipgloss.Width(footerLeft)-lipgloss.Width(footerRight))) line = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render(line) var footer string footer = lipgloss.JoinHorizontal(lipgloss.Center, footerLeft, line, footerRight, ) if lipgloss.Width(footer) > m.viewport.Width() { footer = lipgloss.JoinVertical( lipgloss.Left, lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render(strings.Repeat("-", max(0, m.viewport.Width()))), footerLeft, footerRight, ) } return footer } // 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 ----------------------------------------- // ---------------------------------------------------------------------------- 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 existing bool // flag to edit existing description err error // not implemented } // constructor for the edit widget 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 } e.existing = true } 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: "blockers"}) e.inputFields = fields e.Path = path 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 } } cmds = append(cmds, e.render) case createResult: if !e.existing { cmds = append(cmds, tea.ExitAltScreen, e.editBlankDescription(Issue(msg))) } else { cmds = append(cmds, tea.ExitAltScreen, e.editExistingDescription(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...) } // 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(). BorderForeground(lipgloss.Color("8")). 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 + "tab/shift+tab: down/up\t\tenter: input value\n\nesc: reset\t\tctrl+c: quit" return output } // 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, parseHumanToPath(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 { if e.existing { err := issue.Tags.CleanDisk(issue, false) if err != nil { return writeResult(err) } err = issue.Blockedby.CleanDisk(issue, false) if err != nil { return writeResult(err) } } result, err := WriteIssue(issue, true) 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} } } // Calls InvokeEditor with e.Path, reports output via ReadTemplate func (e edit) editExistingDescription(issue Issue) tea.Cmd { err := InvokeEditor(filepath.Join(e.Path, "description")) if err != nil { return func() tea.Msg { return editorResult{issue: issue, err: err} } } data, err := ReadTemplate(filepath.Join(e.Path, "description")) 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} } } // -------- Issue widget definitions ------------------------------------------ // ---------------------------------------------------------------------------- // enforce widget interface compliance func (i Issue) update(msg tea.Msg) (widget, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "e": cmds = append(cmds, i.edit) case "esc": cmds = append(cmds, i.back) case "d": return i, func() tea.Msg { return deletePath(i.Path) } } } return i, tea.Batch(cmds...) } // 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\n", detailStyle.Render(i.Status.Data)) // variadics var tags string for _, field := range i.Tags.Fields { tags = tags + parsePathToHuman(field.Path) + ", " } tags = strings.TrimRight(tags, ", ") var blockedby string for _, field := range i.Blockedby.Fields { blockedby = blockedby + parsePathToHuman(field.Path) + ", " } blockedby = strings.TrimRight(blockedby, ", ") var tagsString string // placeholder for variadic styling if len(i.Tags.Fields) > 0 { tagsString = tagsString + "\nTags:\n" tagsString = tagsString + variadicDataStyle.Render(tags) tagsString = variadicMarginStyle.Render(tagsString) } output = output + tagsString var blockersString string // placeholder for variadic styling if len(i.Blockedby.Fields) > 0 { blockersString = blockersString + "\nBlockedby:\n" blockersString = blockersString + variadicDataStyle.Render("%s", blockedby) blockersString = variadicMarginStyle.Render(blockersString) } output = output + blockersString // description output = output + titleStyle.Render("\n\nDescription:\n") output = output + fmt.Sprintf("\n%s", i.Description.Data) return lipgloss.Wrap(output, 80, "\n") } // keyhelp cmd for Issue widget func (i Issue) keyhelp() string { var output string var escStr string remainder, _ := filepath.Split(i.Path) if IsIssueCollection(remainder) { escStr = "esc: back\t\t" } output = output + fmt.Sprintf("e: edit\t\td: delete\n\n%sctrl+c: quit", escStr) return output } func (i Issue) edit() tea.Msg { return newEditWidget(i.Path) } func (i Issue) back() tea.Msg { remainder, _ := filepath.Split(i.Path) remainder = strings.TrimRight(remainder, "/") if len(remainder) == 0 { return nil } return loadPath(remainder) } // -------- 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. setTitleMsg 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 "tab": if ic.selection+1 < len(ic.Collection) { ic.selection = ic.selection + 1 } else { ic.selection = 0 } return ic, ic.render case "shift+tab": 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 "c": return ic, func() tea.Msg { return setTitleMsg(ic.Path) } case "e": ic.Path = ic.Collection[ic.selection].Path return ic, ic.edit case "d": ic.Path = ic.Collection[ic.selection].Path return ic, func() tea.Msg { return deletePath(ic.Path) } } } return ic, nil } // render cmd for IssueCollection widget func (ic IssueCollection) render() tea.Msg { var output string var left string output = output + "\nIssues 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)) left = left + detailStyle.Render(fmt.Sprintf(" %s: %s", "tags", lipgloss.NewStyle().Italic(true).Render(issue.Tags.AsString()))) left = left + detailStyle.Render(fmt.Sprintf("\n %s: %s", "blockers", lipgloss.NewStyle().Italic(true).Render(issue.Blockedby.AsString()))) } output = output + collectionStyleLeft.Render(left) return output } // keyhelp cmd for IssueCollection widget func (ic IssueCollection) keyhelp() string { var output string output = output + "tab/shift+tab: select\t\tenter: view\nc: create\t\td: delete\ne: edit" return output } func (ic IssueCollection) sendLoad() tea.Msg { return loadPath(ic.Path) } func (ic IssueCollection) edit() tea.Msg { return newEditWidget(ic.Path) } // -------- setTitle widget definitions --------------------------------------- // ---------------------------------------------------------------------------- type setTitle struct { Path string // base path for the new issue name string input textinput.Model // the input widget } func (w setTitle) update(msg tea.Msg) (widget, tea.Cmd) { var cmds []tea.Cmd i, cmd := w.input.Update(msg) w.input = i w.name = i.Value() cmds = append(cmds, cmd) switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "enter": cmds = append(cmds, w.create) } cmds = append(cmds, w.render) } return w, tea.Batch(cmds...) } func (w setTitle) render() tea.Msg { var header string if len(w.Path) == 0 { header = "Setting title for issue..." } else { header = fmt.Sprintf("Setting title for issue in %s", w.Path) } return borderStyle.Render(fmt.Sprintf("%s\ntitle: %s", header, w.input.View())) } func (w setTitle) keyhelp() string { return "enter: submit" } func (w setTitle) create() tea.Msg { w.Path = filepath.Join(w.Path, parseHumanToPath(w.name)) return newEditWidget(w.Path) } // -------- confirmDelete widget definitions ---------------------------------- // ---------------------------------------------------------------------------- type confirmDelete struct { Path string input textinput.Model prompt string validateString string } func (w confirmDelete) update(msg tea.Msg) (widget, tea.Cmd) { var cmds []tea.Cmd var cmd tea.Cmd w.input, cmd = w.input.Update(msg) cmds = append(cmds, cmd) switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "enter": cmds = append(cmds, w.validate) case "esc": cmds = append(cmds, w.back) } cmds = append(cmds, w.render) case validateMsg: switch msg { case true: cmds = append(cmds, w.deletePath) case false: cmds = append(cmds, w.back) } case deleteResult: cmds = append(cmds, w.back) } return w, tea.Batch(cmds...) } func (w confirmDelete) render() tea.Msg { prompt := fmt.Sprintf("%s (%s)...\n", w.prompt, w.Path) return fmt.Sprintf("%s%s", prompt, w.input.View()) } func (w confirmDelete) keyhelp() string { return "enter: submit\t\tesc: cancel" } func (w confirmDelete) validate() tea.Msg { if w.input.Value() == w.validateString { return validateMsg(true) } return validateMsg(false) } func (w confirmDelete) deletePath() tea.Msg { if err := os.RemoveAll(w.Path); err != nil { return deleteResult(err.Error()) } return deleteResult("") } func (w confirmDelete) back() tea.Msg { remainder, _ := filepath.Split(w.Path) remainder = strings.TrimRight(remainder, "/") if len(remainder) == 0 { return nil } return loadPath(remainder) }