tui.go 23 KB

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