tui.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407
  1. // The package defines an extensible TUI via the bubbletea framework.
  2. //
  3. // TODO enable collection recursing (i.e, embeded collections)
  4. //
  5. // TODO enable scroll/viewport logic on issues!!!
  6. // TODO connect WriteIssue to createIssue widget
  7. //
  8. // While the package remains in v0.0.X releases, this TUI may be undocumented.
  9. package issues
  10. import (
  11. "fmt"
  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. // Handles quit logic and viewport scroll and size updates
  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 createIssue:
  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.(createIssue); 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. } else {
  127. output = output + m.content
  128. }
  129. output = output + "\nj/k: down/up\tenter: select\tq: quit"
  130. return output
  131. }
  132. // WIDGET DEFINITIONS ---------------------------------------------------------
  133. // ----------------------------------------------------------------------------
  134. // interface definition for widgets
  135. type widget interface {
  136. render() tea.Msg
  137. }
  138. // -------- creatIssue definitions --------------------------------------------
  139. // ----------------------------------------------------------------------------
  140. // data struct for createIssue
  141. type inputField struct {
  142. input textinput.Model
  143. title string
  144. }
  145. // widget for creating an issue
  146. type createIssue struct {
  147. inputFields []inputField
  148. Path string
  149. selected int
  150. Err error // behaviour undefined
  151. }
  152. func initialInputModel(path string, placeholder string) createIssue {
  153. spawnInput := func(f bool) textinput.Model {
  154. ti := textinput.New()
  155. ti.Placeholder = placeholder
  156. if f {
  157. ti.Focus()
  158. }
  159. ti.CharLimit = 80
  160. ti.Width = 30
  161. return ti
  162. }
  163. var inputs []inputField
  164. for i, t := range [4]string{"title", "status", "tags", "blockers"} {
  165. if i == 0 {
  166. inputs = append(inputs, inputField{title: t, input: spawnInput(true)})
  167. } else {
  168. inputs = append(inputs, inputField{title: t, input: spawnInput(false)})
  169. }
  170. }
  171. return createIssue{
  172. inputFields: inputs,
  173. Path: path,
  174. selected: 0,
  175. Err: nil,
  176. }
  177. }
  178. func (ci createIssue) init() tea.Cmd {
  179. return textinput.Blink
  180. }
  181. func (ci createIssue) update(msg tea.Msg) (createIssue, tea.Cmd) {
  182. var cmds []tea.Cmd
  183. var cmd tea.Cmd
  184. // simple anon funcs to increment the selected index
  185. incrementSelected := func() {
  186. if ci.selected < len(ci.inputFields) {
  187. ci.selected++
  188. for i := 0; i < len(ci.inputFields); i++ {
  189. if i == ci.selected {
  190. ci.inputFields[i].input.Focus()
  191. } else {
  192. ci.inputFields[i].input.Blur()
  193. }
  194. }
  195. } else {
  196. ci.selected = 0
  197. ci.inputFields[ci.selected].input.Focus()
  198. }
  199. }
  200. decrementSelected := func() {
  201. if ci.selected != 0 {
  202. ci.selected--
  203. for i := 0; i < len(ci.inputFields); i++ {
  204. if i == ci.selected {
  205. ci.inputFields[i].input.Focus()
  206. } else {
  207. ci.inputFields[i].input.Blur()
  208. }
  209. }
  210. } else {
  211. for i := 0; i < len(ci.inputFields); i++ {
  212. ci.inputFields[i].input.Blur()
  213. }
  214. ci.selected = len(ci.inputFields)
  215. }
  216. }
  217. switch msg := msg.(type) { // keybinding handler
  218. case tea.KeyMsg:
  219. switch msg.String() {
  220. case "tab":
  221. incrementSelected()
  222. case "shift+tab":
  223. decrementSelected()
  224. case "enter":
  225. if ci.selected == len(ci.inputFields) { // confirm create
  226. ci.selected++
  227. } else if ci.selected == len(ci.inputFields)+1 { // confirmed
  228. cmds = append(cmds, tea.Quit)
  229. } else {
  230. incrementSelected()
  231. }
  232. case "esc": // cancel
  233. cmds = append(cmds, tea.Quit)
  234. }
  235. }
  236. for i, ti := range ci.inputFields {
  237. ci.inputFields[i].input, cmd = ti.input.Update(msg)
  238. cmds = append(cmds, cmd)
  239. }
  240. cmds = append(cmds, cmd)
  241. return ci, tea.Batch(cmds...)
  242. }
  243. func (ci createIssue) render() tea.Msg {
  244. borderStyle := lipgloss.NewStyle().
  245. BorderStyle(lipgloss.NormalBorder()).
  246. Margin(1).
  247. Padding(0, 1)
  248. ulStyle := lipgloss.NewStyle().Underline(true)
  249. var output string
  250. for _, field := range ci.inputFields {
  251. output = output + fmt.Sprintf(
  252. "\n%s:%s",
  253. field.title,
  254. borderStyle.Render(field.input.View()),
  255. )
  256. }
  257. output = strings.TrimLeft(output, "\n")
  258. if ci.selected < len(ci.inputFields) {
  259. output = output + borderStyle.Render("press enter to submit...")
  260. } else if ci.selected == len(ci.inputFields) {
  261. output = output + borderStyle.Render(ulStyle.Render("press enter to submit..."))
  262. } else if ci.selected == len(ci.inputFields)+1 {
  263. confirmPrompt := fmt.Sprintf(
  264. "creating issue titled \"%s\"...\n\n%s",
  265. ulStyle.Render(ci.inputFields[0].input.Value()),
  266. ulStyle.Render("press enter to confirm..."),
  267. )
  268. output = output + borderStyle.Render(confirmPrompt)
  269. }
  270. return output
  271. }
  272. // -------- Issue widget definitions ------------------------------------------
  273. // ----------------------------------------------------------------------------
  274. // Handles all render logic for Issue structs
  275. func (i Issue) render() tea.Msg {
  276. var output string
  277. // title
  278. output = output + titleStyle.Render(i.Title)
  279. // status
  280. output = output + fmt.Sprintf("\n%s", statusStyle.Render(i.Status.Data))
  281. // variadics
  282. var tags string
  283. for _, field := range i.Tags.Fields {
  284. tags = tags + field.Path + ", "
  285. }
  286. tags = strings.TrimRight(tags, ", ")
  287. var blockedby string
  288. for _, field := range i.Blockedby.Fields {
  289. blockedby = blockedby + field.Path + ", "
  290. }
  291. blockedby = strings.TrimRight(blockedby, ", ")
  292. if len(i.Tags.Fields) > 0 {
  293. output = output + variadicTitleStyle.Render("\n\nTags:")
  294. output = output + fmt.Sprintf("\n%s", variadicDataStyle.Render(tags))
  295. }
  296. if len(i.Blockedby.Fields) > 0 {
  297. output = output + variadicTitleStyle.Render("\n\nBlockedby:")
  298. output = output + fmt.Sprintf("\n%s", variadicDataStyle.Render(blockedby))
  299. }
  300. // description
  301. output = output + titleStyle.Render("\n\nDescription:\n")
  302. output = output + fmt.Sprintf("\n%s", i.Description.Data)
  303. return borderStyle.Render(output)
  304. }
  305. // -------- IssueCollection widget definitions --------------------------------
  306. // ----------------------------------------------------------------------------
  307. // Handles all render logic for IssueCollection structs.
  308. func (ic IssueCollection) render() tea.Msg {
  309. var output string
  310. var left string
  311. output = output + "Issues in " + ic.Path + "...\n\n"
  312. for i, issue := range ic.Collection {
  313. // pointer render
  314. if i == ic.selection {
  315. left = left + pointerStyle.Render("-> ")
  316. } else {
  317. left = left + pointerStyle.Render(" ")
  318. }
  319. // index render
  320. left = left + "[" + indexStyle.Render(fmt.Sprintf("%d", i+1)) + "]: "
  321. // title render
  322. left = left + fmt.Sprintf("%s\n", titleStyle.Render(issue.Title))
  323. }
  324. output = output + collectionStyleLeft.Render(left)
  325. return output
  326. }
  327. // tea.Cmd definitions --------------------------------------------------------
  328. // ----------------------------------------------------------------------------
  329. // Handles load logic
  330. func (m Model) load() tea.Msg {
  331. if IsIssue(m.Path) {
  332. issue, err := Issue{}.NewFromPath(m.Path)
  333. if err != nil {
  334. return nil
  335. }
  336. return issue
  337. }
  338. if IsIssueCollection(m.Path) {
  339. collection, err := IssueCollection{}.NewFromPath(m.Path)
  340. if err != nil {
  341. return nil
  342. }
  343. return collection
  344. }
  345. return initialInputModel(m.Path, "lorem ipsum")
  346. }