tui.go 14 KB

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