tui.go 14 KB

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