tui.go 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788
  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. package issues
  7. import (
  8. "fmt"
  9. "path/filepath"
  10. "strings"
  11. "charm.land/bubbles/v2/viewport"
  12. "charm.land/lipgloss/v2"
  13. "github.com/charmbracelet/bubbles/textinput"
  14. tea "github.com/charmbracelet/bubbletea"
  15. )
  16. // Type and Style definitions -------------------------------------------------
  17. // ----------------------------------------------------------------------------
  18. // [lipgloss] style definitions, stores the currently displayed "widget"
  19. var (
  20. titleStyle = lipgloss.NewStyle().
  21. Width(80).
  22. Bold(true).
  23. Underline(true)
  24. detailStyle = lipgloss.NewStyle().
  25. Width(80).
  26. Faint(true).
  27. Italic(true)
  28. variadicTitleStyle = lipgloss.NewStyle().
  29. Width(80).
  30. Align(lipgloss.Left).
  31. Italic(true)
  32. variadicDataStyle = lipgloss.NewStyle().
  33. Width(40).
  34. BorderStyle(lipgloss.ASCIIBorder())
  35. borderStyle = lipgloss.NewStyle().
  36. Width(80).
  37. Padding(1, 2).
  38. Margin(1).
  39. BorderStyle(lipgloss.NormalBorder())
  40. indexStyle = lipgloss.NewStyle().
  41. Italic(true)
  42. pointerStyle = lipgloss.NewStyle().
  43. Faint(true)
  44. collectionStyleLeft = lipgloss.NewStyle().
  45. Width(80).
  46. Align(lipgloss.Left)
  47. )
  48. // MAIN MODEL DEFINITIONS -----------------------------------------------------
  49. // ----------------------------------------------------------------------------
  50. // The main bubbletea Model
  51. type Model struct {
  52. widget widget
  53. content string
  54. Path string
  55. viewport viewport.Model
  56. viewportReady bool
  57. width int
  58. }
  59. // The bubbletea init function
  60. func (m Model) Init() tea.Cmd { return m.load }
  61. // Core tea.Model update loop
  62. func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
  63. var cmds []tea.Cmd
  64. // general message handling
  65. switch msg := msg.(type) {
  66. case tea.WindowSizeMsg:
  67. m.width = msg.Width
  68. headerHeight := lipgloss.Height(m.header())
  69. footerHeight := lipgloss.Height(m.footer())
  70. verticalMarginHeight := headerHeight + footerHeight
  71. if !m.viewportReady {
  72. m.viewport = viewport.New(
  73. viewport.WithWidth(msg.Width),
  74. viewport.WithHeight(msg.Height-verticalMarginHeight),
  75. )
  76. m.viewport.YPosition = headerHeight
  77. m.viewport.SetContent(m.content)
  78. m.viewportReady = true
  79. } else {
  80. m.viewport.SetWidth(msg.Width)
  81. m.viewport.SetHeight(msg.Height - verticalMarginHeight)
  82. }
  83. case tea.KeyMsg: // KeyMsg capture that is always present
  84. switch msg.String() {
  85. case "ctrl+c":
  86. cmds = append(cmds, tea.Quit)
  87. }
  88. switch msg.Type {
  89. case tea.KeyUp:
  90. m.viewport.ScrollUp(1)
  91. case tea.KeyDown:
  92. m.viewport.ScrollDown(1)
  93. }
  94. case widget: // widget is initialized from m.load()
  95. switch T := msg.(type) {
  96. case edit:
  97. m.widget = T
  98. cmds = append(cmds, T.render, T.init())
  99. default:
  100. m.widget = T
  101. cmds = append(cmds, T.render)
  102. }
  103. case loadPath:
  104. m.Path = string(msg)
  105. return m, m.load
  106. case createInCollectionMsg:
  107. wg := createInCollection{Path: string(msg)}
  108. i := textinput.New()
  109. i.Placeholder = "a short title"
  110. i.Focus()
  111. i.CharLimit = 80
  112. i.Width = 30
  113. wg.input = i
  114. return m, func() tea.Msg { return wg }
  115. case string:
  116. m.content = msg
  117. m.viewport.SetContent(m.content)
  118. cmds = append(cmds, tea.EnterAltScreen)
  119. }
  120. // finally, pass msg to widget
  121. var cmd tea.Cmd
  122. switch w := m.widget.(type) {
  123. case edit:
  124. m.widget, cmd = w.update(msg)
  125. cmds = append(cmds, cmd)
  126. case Issue:
  127. m.widget, cmd = w.update(msg)
  128. cmds = append(cmds, cmd)
  129. case IssueCollection:
  130. m.widget, cmd = w.update(msg)
  131. cmds = append(cmds, cmd)
  132. case createInCollection:
  133. m.widget, cmd = w.update(msg)
  134. cmds = append(cmds, cmd)
  135. }
  136. return m, tea.Batch(cmds...)
  137. }
  138. // Handles top level view functionality
  139. func (m Model) View() string {
  140. var output string
  141. if len(m.content) == 0 {
  142. return "loading..."
  143. } else {
  144. output = output + m.header()
  145. output = output + m.viewport.View()
  146. footerLeft := lipgloss.NewStyle().Faint(true).Margin(1).Render(m.widget.keyhelp())
  147. footerRight := lipgloss.NewStyle().Faint(true).Margin(1).Render(m.footer())
  148. line := strings.Repeat("─",
  149. max(0, m.viewport.Width()-lipgloss.Width(footerLeft)-lipgloss.Width(footerRight)))
  150. line = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render(line)
  151. output = output + lipgloss.JoinHorizontal(lipgloss.Center,
  152. footerLeft,
  153. line,
  154. footerRight,
  155. )
  156. // output = output + lipgloss.NewStyle().Faint(true).Margin(0, 1, 1, 1).Render(m.footer())
  157. return output
  158. }
  159. }
  160. // Handles load logic
  161. func (m Model) load() tea.Msg {
  162. if IsIssue(m.Path) {
  163. issue, err := Issue{}.NewFromPath(m.Path)
  164. if err != nil {
  165. return nil
  166. }
  167. return issue
  168. }
  169. if IsIssueCollection(m.Path) {
  170. collection, err := IssueCollection{}.NewFromPath(m.Path)
  171. if err != nil {
  172. return nil
  173. }
  174. return collection
  175. }
  176. return newEditWidget(m.Path)
  177. }
  178. // renders a header for the program
  179. func (m Model) header() string {
  180. var borderStyle = lipgloss.NewStyle().
  181. BorderStyle(lipgloss.NormalBorder()).
  182. BorderForeground(lipgloss.Color("8")).
  183. Padding(0, 1)
  184. left := "tissues v0.0"
  185. left = borderStyle.Render(left)
  186. line := strings.Repeat("─", max(0, m.viewport.Width()-lipgloss.Width(left)))// -lipgloss.Width(right)))
  187. line = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render(line)
  188. return lipgloss.JoinHorizontal(lipgloss.Center, line, left)
  189. }
  190. // renders a footer for the program
  191. func (m Model) footer() string { return "j/k: scroll\t\tctrl+c: quit" }
  192. // WIDGET DEFINITIONS ---------------------------------------------------------
  193. // ----------------------------------------------------------------------------
  194. // interface definition for widgets
  195. type widget interface {
  196. update(tea.Msg) (widget, tea.Cmd) // implements widget specific update life cycles
  197. render() tea.Msg // renders content
  198. keyhelp() string // renders key usage
  199. }
  200. // -------- edit widget definitions -----------------------------------------
  201. // ----------------------------------------------------------------------------
  202. type ( // Type definitions for use in tea.Msg life cycle for create widget.
  203. createResult Issue // type wrapper for create.create() result
  204. writeResult any // type wrapper for create.write() result
  205. editorResult struct { // type wrapper for create.editor() result
  206. err error // any error returned by InvokeEditor
  207. issue Issue // the data in the template file
  208. }
  209. )
  210. // data struct for create widget
  211. type inputField struct {
  212. input textinput.Model
  213. title string
  214. }
  215. // struct definition for edit widget
  216. type edit struct {
  217. inputFields []inputField
  218. Path string
  219. selected int
  220. existing bool // flag to edit existing description
  221. err error // not implemented
  222. }
  223. // constructor for the edit widget
  224. func newEditWidget(path string) widget {
  225. // data prep
  226. var e edit
  227. var issue Issue
  228. var err error
  229. if IsIssue(path) { // if path is existing issue, load Issue from path
  230. issue, err = Issue{}.NewFromPath(path)
  231. if err != nil {
  232. return e
  233. }
  234. e.existing = true
  235. } else { // if path is not existing issue, create new Issue with sensible defaults
  236. issue = Issue{
  237. Path: path, Title: parsePathToHuman(path),
  238. Status: Field{Path: "/status", Data: "open"},
  239. }
  240. }
  241. // anon function for spawning instantiated textinput.Model's
  242. spawnInput := func(f bool) textinput.Model {
  243. ti := textinput.New()
  244. if f {
  245. ti.Focus()
  246. }
  247. ti.CharLimit = 80
  248. ti.Width = 30
  249. return ti
  250. }
  251. // inputFields data prep
  252. var fields []inputField
  253. var input textinput.Model
  254. // title
  255. input = spawnInput(true)
  256. input.SetValue(issue.Title)
  257. input.Placeholder = "title"
  258. fields = append(fields, inputField{input: input, title: "title"})
  259. // status
  260. input = spawnInput(false)
  261. input.SetValue(issue.Status.Data)
  262. input.Placeholder = "status"
  263. fields = append(fields, inputField{input: input, title: "status"})
  264. // tags
  265. input = spawnInput(false)
  266. input.SetValue(issue.Tags.AsString())
  267. input.Placeholder = "tags, separated by comma"
  268. fields = append(fields, inputField{input: input, title: "tags"})
  269. // blockedby
  270. input = spawnInput(false)
  271. input.SetValue(issue.Blockedby.AsString())
  272. input.Placeholder = "blockers, separated by comma"
  273. fields = append(fields, inputField{input: input, title: "blockers"})
  274. e.inputFields = fields
  275. e.Path = path
  276. return e
  277. }
  278. // init cmd for create widget
  279. func (e edit) init() tea.Cmd { return textinput.Blink }
  280. // update cmd for create widget
  281. func (e edit) update(msg tea.Msg) (widget, tea.Cmd) {
  282. var cmds []tea.Cmd
  283. var cmd tea.Cmd
  284. // simple anon functions to increment the selected index
  285. incrementSelected := func() {
  286. if e.selected < len(e.inputFields) {
  287. e.selected++
  288. for i := 0; i < len(e.inputFields); i++ {
  289. if i == e.selected {
  290. e.inputFields[i].input.Focus()
  291. } else {
  292. e.inputFields[i].input.Blur()
  293. }
  294. }
  295. } else {
  296. e.selected = 0
  297. e.inputFields[e.selected].input.Focus()
  298. }
  299. }
  300. decrementSelected := func() {
  301. if e.selected != 0 {
  302. e.selected--
  303. for i := 0; i < len(e.inputFields); i++ {
  304. if i == e.selected {
  305. e.inputFields[i].input.Focus()
  306. } else {
  307. e.inputFields[i].input.Blur()
  308. }
  309. }
  310. } else {
  311. for i := 0; i < len(e.inputFields); i++ {
  312. e.inputFields[i].input.Blur()
  313. }
  314. e.selected = len(e.inputFields)
  315. }
  316. }
  317. switch msg := msg.(type) { // keybinding handler
  318. case tea.KeyMsg:
  319. switch msg.String() {
  320. case "tab":
  321. incrementSelected()
  322. case "shift+tab":
  323. decrementSelected()
  324. case "enter":
  325. if e.selected == len(e.inputFields) { // confirm create
  326. e.selected++
  327. } else if e.selected == len(e.inputFields)+1 { // confirmed
  328. cmds = append(cmds, e.createIssueObject)
  329. } else {
  330. incrementSelected()
  331. }
  332. case "esc": // reset
  333. for i, field := range e.inputFields {
  334. field.input.Reset()
  335. switch field.title {
  336. case "title":
  337. parsed := parsePathToHuman(e.Path)
  338. if parsed == "." {
  339. parsed = ""
  340. }
  341. field.input.SetValue(parsed)
  342. case "status":
  343. field.input.SetValue("open")
  344. }
  345. e.inputFields[i] = field
  346. }
  347. }
  348. cmds = append(cmds, e.render)
  349. case createResult:
  350. if !e.existing {
  351. cmds = append(cmds, tea.ExitAltScreen, e.editBlankDescription(Issue(msg)))
  352. } else {
  353. cmds = append(cmds, tea.ExitAltScreen, e.editExistingDescription(Issue(msg)))
  354. }
  355. case editorResult:
  356. if msg.err != nil {
  357. e.err = msg.err
  358. } else {
  359. cmds = append(cmds, e.write(msg.issue))
  360. }
  361. case writeResult:
  362. switch value := msg.(type) {
  363. case bool:
  364. if !value {
  365. } else {
  366. cmds = append(cmds, func() tea.Msg { return loadPath(e.Path) })
  367. }
  368. case error:
  369. e.selected = -100
  370. e.err = value
  371. }
  372. }
  373. for i, ti := range e.inputFields {
  374. e.inputFields[i].input, cmd = ti.input.Update(msg)
  375. cmds = append(cmds, cmd)
  376. }
  377. cmds = append(cmds, cmd)
  378. return e, tea.Batch(cmds...)
  379. }
  380. // A tea.Cmd to translate createIssueObject.inputs to a new Issue object
  381. func (e edit) createIssueObject() tea.Msg {
  382. data := make(map[string]string)
  383. commaSplit := func(t string) []string {
  384. s := strings.Split(t, ",")
  385. for i, v := range s {
  386. s[i] = strings.TrimLeft(v, " \t\n")
  387. s[i] = strings.TrimRight(s[i], " \t\n")
  388. s[i] = parseHumanToPath(s[i])
  389. }
  390. return s
  391. }
  392. for _, field := range e.inputFields {
  393. data[field.title] = field.input.Value()
  394. }
  395. var newIssue = Issue{
  396. Path: e.Path,
  397. Tags: VariadicField{Path: "/tags"},
  398. Blockedby: VariadicField{Path: "/blockedby"},
  399. }
  400. for key, value := range data {
  401. switch key {
  402. case "title":
  403. newIssue.Title = value
  404. if parsePathToHuman(newIssue.Path) != value {
  405. dir, _ := filepath.Split(newIssue.Path)
  406. newIssue.Path = filepath.Join(dir, parseHumanToPath(value))
  407. }
  408. case "status":
  409. newIssue.Status = Field{Path: "/status", Data: value}
  410. case "description":
  411. newIssue.Description = Field{Path: "/description", Data: value}
  412. case "tags":
  413. splitTags := commaSplit(value)
  414. for _, tag := range splitTags {
  415. newIssue.Tags.Fields = append(newIssue.Tags.Fields, Field{Path: tag})
  416. }
  417. case "blockers":
  418. splitBlockedby := commaSplit(value)
  419. for _, blocker := range splitBlockedby {
  420. newIssue.Blockedby.Fields = append(
  421. newIssue.Blockedby.Fields, Field{Path: blocker},
  422. )
  423. }
  424. }
  425. }
  426. return createResult(newIssue)
  427. }
  428. // Wraps a tea.Cmd function, passes an initialized Issue to WriteIssue()
  429. func (e edit) write(issue Issue) tea.Cmd {
  430. return func() tea.Msg {
  431. result, err := WriteIssue(issue, true)
  432. if err != nil {
  433. return writeResult(err)
  434. }
  435. return writeResult(result)
  436. }
  437. }
  438. // Calls InvokeEditor via EditTemplate using DescriptionTemplate, reports output
  439. // and errors
  440. //
  441. // WARNING! THIS METHOD HANGS UNTIL THE USER KILLS THE EDITOR!
  442. func (e edit) editBlankDescription(issue Issue) tea.Cmd {
  443. data, err := EditTemplate(DescriptionTemplate, InvokeEditor)
  444. var output string
  445. for _, line := range data {
  446. output = output + line
  447. }
  448. issue.Description = Field{Path: "/description", Data: output}
  449. return func() tea.Msg {
  450. return editorResult{issue: issue, err: err}
  451. }
  452. }
  453. // Calls InvokeEditor with e.Path, reports output via ReadTemplate
  454. func (e edit) editExistingDescription(issue Issue) tea.Cmd {
  455. err := InvokeEditor(filepath.Join(e.Path, "description"))
  456. if err != nil {
  457. return func() tea.Msg { return editorResult{issue: issue, err: err} }
  458. }
  459. data, err := ReadTemplate(filepath.Join(e.Path, "description"))
  460. var output string
  461. for _, line := range data {
  462. output = output + line
  463. }
  464. issue.Description = Field{Path: "/description", Data: output}
  465. return func() tea.Msg { return editorResult{issue: issue, err: err} }
  466. }
  467. // render cmd for create widget
  468. func (e edit) render() tea.Msg {
  469. if e.err != nil {
  470. return fmt.Sprintf("failed to create issue... %s", e.err.Error())
  471. }
  472. borderStyle := lipgloss.NewStyle().
  473. BorderStyle(lipgloss.NormalBorder()).
  474. Margin(1).
  475. Padding(0, 1)
  476. ulStyle := lipgloss.NewStyle().Underline(true)
  477. var output string
  478. for _, field := range e.inputFields {
  479. output = output + fmt.Sprintf(
  480. "\n%s:%s",
  481. field.title,
  482. borderStyle.Render(field.input.View()),
  483. )
  484. }
  485. output = strings.TrimLeft(output, "\n")
  486. if e.selected < len(e.inputFields) {
  487. output = output + borderStyle.Render("press enter to submit...")
  488. } else if e.selected == len(e.inputFields) {
  489. output = output + borderStyle.Render(ulStyle.Render("press enter to submit..."))
  490. } else if e.selected == len(e.inputFields)+1 {
  491. confirmPrompt := fmt.Sprintf(
  492. "create issue titled \"%s\"?\n\n%s",
  493. ulStyle.Render(e.inputFields[0].input.Value()),
  494. ulStyle.Render("press enter to write description..."),
  495. )
  496. output = output + borderStyle.Render(confirmPrompt)
  497. }
  498. return output
  499. }
  500. // keyhelp cmd for create widget
  501. func (e edit) keyhelp() string {
  502. var output string
  503. output = output + "tab/shift+tab: down/up\t\tenter: input value\t\tesc: reset\t\tctrl+e: quit"
  504. return output
  505. }
  506. // -------- Issue widget definitions ------------------------------------------
  507. // ----------------------------------------------------------------------------
  508. // enforce widget interface compliance
  509. func (i Issue) update(msg tea.Msg) (widget, tea.Cmd) {
  510. var cmds []tea.Cmd
  511. switch msg := msg.(type) {
  512. case tea.KeyMsg:
  513. switch msg.String() {
  514. case "e":
  515. cmds = append(cmds, i.edit)
  516. case "esc":
  517. cmds = append(cmds, i.back)
  518. }
  519. }
  520. return i, tea.Batch(cmds...)
  521. }
  522. func (i Issue) edit() tea.Msg { return newEditWidget(i.Path) }
  523. func (i Issue) back() tea.Msg {
  524. remainder, _ := filepath.Split(i.Path)
  525. remainder = strings.TrimRight(remainder, "/")
  526. if len(remainder) == 0 {
  527. return nil
  528. }
  529. return loadPath(remainder)
  530. }
  531. // render cmd for Issue widget
  532. func (i Issue) render() tea.Msg {
  533. var output string
  534. // title
  535. output = output + titleStyle.Render(i.Title)
  536. // status
  537. output = output + fmt.Sprintf("\n%s", detailStyle.Render(i.Status.Data))
  538. // variadics
  539. var tags string
  540. for _, field := range i.Tags.Fields {
  541. tags = tags + parsePathToHuman(field.Path) + ", "
  542. }
  543. tags = strings.TrimRight(tags, ", ")
  544. var blockedby string
  545. for _, field := range i.Blockedby.Fields {
  546. blockedby = blockedby + parsePathToHuman(field.Path) + ", "
  547. }
  548. blockedby = strings.TrimRight(blockedby, ", ")
  549. if len(i.Tags.Fields) > 0 {
  550. output = output + variadicTitleStyle.Render("\n\nTags:")
  551. output = output + fmt.Sprintf("\n%s", variadicDataStyle.Render(tags))
  552. }
  553. if len(i.Blockedby.Fields) > 0 {
  554. output = output + variadicTitleStyle.Render("\n\nBlockedby:")
  555. output = output + fmt.Sprintf("\n%s", variadicDataStyle.Render(blockedby))
  556. }
  557. // description
  558. output = output + titleStyle.Render("\n\nDescription:\n")
  559. output = output + fmt.Sprintf("\n%s", i.Description.Data)
  560. return borderStyle.Render(output)
  561. }
  562. // keyhelp cmd for Issue widget
  563. func (i Issue) keyhelp() string {
  564. var output string
  565. var escStr string
  566. remainder, _ := filepath.Split(i.Path)
  567. if IsIssueCollection(remainder) {
  568. escStr = "\t\tesc: back"
  569. }
  570. output = output + fmt.Sprintf("e: edit issue%s\t\tctrl+c: quit", escStr)
  571. return output
  572. }
  573. // -------- IssueCollection widget definitions --------------------------------
  574. // ----------------------------------------------------------------------------
  575. type ( // Type definitions for use in tea.Msg life cycle for IssueCollection widget.
  576. loadPath string // thrown when user selects a path to load.
  577. createInCollectionMsg string //thrown when user opts to create new issue in collection
  578. )
  579. // enforce widget interface compliance
  580. func (ic IssueCollection) update(msg tea.Msg) (widget, tea.Cmd) {
  581. switch msg := msg.(type) {
  582. case tea.KeyMsg:
  583. switch msg.String() {
  584. case "tab":
  585. if ic.selection+1 < len(ic.Collection) {
  586. ic.selection = ic.selection + 1
  587. } else {
  588. ic.selection = 0
  589. }
  590. return ic, ic.render
  591. case "shift+tab":
  592. if ic.selection != 0 {
  593. ic.selection = ic.selection - 1
  594. } else {
  595. ic.selection = len(ic.Collection) - 1
  596. }
  597. return ic, ic.render
  598. case "enter":
  599. ic.Path = ic.Collection[ic.selection].Path
  600. return ic, ic.sendLoad
  601. case "c":
  602. return ic, ic.newIssueInCollection
  603. case "e":
  604. ic.Path = ic.Collection[ic.selection].Path
  605. return ic, ic.edit
  606. }
  607. }
  608. return ic, nil
  609. }
  610. func (ic IssueCollection) sendLoad() tea.Msg { return loadPath(ic.Path) }
  611. func (ic IssueCollection) newIssueInCollection() tea.Msg { return createInCollectionMsg(ic.Path) }
  612. func (ic IssueCollection) edit() tea.Msg { return newEditWidget(ic.Path) }
  613. // render cmd for IssueCollection widget
  614. func (ic IssueCollection) render() tea.Msg {
  615. var output string
  616. var left string
  617. output = output + "\nIssues in " + ic.Path + "...\n\n"
  618. for i, issue := range ic.Collection {
  619. // pointer render
  620. if i == ic.selection {
  621. left = left + pointerStyle.Render("-> ")
  622. } else {
  623. left = left + pointerStyle.Render("•• ")
  624. }
  625. // index render
  626. // left = left + "[" + indexStyle.Render(fmt.Sprintf("%d", i+1)) + "]: "
  627. // title render
  628. left = left + fmt.Sprintf("%s\n", titleStyle.Render(issue.Title))
  629. left = left + detailStyle.Render(fmt.Sprintf(" %s: %s", "tags",
  630. lipgloss.NewStyle().Italic(true).Render(issue.Tags.AsString())))
  631. left = left + detailStyle.Render(fmt.Sprintf("\n %s: %s", "blockers",
  632. lipgloss.NewStyle().Italic(true).Render(issue.Blockedby.AsString())))
  633. }
  634. output = output + collectionStyleLeft.Render(left)
  635. return output
  636. }
  637. // keyhelp cmd for IssueCollection widget
  638. func (ic IssueCollection) keyhelp() string {
  639. var output string
  640. output = output + "tab/shift+tab: select\t\tenter: view issue\t\tc: create new issue\t\te: edit selected issue"
  641. return output
  642. }
  643. // -------- createInCollection widget definitions -----------------------------
  644. // ----------------------------------------------------------------------------
  645. type createInCollection struct {
  646. Path string // base path for the new issue
  647. name string // the name input by the user
  648. input textinput.Model // the input widget
  649. }
  650. func (w createInCollection) update(msg tea.Msg) (widget, tea.Cmd) {
  651. var cmds []tea.Cmd
  652. switch msg := msg.(type) {
  653. case tea.KeyMsg:
  654. switch msg.String() {
  655. case "enter":
  656. cmds = append(cmds, w.create)
  657. }
  658. cmds = append(cmds, w.render)
  659. }
  660. var inputCmd tea.Cmd
  661. w.input, inputCmd = w.input.Update(msg)
  662. cmds = append(cmds, inputCmd)
  663. w.name = w.input.Value()
  664. return w, tea.Batch(cmds...)
  665. }
  666. func (w createInCollection) create() tea.Msg {
  667. w.Path = filepath.Join(w.Path, parseHumanToPath(w.name))
  668. return newEditWidget(w.Path)
  669. }
  670. func (w createInCollection) render() tea.Msg {
  671. return borderStyle.Render(fmt.Sprintf("Creating new issue in %s\ntitle: %s",
  672. w.Path, w.input.View()))
  673. }
  674. func (w createInCollection) keyhelp() string { return "enter: submit\t\tctrl+c: quit" }