tui.go 23 KB

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