tui.go 21 KB

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