session.go 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471
  1. package session
  2. import (
  3. "bytes"
  4. "encoding/json"
  5. "fmt"
  6. "os"
  7. "strings"
  8. "git.andreafazzi.eu/andrea/probo/pkg/models"
  9. "git.andreafazzi.eu/andrea/probo/pkg/store/file"
  10. "github.com/alecthomas/chroma/quick"
  11. "github.com/charmbracelet/bubbles/help"
  12. "github.com/charmbracelet/bubbles/key"
  13. "github.com/charmbracelet/bubbles/spinner"
  14. btTable "github.com/charmbracelet/bubbles/table"
  15. tea "github.com/charmbracelet/bubbletea"
  16. "github.com/charmbracelet/glamour"
  17. "github.com/charmbracelet/huh"
  18. "github.com/charmbracelet/lipgloss"
  19. "github.com/d5/tengo/v2"
  20. "github.com/d5/tengo/v2/stdlib"
  21. "github.com/remogatto/sugarfoam/components/form"
  22. "github.com/remogatto/sugarfoam/components/group"
  23. "github.com/remogatto/sugarfoam/components/header"
  24. "github.com/remogatto/sugarfoam/components/statusbar"
  25. "github.com/remogatto/sugarfoam/components/table"
  26. "github.com/remogatto/sugarfoam/components/viewport"
  27. "github.com/remogatto/sugarfoam/layout"
  28. "github.com/remogatto/sugarfoam/layout/tiled"
  29. )
  30. type SessionModel struct {
  31. // UI
  32. form *form.Model
  33. viewport *viewport.Model
  34. table *table.Model
  35. group *group.Model
  36. help help.Model
  37. statusBar *statusbar.Model
  38. spinner spinner.Model
  39. // Layout
  40. document *layout.Layout
  41. // Key bindings
  42. bindings *keyBindings
  43. // file store
  44. store *file.SessionFileStore
  45. result []any
  46. // json
  47. FilteredJson string
  48. InputJson string
  49. Result string
  50. // session
  51. session *models.Session
  52. // markdown
  53. mdRenderer *glamour.TermRenderer
  54. // filter file
  55. scriptFilePath string
  56. state int
  57. }
  58. type keyBindings struct {
  59. group *group.Model
  60. quit, enter key.Binding
  61. }
  62. func (k *keyBindings) ShortHelp() []key.Binding {
  63. keys := make([]key.Binding, 0)
  64. current := k.group.Current()
  65. switch item := current.(type) {
  66. case *viewport.Model:
  67. keys = append(
  68. keys,
  69. item.KeyMap.Up,
  70. item.KeyMap.Down,
  71. )
  72. }
  73. keys = append(
  74. keys,
  75. k.quit,
  76. )
  77. return keys
  78. }
  79. func (k keyBindings) FullHelp() [][]key.Binding {
  80. return [][]key.Binding{
  81. {
  82. k.quit,
  83. },
  84. }
  85. }
  86. func newBindings(g *group.Model) *keyBindings {
  87. return &keyBindings{
  88. group: g,
  89. quit: key.NewBinding(
  90. key.WithKeys("esc"), key.WithHelp("esc", "Quit app"),
  91. ),
  92. enter: key.NewBinding(
  93. key.WithKeys("enter"), key.WithHelp("enter", "Quit app and return the results"),
  94. ),
  95. }
  96. }
  97. func New(path string, stdin string) *SessionModel {
  98. form := form.New(
  99. form.WithGroups(huh.NewGroup(
  100. huh.NewInput().
  101. Title("Session name").
  102. Description("Enter the name of the session"),
  103. )))
  104. form.
  105. WithShowHelp(false).
  106. WithTheme(huh.ThemeDracula())
  107. viewport := viewport.New()
  108. table := table.New(table.WithRelWidths(20, 30, 30, 20))
  109. table.Model.SetColumns([]btTable.Column{
  110. {Title: "Token", Width: 20},
  111. {Title: "Lastname", Width: 20},
  112. {Title: "Firstname", Width: 20},
  113. {Title: "Class", Width: 20},
  114. })
  115. help := help.New()
  116. group := group.New(
  117. group.WithItems(form, table, viewport),
  118. group.WithLayout(
  119. layout.New(
  120. layout.WithStyles(&layout.Styles{Container: lipgloss.NewStyle().Padding(1, 0, 1, 0)}),
  121. layout.WithItem(form),
  122. layout.WithItem(tiled.New(table, viewport)),
  123. ),
  124. ),
  125. )
  126. bindings := newBindings(group)
  127. statusBar := statusbar.New(bindings)
  128. s := spinner.New(
  129. spinner.WithStyle(
  130. lipgloss.NewStyle().Foreground(lipgloss.Color("265"))),
  131. )
  132. s.Spinner = spinner.Dot
  133. header := header.New(
  134. header.WithContent(
  135. lipgloss.NewStyle().
  136. Bold(true).
  137. Border(lipgloss.NormalBorder(), false, false, true, false).
  138. Render("✨ Create session ✨"),
  139. ),
  140. )
  141. document := layout.New(
  142. layout.WithStyles(&layout.Styles{Container: lipgloss.NewStyle().Margin(1)}),
  143. layout.WithItem(header),
  144. layout.WithItem(group),
  145. layout.WithItem(statusBar),
  146. )
  147. renderer, err := glamour.NewTermRenderer(
  148. glamour.WithStandardStyle("dracula"),
  149. glamour.WithWordWrap(80),
  150. )
  151. if err != nil {
  152. panic(err)
  153. }
  154. return &SessionModel{
  155. form: form,
  156. table: table,
  157. viewport: viewport,
  158. group: group,
  159. statusBar: statusBar,
  160. spinner: s,
  161. document: document,
  162. mdRenderer: renderer,
  163. bindings: bindings,
  164. help: help,
  165. scriptFilePath: path,
  166. InputJson: stdin,
  167. }
  168. }
  169. func (m *SessionModel) Init() tea.Cmd {
  170. var cmds []tea.Cmd
  171. cmds = append(cmds, m.group.Init(), m.loadStore(), m.spinner.Tick)
  172. m.group.Focus()
  173. return tea.Batch(cmds...)
  174. }
  175. func (m *SessionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
  176. var cmds []tea.Cmd
  177. switch msg := msg.(type) {
  178. case tea.WindowSizeMsg:
  179. m.handleWindowSize(msg)
  180. case tea.KeyMsg:
  181. switch {
  182. case key.Matches(msg, m.bindings.quit):
  183. m.FilteredJson = ""
  184. return m, tea.Quit
  185. case key.Matches(msg, m.bindings.enter):
  186. m.marshalJSON()
  187. return m, tea.Quit
  188. }
  189. case storeLoadedMsg:
  190. cmds = append(cmds, m.handleStoreLoaded(msg))
  191. case scriptExecutedMsg:
  192. m.handleScriptExecuted(msg)
  193. case errorMsg:
  194. m.handleError(msg)
  195. m.state = ErrorState
  196. }
  197. cmds = m.handleState(msg, cmds)
  198. return m, tea.Batch(cmds...)
  199. }
  200. func (m *SessionModel) View() string {
  201. return m.document.View()
  202. }
  203. func (m *SessionModel) executeScript(path string) tea.Cmd {
  204. return func() tea.Msg {
  205. if m.scriptFilePath == "" {
  206. return nil
  207. }
  208. sessionJson, err := json.Marshal(models.Session{Exams: map[string]*models.Exam{}})
  209. if err != nil {
  210. panic(err)
  211. }
  212. script, err := os.ReadFile(m.scriptFilePath)
  213. if err != nil {
  214. return errorMsg{err}
  215. }
  216. s := tengo.NewScript(script)
  217. s.SetImports(stdlib.GetModuleMap("fmt", "json"))
  218. _ = s.Add("input", m.InputJson)
  219. _ = s.Add("output", string(sessionJson))
  220. // compile the source
  221. c, err := s.Compile()
  222. if err != nil {
  223. return errorMsg{err}
  224. }
  225. if err := c.Run(); err != nil {
  226. return errorMsg{err}
  227. }
  228. return scriptExecutedMsg{fmt.Sprintf("%s", c.Get("output"))}
  229. }
  230. }
  231. func (m *SessionModel) marshalJSON() {
  232. var result interface{}
  233. err := json.Unmarshal([]byte(m.FilteredJson), &result)
  234. if err != nil {
  235. panic(err)
  236. }
  237. resultJson, err := json.Marshal(result)
  238. if err != nil {
  239. panic(err)
  240. }
  241. m.Result = string(resultJson)
  242. }
  243. func (m *SessionModel) showErrorOnStatusBar(err error) {
  244. m.statusBar.SetContent(
  245. stateFormats[ErrorState][0],
  246. fmt.Sprintf(stateFormats[ErrorState][1], err),
  247. stateFormats[ErrorState][2],
  248. )
  249. }
  250. func (m *SessionModel) updateTableContent(session *models.Session) {
  251. rows := make([]btTable.Row, 0)
  252. for token, exam := range session.Exams {
  253. rows = append(rows, btTable.Row{
  254. token,
  255. exam.Participant.Lastname,
  256. exam.Participant.Firstname,
  257. exam.Participant.Attributes.Get("class"),
  258. })
  259. }
  260. m.table.SetRows(rows)
  261. }
  262. func (m *SessionModel) updateViewportContent(session *models.Session) {
  263. currentToken := m.table.SelectedRow()[0]
  264. currentExam := session.Exams[currentToken]
  265. if currentExam == nil {
  266. panic("Current token is not associate to any exam")
  267. }
  268. md, err := currentExam.ToMarkdown()
  269. if err != nil {
  270. m.showErrorOnStatusBar(err)
  271. }
  272. result, err := m.mdRenderer.Render(md)
  273. if err != nil {
  274. m.showErrorOnStatusBar(err)
  275. }
  276. m.viewport.SetContent(result)
  277. }
  278. func (m *SessionModel) createMDRenderer(width int) *glamour.TermRenderer {
  279. renderer, err := glamour.NewTermRenderer(
  280. glamour.WithStandardStyle("dracula"),
  281. glamour.WithWordWrap(m.viewport.GetWidth()),
  282. )
  283. if err != nil {
  284. panic(err)
  285. }
  286. return renderer
  287. }
  288. func (m *SessionModel) handleWindowSize(msg tea.WindowSizeMsg) {
  289. m.group.SetSize(msg.Width, msg.Height)
  290. m.document.SetSize(msg.Width, msg.Height)
  291. m.mdRenderer = m.createMDRenderer(msg.Width)
  292. }
  293. func (m *SessionModel) handleError(msg tea.Msg) {
  294. err := msg.(errorMsg)
  295. m.statusBar.SetContent(
  296. stateFormats[ErrorState][0],
  297. fmt.Sprintf(stateFormats[ErrorState][1], err.error),
  298. stateFormats[ErrorState][2],
  299. )
  300. }
  301. func (m *SessionModel) handleScriptExecuted(msg tea.Msg) {
  302. session := new(models.Session)
  303. jsonData := []byte(msg.(scriptExecutedMsg).result)
  304. err := json.Unmarshal(jsonData, &session)
  305. if err != nil {
  306. panic(err)
  307. }
  308. m.session = session
  309. m.updateTableContent(session)
  310. m.updateViewportContent(session)
  311. m.state = BrowseState
  312. }
  313. func (m *SessionModel) handleStoreLoaded(msg tea.Msg) tea.Cmd {
  314. storeMsg := msg.(storeLoadedMsg)
  315. m.store = storeMsg.store
  316. return m.executeScript(m.scriptFilePath)
  317. }
  318. func (m *SessionModel) handleState(msg tea.Msg, cmds []tea.Cmd) []tea.Cmd {
  319. _, cmd := m.group.Update(msg)
  320. if m.state == LoadingStoreState {
  321. return m.updateSpinner(msg, cmd, cmds)
  322. }
  323. if m.state == BrowseState {
  324. m.updateViewportContent(m.session)
  325. }
  326. cmds = append(cmds, cmd /*, m.query(m.textInput.Value())*/)
  327. if m.state != ErrorState {
  328. m.statusBar.SetContent(stateFormats[BrowseState]...)
  329. }
  330. return cmds
  331. }
  332. func (m *SessionModel) updateSpinner(msg tea.Msg, cmd tea.Cmd, cmds []tea.Cmd) []tea.Cmd {
  333. m.spinner, cmd = m.spinner.Update(msg)
  334. m.statusBar.SetContent(fmt.Sprintf(stateFormats[m.state][0], m.spinner.View()), stateFormats[m.state][1], stateFormats[m.state][2])
  335. cmds = append(cmds, cmd)
  336. return cmds
  337. }
  338. func (m *SessionModel) loadStore() tea.Cmd {
  339. return func() tea.Msg {
  340. sStore, err := file.NewDefaultSessionFileStore()
  341. if err != nil {
  342. return errorMsg{err}
  343. }
  344. return storeLoadedMsg{sStore}
  345. }
  346. }
  347. func toColoredJson(data []any) (string, error) {
  348. result, err := json.MarshalIndent(data, "", " ")
  349. if err != nil {
  350. return "", err
  351. }
  352. coloredBytes := make([]byte, 0)
  353. buffer := bytes.NewBuffer(coloredBytes)
  354. err = quick.Highlight(buffer, string(result), "json", "terminal16m", "dracula")
  355. if err != nil {
  356. panic(err)
  357. }
  358. return sanitize(buffer.String()), nil
  359. }
  360. func toJson(data []any) (string, error) {
  361. result, err := json.Marshal(data)
  362. if err != nil {
  363. return "", err
  364. }
  365. return string(result), nil
  366. }
  367. func sanitize(text string) string {
  368. // FIXME: The use of a standard '-' character causes rendering
  369. // issues within the viewport. Further investigation is
  370. // required to resolve this problem.
  371. return strings.Replace(text, "-", "–", -1)
  372. }