tui.go 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708
  1. // The package defines an extensible TUI via the bubbletea framework.
  2. //
  3. // While the package remains in v0.0.X releases, this TUI may be undocumented.
  4. //
  5. // TODO enable collection recursing (i.e, embedded collections)
  6. //
  7. // TODO enable scroll/viewport logic
  8. package issues
  9. import (
  10. "fmt"
  11. "path/filepath"
  12. "strings"
  13. "github.com/charmbracelet/bubbles/textinput"
  14. tea "github.com/charmbracelet/bubbletea"
  15. "github.com/charmbracelet/lipgloss"
  16. )
  17. // Type and Style definitions -------------------------------------------------
  18. // ----------------------------------------------------------------------------
  19. // [lipgloss] style definitions, stores the currently displayed "widget"
  20. var (
  21. titleStyle = lipgloss.NewStyle().
  22. Bold(true).
  23. Underline(true)
  24. statusStyle = lipgloss.NewStyle().
  25. Faint(true).
  26. Italic(true)
  27. variadicTitleStyle = lipgloss.NewStyle().
  28. Align(lipgloss.Left).
  29. Italic(true)
  30. variadicDataStyle = lipgloss.NewStyle().
  31. Width(40).
  32. BorderStyle(lipgloss.ASCIIBorder())
  33. borderStyle = lipgloss.NewStyle().
  34. Padding(1, 2).
  35. Margin(1).
  36. BorderStyle(lipgloss.NormalBorder())
  37. indexStyle = lipgloss.NewStyle().
  38. Italic(true)
  39. pointerStyle = lipgloss.NewStyle().
  40. Faint(true)
  41. collectionStyleLeft = lipgloss.NewStyle().
  42. Align(lipgloss.Left)
  43. )
  44. // MAIN MODEL DEFINITIONS -----------------------------------------------------
  45. // ----------------------------------------------------------------------------
  46. // The main bubbletea Model
  47. type Model struct {
  48. widget widget
  49. content string
  50. Path string
  51. // viewport viewport.Model
  52. }
  53. // The bubbletea init function
  54. func (m Model) Init() tea.Cmd { return m.load }
  55. // Core tea.Model update loop
  56. func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
  57. var cmds []tea.Cmd
  58. // general message handling
  59. switch msg := msg.(type) {
  60. case tea.KeyMsg: // KeyMsg capture that is always present
  61. switch msg.String() {
  62. case "ctrl+c":
  63. cmds = append(cmds, tea.Quit)
  64. }
  65. case widget: // widget is initialized from m.load()
  66. switch T := msg.(type) {
  67. case edit:
  68. m.widget = T
  69. cmds = append(cmds, T.render, T.init())
  70. default:
  71. m.widget = T
  72. cmds = append(cmds, T.render)
  73. }
  74. case loadPath:
  75. m.Path = string(msg)
  76. return m, m.load
  77. case createInCollectionMsg:
  78. wg := createInCollection{Path: string(msg)}
  79. i := textinput.New()
  80. i.Placeholder = "a short title"
  81. i.Focus()
  82. i.CharLimit = 80
  83. i.Width = 30
  84. wg.input = i
  85. return m, func() tea.Msg { return wg }
  86. case string:
  87. m.content = msg
  88. }
  89. // finally, pass msg to widget
  90. var cmd tea.Cmd
  91. switch w := m.widget.(type) {
  92. case edit:
  93. m.widget, cmd = w.update(msg)
  94. cmds = append(cmds, cmd, w.render)
  95. case Issue:
  96. m.widget, cmd = w.update(msg)
  97. cmds = append(cmds, cmd, w.render)
  98. case IssueCollection:
  99. m.widget, cmd = w.update(msg)
  100. cmds = append(cmds, cmd, w.render)
  101. case createInCollection:
  102. m.widget, cmd = w.update(msg)
  103. cmds = append(cmds, cmd, w.render)
  104. }
  105. return m, tea.Batch(cmds...)
  106. }
  107. // Handles top level view functionality
  108. func (m Model) View() string {
  109. var output string
  110. if len(m.content) == 0 {
  111. return "loading..."
  112. }
  113. output = output + m.content
  114. output = output + lipgloss.NewStyle().Faint(true).Margin(1).Render(m.widget.keyhelp())
  115. return output
  116. }
  117. // Handles load logic
  118. func (m Model) load() tea.Msg {
  119. if IsIssue(m.Path) {
  120. issue, err := Issue{}.NewFromPath(m.Path)
  121. if err != nil {
  122. return nil
  123. }
  124. return issue
  125. }
  126. if IsIssueCollection(m.Path) {
  127. collection, err := IssueCollection{}.NewFromPath(m.Path)
  128. if err != nil {
  129. return nil
  130. }
  131. return collection
  132. }
  133. return newEditWidget(m.Path)
  134. }
  135. // WIDGET DEFINITIONS ---------------------------------------------------------
  136. // ----------------------------------------------------------------------------
  137. // interface definition for widgets
  138. type widget interface {
  139. update(tea.Msg) (widget, tea.Cmd) // implements widget specific update life cycles
  140. render() tea.Msg // renders content
  141. keyhelp() string // renders key usage
  142. }
  143. // -------- edit widget definitions -----------------------------------------
  144. // ----------------------------------------------------------------------------
  145. // TODO(create widget) implement description field in create.create
  146. type ( // Type definitions for use in tea.Msg life cycle for create widget.
  147. createResult Issue // type wrapper for create.create() result
  148. writeResult any // type wrapper for create.write() result
  149. editorResult struct { // type wrapper for create.editor() result
  150. err error // any error returned by InvokeEditor
  151. issue Issue // the data in the template file
  152. }
  153. )
  154. // data struct for create widget
  155. type inputField struct {
  156. input textinput.Model
  157. title string
  158. }
  159. // struct definition for edit widget
  160. type edit struct {
  161. inputFields []inputField
  162. Path string
  163. selected int
  164. existing bool // flag to edit existing description
  165. err error // not implemented
  166. }
  167. // constructor for the edit widget
  168. func newEditWidget(path string) widget {
  169. // data prep
  170. var e edit
  171. var issue Issue
  172. var err error
  173. if IsIssue(path) { // if path is existing issue, load Issue from path
  174. issue, err = Issue{}.NewFromPath(path)
  175. if err != nil {
  176. return e
  177. }
  178. e.existing = true
  179. } else { // if path is not existing issue, create new Issue with sensible defaults
  180. issue = Issue{
  181. Path: path, Title: parsePathToHuman(path),
  182. Status: Field{Path: "/status", Data: "open"},
  183. }
  184. }
  185. // anon function for spawning instantiated textinput.Model's
  186. spawnInput := func(f bool) textinput.Model {
  187. ti := textinput.New()
  188. if f {
  189. ti.Focus()
  190. }
  191. ti.CharLimit = 80
  192. ti.Width = 30
  193. return ti
  194. }
  195. // inputFields data prep
  196. var fields []inputField
  197. var input textinput.Model
  198. // title
  199. input = spawnInput(true)
  200. input.SetValue(issue.Title)
  201. input.Placeholder = "title"
  202. fields = append(fields, inputField{input: input, title: "title"})
  203. // status
  204. input = spawnInput(false)
  205. input.SetValue(issue.Status.Data)
  206. input.Placeholder = "status"
  207. fields = append(fields, inputField{input: input, title: "status"})
  208. // tags
  209. input = spawnInput(false)
  210. input.SetValue(issue.Tags.AsString())
  211. input.Placeholder = "tags, separated by comma"
  212. fields = append(fields, inputField{input: input, title: "tags"})
  213. // blockedby
  214. input = spawnInput(false)
  215. input.SetValue(issue.Blockedby.AsString())
  216. input.Placeholder = "blockers, separated by comma"
  217. fields = append(fields, inputField{input: input, title: "blockers"})
  218. e.inputFields = fields
  219. e.Path = path
  220. return e
  221. }
  222. // init cmd for create widget
  223. func (e edit) init() tea.Cmd { return textinput.Blink }
  224. // update cmd for create widget
  225. func (e edit) update(msg tea.Msg) (widget, tea.Cmd) {
  226. var cmds []tea.Cmd
  227. var cmd tea.Cmd
  228. // simple anon functions to increment the selected index
  229. incrementSelected := func() {
  230. if e.selected < len(e.inputFields) {
  231. e.selected++
  232. for i := 0; i < len(e.inputFields); i++ {
  233. if i == e.selected {
  234. e.inputFields[i].input.Focus()
  235. } else {
  236. e.inputFields[i].input.Blur()
  237. }
  238. }
  239. } else {
  240. e.selected = 0
  241. e.inputFields[e.selected].input.Focus()
  242. }
  243. }
  244. decrementSelected := func() {
  245. if e.selected != 0 {
  246. e.selected--
  247. for i := 0; i < len(e.inputFields); i++ {
  248. if i == e.selected {
  249. e.inputFields[i].input.Focus()
  250. } else {
  251. e.inputFields[i].input.Blur()
  252. }
  253. }
  254. } else {
  255. for i := 0; i < len(e.inputFields); i++ {
  256. e.inputFields[i].input.Blur()
  257. }
  258. e.selected = len(e.inputFields)
  259. }
  260. }
  261. switch msg := msg.(type) { // keybinding handler
  262. case tea.KeyMsg:
  263. switch msg.String() {
  264. case "tab":
  265. incrementSelected()
  266. case "shift+tab":
  267. decrementSelected()
  268. case "enter":
  269. if e.selected == len(e.inputFields) { // confirm create
  270. e.selected++
  271. } else if e.selected == len(e.inputFields)+1 { // confirmed
  272. cmds = append(cmds, e.createIssueObject)
  273. } else {
  274. incrementSelected()
  275. }
  276. case "esc": // reset
  277. for i, field := range e.inputFields {
  278. field.input.Reset()
  279. switch field.title {
  280. case "title":
  281. parsed := parsePathToHuman(e.Path)
  282. if parsed == "." {
  283. parsed = ""
  284. }
  285. field.input.SetValue(parsed)
  286. case "status":
  287. field.input.SetValue("open")
  288. }
  289. e.inputFields[i] = field
  290. }
  291. }
  292. case createResult:
  293. if !e.existing {
  294. cmds = append(cmds, e.editBlankDescription(Issue(msg)))
  295. } else {
  296. cmds = append(cmds, e.editExistingDescription(Issue(msg)))
  297. }
  298. case editorResult:
  299. if msg.err != nil {
  300. e.err = msg.err
  301. } else {
  302. cmds = append(cmds, e.write(msg.issue))
  303. }
  304. case writeResult:
  305. switch value := msg.(type) {
  306. case bool:
  307. if !value {
  308. } else {
  309. cmds = append(cmds, func() tea.Msg { return loadPath(e.Path) })
  310. }
  311. case error:
  312. e.selected = -100
  313. e.err = value
  314. }
  315. }
  316. for i, ti := range e.inputFields {
  317. e.inputFields[i].input, cmd = ti.input.Update(msg)
  318. cmds = append(cmds, cmd)
  319. }
  320. cmds = append(cmds, cmd)
  321. return e, tea.Batch(cmds...)
  322. }
  323. // A tea.Cmd to translate createIssueObject.inputs to a new Issue object
  324. func (e edit) createIssueObject() tea.Msg {
  325. data := make(map[string]string)
  326. commaSplit := func(t string) []string {
  327. s := strings.Split(t, ",")
  328. for i, v := range s {
  329. s[i] = strings.TrimLeft(v, " \t\n")
  330. s[i] = strings.TrimRight(s[i], " \t\n")
  331. s[i] = parseHumanToPath(s[i])
  332. }
  333. return s
  334. }
  335. for _, field := range e.inputFields {
  336. data[field.title] = field.input.Value()
  337. }
  338. var newIssue = Issue{
  339. Path: e.Path,
  340. Tags: VariadicField{Path: "/tags"},
  341. Blockedby: VariadicField{Path: "/blockedby"},
  342. }
  343. for key, value := range data {
  344. switch key {
  345. case "title":
  346. newIssue.Title = value
  347. if parsePathToHuman(newIssue.Path) != value {
  348. dir, _ := filepath.Split(newIssue.Path)
  349. newIssue.Path = filepath.Join(dir, value)
  350. }
  351. case "status":
  352. newIssue.Status = Field{Path: "/status", Data: value}
  353. case "description":
  354. newIssue.Description = Field{Path: "/description", Data: value}
  355. case "tags":
  356. splitTags := commaSplit(value)
  357. for _, tag := range splitTags {
  358. newIssue.Tags.Fields = append(newIssue.Tags.Fields, Field{Path: tag})
  359. }
  360. case "blockers":
  361. splitBlockedby := commaSplit(value)
  362. for _, blocker := range splitBlockedby {
  363. newIssue.Blockedby.Fields = append(
  364. newIssue.Blockedby.Fields, Field{Path: blocker},
  365. )
  366. }
  367. }
  368. }
  369. return createResult(newIssue)
  370. }
  371. // Wraps a tea.Cmd function, passes an initialized Issue to WriteIssue()
  372. func (e edit) write(issue Issue) tea.Cmd {
  373. return func() tea.Msg {
  374. result, err := WriteIssue(issue, true)
  375. if err != nil {
  376. return writeResult(err)
  377. }
  378. return writeResult(result)
  379. }
  380. }
  381. // Calls InvokeEditor via EditTemplate using DescriptionTemplate, reports output
  382. // and errors
  383. //
  384. // WARNING! THIS METHOD HANGS UNTIL THE USER KILLS THE EDITOR!
  385. func (e edit) editBlankDescription(issue Issue) tea.Cmd {
  386. data, err := EditTemplate(DescriptionTemplate, InvokeEditor)
  387. var output string
  388. for _, line := range data {
  389. output = output + line
  390. }
  391. issue.Description = Field{Path: "/description", Data: output}
  392. return func() tea.Msg {
  393. return editorResult{issue: issue, err: err}
  394. }
  395. }
  396. // does this just call InvokeEditor?
  397. func (e edit) editExistingDescription(issue Issue) tea.Cmd {
  398. err := InvokeEditor(filepath.Join(e.Path, "description"))
  399. if err != nil {
  400. return func() tea.Msg { return editorResult{issue: issue, err: err} }
  401. }
  402. data, err := ReadTemplate(filepath.Join(e.Path, "description"))
  403. var output string
  404. for _, line := range data {
  405. output = output + line
  406. }
  407. issue.Description = Field{Path: "/description", Data: output}
  408. return func() tea.Msg { return editorResult{issue: issue, err: err} }
  409. }
  410. // render cmd for create widget
  411. func (e edit) render() tea.Msg {
  412. if e.err != nil {
  413. return fmt.Sprintf("failed to create issue... %s", e.err.Error())
  414. }
  415. borderStyle := lipgloss.NewStyle().
  416. BorderStyle(lipgloss.NormalBorder()).
  417. Margin(1).
  418. Padding(0, 1)
  419. ulStyle := lipgloss.NewStyle().Underline(true)
  420. var output string
  421. for _, field := range e.inputFields {
  422. output = output + fmt.Sprintf(
  423. "\n%s:%s",
  424. field.title,
  425. borderStyle.Render(field.input.View()),
  426. )
  427. }
  428. output = strings.TrimLeft(output, "\n")
  429. if e.selected < len(e.inputFields) {
  430. output = output + borderStyle.Render("press enter to submit...")
  431. } else if e.selected == len(e.inputFields) {
  432. output = output + borderStyle.Render(ulStyle.Render("press enter to submit..."))
  433. } else if e.selected == len(e.inputFields)+1 {
  434. confirmPrompt := fmt.Sprintf(
  435. "create issue titled \"%s\"?\n\n%s",
  436. ulStyle.Render(e.inputFields[0].input.Value()),
  437. ulStyle.Render("press enter to write description..."),
  438. )
  439. output = output + borderStyle.Render(confirmPrompt)
  440. }
  441. return output
  442. }
  443. // keyhelp cmd for create widget
  444. func (e edit) keyhelp() string {
  445. var output string
  446. output = output + "\ntab/shift+tab: down/up\t\tenter: input value\t\tesc: reset\t\tctrl+e: quit"
  447. return output
  448. }
  449. // -------- Issue widget definitions ------------------------------------------
  450. // ----------------------------------------------------------------------------
  451. // enforce widget interface compliance
  452. func (i Issue) update(msg tea.Msg) (widget, tea.Cmd) {
  453. var cmds []tea.Cmd
  454. switch msg := msg.(type) {
  455. case tea.KeyMsg:
  456. switch msg.String() {
  457. case "e":
  458. cmds = append(cmds, i.edit)
  459. }
  460. }
  461. return i, tea.Batch(cmds...)
  462. }
  463. func (i Issue) edit() tea.Msg { return newEditWidget(i.Path) }
  464. // render cmd for Issue widget
  465. func (i Issue) render() tea.Msg {
  466. var output string
  467. // title
  468. output = output + titleStyle.Render(i.Title)
  469. // status
  470. output = output + fmt.Sprintf("\n%s", statusStyle.Render(i.Status.Data))
  471. // variadics
  472. var tags string
  473. for _, field := range i.Tags.Fields {
  474. tags = tags + field.Path + ", "
  475. }
  476. tags = strings.TrimRight(tags, ", ")
  477. var blockedby string
  478. for _, field := range i.Blockedby.Fields {
  479. blockedby = blockedby + field.Path + ", "
  480. }
  481. blockedby = strings.TrimRight(blockedby, ", ")
  482. if len(i.Tags.Fields) > 0 {
  483. output = output + variadicTitleStyle.Render("\n\nTags:")
  484. output = output + fmt.Sprintf("\n%s", variadicDataStyle.Render(tags))
  485. }
  486. if len(i.Blockedby.Fields) > 0 {
  487. output = output + variadicTitleStyle.Render("\n\nBlockedby:")
  488. output = output + fmt.Sprintf("\n%s", variadicDataStyle.Render(blockedby))
  489. }
  490. // description
  491. output = output + titleStyle.Render("\n\nDescription:\n")
  492. output = output + fmt.Sprintf("\n%s", i.Description.Data)
  493. return borderStyle.Render(output)
  494. }
  495. // keyhelp cmd for Issue widget
  496. func (i Issue) keyhelp() string {
  497. var output string
  498. output = output + "\ne: edit issue\t\tctrl+c: quit"
  499. return output
  500. }
  501. // -------- IssueCollection widget definitions --------------------------------
  502. // ----------------------------------------------------------------------------
  503. type ( // Type definitions for use in tea.Msg life cycle for IssueCollection widget.
  504. loadPath string // thrown when user selects a path to load.
  505. createInCollectionMsg string //thrown when user opts to create new issue in collection
  506. )
  507. // enforce widget interface compliance
  508. func (ic IssueCollection) update(msg tea.Msg) (widget, tea.Cmd) {
  509. switch msg := msg.(type) {
  510. case tea.KeyMsg:
  511. switch msg.String() {
  512. case "j":
  513. if ic.selection+1 < len(ic.Collection) {
  514. ic.selection = ic.selection + 1
  515. } else {
  516. ic.selection = 0
  517. }
  518. return ic, ic.render
  519. case "k":
  520. if ic.selection != 0 {
  521. ic.selection = ic.selection - 1
  522. } else {
  523. ic.selection = len(ic.Collection) - 1
  524. }
  525. return ic, ic.render
  526. case "enter":
  527. ic.Path = ic.Collection[ic.selection].Path
  528. return ic, ic.sendLoad
  529. case "q":
  530. return ic, tea.Quit
  531. case "c":
  532. return ic, ic.newIssueInCollection
  533. case "e":
  534. ic.Path = ic.Collection[ic.selection].Path
  535. return ic, ic.edit
  536. }
  537. }
  538. return ic, nil
  539. }
  540. func (ic IssueCollection) sendLoad() tea.Msg { return loadPath(ic.Path) }
  541. func (ic IssueCollection) newIssueInCollection() tea.Msg { return createInCollectionMsg(ic.Path) }
  542. func (ic IssueCollection) edit() tea.Msg { return newEditWidget(ic.Path) }
  543. // render cmd for IssueCollection widget
  544. func (ic IssueCollection) render() tea.Msg {
  545. var output string
  546. if ic.selection == -1 {
  547. }
  548. var left string
  549. output = output + "Issues in " + ic.Path + "...\n\n"
  550. for i, issue := range ic.Collection {
  551. // pointer render
  552. if i == ic.selection {
  553. left = left + pointerStyle.Render("-> ")
  554. } else {
  555. left = left + pointerStyle.Render(" ")
  556. }
  557. // index render
  558. left = left + "[" + indexStyle.Render(fmt.Sprintf("%d", i+1)) + "]: "
  559. // title render
  560. left = left + fmt.Sprintf("%s\n", titleStyle.Render(issue.Title))
  561. }
  562. output = output + collectionStyleLeft.Render(left)
  563. return output
  564. }
  565. // keyhelp cmd for IssueCollection widget
  566. func (ic IssueCollection) keyhelp() string {
  567. var output string
  568. output = output + "\nc: create new issue\t\tenter: view issue\t\tj/k: down/up\t\tq/ctrl+c: quit"
  569. return output
  570. }
  571. // -------- createInCollection widget definitions -----------------------------
  572. // ----------------------------------------------------------------------------
  573. type createInCollection struct {
  574. Path string // base path for the new issue
  575. name string // the name input by the user
  576. input textinput.Model // the input widget
  577. }
  578. func (w createInCollection) update(msg tea.Msg) (widget, tea.Cmd) {
  579. var cmds []tea.Cmd
  580. switch msg := msg.(type) {
  581. case tea.KeyMsg:
  582. switch msg.String() {
  583. case "enter":
  584. cmds = append(cmds, w.create)
  585. }
  586. }
  587. var inputCmd tea.Cmd
  588. w.input, inputCmd = w.input.Update(msg)
  589. cmds = append(cmds, inputCmd)
  590. w.name = w.input.Value()
  591. return w, tea.Batch(cmds...)
  592. }
  593. func (w createInCollection) create() tea.Msg {
  594. w.Path = filepath.Join(w.Path, w.name)
  595. return newEditWidget(w.Path)
  596. }
  597. func (w createInCollection) render() tea.Msg {
  598. return borderStyle.Render(fmt.Sprintf("Creating new issue in %s\ntitle: %s",
  599. w.Path, w.input.View()))
  600. }
  601. func (w createInCollection) keyhelp() string { return "enter: submit\t\tctrl+c: quit" }