tui.go 23 KB

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