tui.go 16 KB

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