| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398 |
- // 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)
- )
- // interface for renderable structs
- type widget interface {
- render() tea.Msg
- }
- // The main bubbletea Model
- type Model struct {
- widget widget
- content string
- Path string
- // viewport viewport.Model
- }
- // Main model definitions -----------------------------------------------------
- // ----------------------------------------------------------------------------
- // 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 ---------------------------------------------------------
- // ----------------------------------------------------------------------------
- // -------- 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
- }
- // 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")
- }
- // 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)
- }
- // 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
- }
|