tui.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561
  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. // widget specifc keyhandling
  58. var cmds []tea.Cmd
  59. switch m.widget.(type) {
  60. case IssueCollection: // TODO handle updates to IssueCollection widgets in its own update func
  61. if msg, ok := msg.(tea.KeyMsg); ok {
  62. switch msg.String() {
  63. case "j":
  64. if collection, ok := m.widget.(IssueCollection); ok {
  65. if collection.selection+1 < len(collection.Collection) {
  66. collection.selection = collection.selection + 1
  67. } else {
  68. collection.selection = 0
  69. }
  70. m.widget = collection
  71. return m, collection.render
  72. }
  73. case "k":
  74. // do something only if widget is collection
  75. if collection, ok := m.widget.(IssueCollection); ok {
  76. if collection.selection != 0 {
  77. collection.selection = collection.selection - 1
  78. } else {
  79. collection.selection = len(collection.Collection) - 1
  80. }
  81. m.widget = collection
  82. return m, collection.render
  83. }
  84. case "enter":
  85. if _, ok := m.widget.(IssueCollection); ok {
  86. m.Path = m.widget.(IssueCollection).Collection[m.widget.(IssueCollection).selection].Path
  87. return m, m.load
  88. }
  89. case "q":
  90. cmds = append(cmds, tea.Quit)
  91. }
  92. }
  93. }
  94. // general message handling
  95. switch msg := msg.(type) {
  96. case tea.KeyMsg: // keymsg capture that is always present
  97. switch msg.String() {
  98. case "ctrl+c":
  99. cmds = append(cmds, tea.Quit)
  100. }
  101. case widget: // widget is initialized from m.load()
  102. switch T := msg.(type) {
  103. case create:
  104. m.widget = T
  105. cmds = append(cmds, T.render, T.init())
  106. default:
  107. m.widget = T
  108. cmds = append(cmds, T.render)
  109. }
  110. case string:
  111. m.content = msg
  112. }
  113. // finally, handle input updates if any
  114. if w, ok := m.widget.(create); ok {
  115. var cmd tea.Cmd
  116. m.widget, cmd = w.update(msg)
  117. cmds = append(cmds, cmd, w.render)
  118. }
  119. return m, tea.Batch(cmds...)
  120. }
  121. // Handles top level view functionality
  122. func (m Model) View() string {
  123. var output string
  124. if len(m.content) == 0 {
  125. return "loading..."
  126. }
  127. output = output + m.content
  128. output = output + lipgloss.NewStyle().Faint(true).Margin(1).Render(m.widget.keyhelp())
  129. return output
  130. }
  131. // WIDGET DEFINITIONS ---------------------------------------------------------
  132. // ----------------------------------------------------------------------------
  133. // interface definition for widgets
  134. type widget interface {
  135. render() tea.Msg // renders content
  136. keyhelp() string // renders key usage
  137. }
  138. // -------- create widget definitions -----------------------------------------
  139. // ----------------------------------------------------------------------------
  140. // TODO(create widget) handle reset on esc
  141. // TODO(create widget) implement description field in create.create
  142. // data struct for create widget
  143. type inputField struct {
  144. input textinput.Model
  145. title string
  146. }
  147. // struct definition for create widget
  148. type create struct {
  149. inputFields []inputField
  150. Path string
  151. selected int
  152. err error // not implemented
  153. }
  154. // constructor for create widget
  155. func initialCreateWidget(path string, placeholder string) create {
  156. spawnInput := func(f bool) textinput.Model {
  157. ti := textinput.New()
  158. ti.Placeholder = placeholder
  159. if f {
  160. ti.Focus()
  161. }
  162. ti.CharLimit = 80
  163. ti.Width = 30
  164. return ti
  165. }
  166. var inputs []inputField
  167. for i, t := range [4]string{"title", "status", "tags", "blockers"} {
  168. if i == 0 {
  169. inputs = append(inputs, inputField{title: t, input: spawnInput(true)})
  170. } else {
  171. inputs = append(inputs, inputField{title: t, input: spawnInput(false)})
  172. }
  173. switch t {
  174. case "title":
  175. parsed := parsePathToHuman(path)
  176. if parsed == "." {
  177. parsed = ""
  178. }
  179. inputs[i].input.SetValue(parsed)
  180. case "status":
  181. inputs[i].input.SetValue("open")
  182. }
  183. }
  184. return create{
  185. inputFields: inputs,
  186. Path: path,
  187. selected: 0,
  188. err: nil,
  189. }
  190. }
  191. // init cmd for create widget
  192. func (c create) init() tea.Cmd {
  193. return textinput.Blink
  194. }
  195. type ( // Type definitions for use in tea.Msg life cycle for create widget.
  196. createResult Issue // type wrapper for create.create() result
  197. writeResult any // type wrapper for create.write() result
  198. editorResult struct { // type wrapper for create.editor() result
  199. err error // any error returned by InvokeEditor
  200. issue Issue // the data in the template file
  201. }
  202. )
  203. // update cmd for create widget
  204. func (c create) update(msg tea.Msg) (create, tea.Cmd) {
  205. var cmds []tea.Cmd
  206. var cmd tea.Cmd
  207. // simple anon functions to increment the selected index
  208. incrementSelected := func() {
  209. if c.selected < len(c.inputFields) {
  210. c.selected++
  211. for i := 0; i < len(c.inputFields); i++ {
  212. if i == c.selected {
  213. c.inputFields[i].input.Focus()
  214. } else {
  215. c.inputFields[i].input.Blur()
  216. }
  217. }
  218. } else {
  219. c.selected = 0
  220. c.inputFields[c.selected].input.Focus()
  221. }
  222. }
  223. decrementSelected := func() {
  224. if c.selected != 0 {
  225. c.selected--
  226. for i := 0; i < len(c.inputFields); i++ {
  227. if i == c.selected {
  228. c.inputFields[i].input.Focus()
  229. } else {
  230. c.inputFields[i].input.Blur()
  231. }
  232. }
  233. } else {
  234. for i := 0; i < len(c.inputFields); i++ {
  235. c.inputFields[i].input.Blur()
  236. }
  237. c.selected = len(c.inputFields)
  238. }
  239. }
  240. switch msg := msg.(type) { // keybinding handler
  241. case tea.KeyMsg:
  242. switch msg.String() {
  243. case "tab":
  244. incrementSelected()
  245. case "shift+tab":
  246. decrementSelected()
  247. case "enter":
  248. if c.selected == len(c.inputFields) { // confirm create
  249. c.selected++
  250. } else if c.selected == len(c.inputFields)+1 { // confirmed
  251. cmds = append(cmds, c.create)
  252. } else {
  253. incrementSelected()
  254. }
  255. case "esc": // cancel
  256. cmds = append(cmds, tea.Quit)
  257. }
  258. case createResult:
  259. cmds = append(cmds, c.editDescription(Issue(msg)))
  260. case editorResult:
  261. if msg.err != nil {
  262. c.err = msg.err
  263. } else {
  264. cmds = append(cmds, c.write(msg.issue))
  265. }
  266. case writeResult:
  267. switch value := msg.(type) {
  268. case bool:
  269. if !value {
  270. } else {
  271. cmds = append(cmds, tea.Quit)
  272. }
  273. case error:
  274. c.err = value
  275. }
  276. }
  277. for i, ti := range c.inputFields {
  278. c.inputFields[i].input, cmd = ti.input.Update(msg)
  279. cmds = append(cmds, cmd)
  280. }
  281. cmds = append(cmds, cmd)
  282. return c, tea.Batch(cmds...)
  283. }
  284. // A tea.Cmd to translate create.inputs to a new Issue object
  285. func (c create) create() tea.Msg {
  286. data := make(map[string]string)
  287. commaSplit := func(t string) []string {
  288. s := strings.Split(t, ",")
  289. for i, v := range s {
  290. s[i] = strings.TrimLeft(v, " \t\n")
  291. s[i] = strings.TrimRight(s[i], " \t\n")
  292. s[i] = parseHumanToPath(s[i])
  293. }
  294. return s
  295. }
  296. for _, field := range c.inputFields {
  297. data[field.title] = field.input.Value()
  298. }
  299. var newIssue = Issue{
  300. Path: c.Path,
  301. Tags: VariadicField{Path: "/tags"},
  302. Blockedby: VariadicField{Path: "/blockedby"},
  303. }
  304. for key, value := range data {
  305. switch key {
  306. case "title":
  307. newIssue.Title = value
  308. if parsePathToHuman(newIssue.Path) != value {
  309. dir, _ := filepath.Split(newIssue.Path)
  310. newIssue.Path = filepath.Join(dir, value)
  311. }
  312. case "status":
  313. newIssue.Status = Field{Path: "/status", Data: value}
  314. case "description":
  315. newIssue.Description = Field{Path: "/description", Data: value}
  316. case "tags":
  317. splitTags := commaSplit(value)
  318. for _, tag := range splitTags {
  319. newIssue.Tags.Fields = append(newIssue.Tags.Fields, Field{Path: tag})
  320. }
  321. case "blockers":
  322. splitBlockedby := commaSplit(value)
  323. for _, blocker := range splitBlockedby {
  324. newIssue.Blockedby.Fields = append(
  325. newIssue.Blockedby.Fields, Field{Path: blocker},
  326. )
  327. }
  328. }
  329. }
  330. return createResult(newIssue)
  331. }
  332. // Wraps a tea.Cmd function, passes an initialized Issue to WriteIssue()
  333. func (c create) write(issue Issue) tea.Cmd {
  334. return func() tea.Msg {
  335. result, err := WriteIssue(issue, false)
  336. if err != nil {
  337. return writeResult(err)
  338. }
  339. return writeResult(result)
  340. }
  341. }
  342. // Calls InvokeEditor via EditTemplate using DescriptionTemplate, reports output
  343. // and errors
  344. //
  345. // WARNING! THIS METHOD HANGS UNTIL THE USER KILLS THE EDITOR!
  346. func (c create) editDescription(issue Issue) tea.Cmd {
  347. data, err := EditTemplate(DescriptionTemplate, InvokeEditor)
  348. var output string
  349. for _, line := range data {
  350. output = output + line
  351. }
  352. issue.Description = Field{Path: "/description", Data: output}
  353. return func() tea.Msg {
  354. return editorResult{issue: issue, err: err}
  355. }
  356. }
  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\tctrl+c: quit"
  394. return output
  395. }
  396. // -------- Issue widget definitions ------------------------------------------
  397. // ----------------------------------------------------------------------------
  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\tq: quit"
  433. return output
  434. }
  435. // -------- IssueCollection widget definitions --------------------------------
  436. // ----------------------------------------------------------------------------
  437. // render cmd for IssueCollection widget
  438. func (ic IssueCollection) render() tea.Msg {
  439. var output string
  440. var left string
  441. output = output + "Issues in " + ic.Path + "...\n\n"
  442. for i, issue := range ic.Collection {
  443. // pointer render
  444. if i == ic.selection {
  445. left = left + pointerStyle.Render("-> ")
  446. } else {
  447. left = left + pointerStyle.Render(" ")
  448. }
  449. // index render
  450. left = left + "[" + indexStyle.Render(fmt.Sprintf("%d", i+1)) + "]: "
  451. // title render
  452. left = left + fmt.Sprintf("%s\n", titleStyle.Render(issue.Title))
  453. }
  454. output = output + collectionStyleLeft.Render(left)
  455. return output
  456. }
  457. // keyhelp cmd for IssueCollection widget
  458. func (ic IssueCollection) keyhelp() string {
  459. var output string
  460. output = output + "\nj/k: down/up\t\tenter: select\t\tq: quit"
  461. return output
  462. }
  463. // tea.Cmd definitions --------------------------------------------------------
  464. // ----------------------------------------------------------------------------
  465. // Handles load logic
  466. func (m Model) load() tea.Msg {
  467. if IsIssue(m.Path) {
  468. issue, err := Issue{}.NewFromPath(m.Path)
  469. if err != nil {
  470. return nil
  471. }
  472. return issue
  473. }
  474. if IsIssueCollection(m.Path) {
  475. collection, err := IssueCollection{}.NewFromPath(m.Path)
  476. if err != nil {
  477. return nil
  478. }
  479. return collection
  480. }
  481. return initialCreateWidget(m.Path, "lorem ipsum")
  482. }