tui.go 16 KB

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