ui.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541
  1. package ui
  2. import (
  3. "fmt"
  4. "servitor/ansi"
  5. "servitor/config"
  6. "servitor/feed"
  7. "servitor/history"
  8. "servitor/mime"
  9. "servitor/pub"
  10. "servitor/splicer"
  11. "servitor/style"
  12. "os/exec"
  13. "strconv"
  14. "strings"
  15. "sync"
  16. "errors"
  17. )
  18. /*
  19. The public methods herein are threadsafe, the private methods
  20. are not and need to be protected by State.m
  21. */
  22. /* Modes */
  23. const (
  24. loading = iota
  25. normal
  26. command
  27. selection
  28. opening
  29. problem
  30. )
  31. const (
  32. enterKey byte = '\r'
  33. escapeKey byte = 27
  34. backspaceKey byte = 127
  35. )
  36. type Page struct {
  37. feed *feed.Feed
  38. frontier pub.Tangible
  39. loadingUp bool
  40. children pub.Container
  41. basepoint uint
  42. loadingDown bool
  43. }
  44. type State struct {
  45. m *sync.Mutex
  46. h history.History[*Page]
  47. width int
  48. height int
  49. output func(string)
  50. mode int
  51. buffer string
  52. }
  53. func (s *State) view() string {
  54. const cursor = "┃ "
  55. const parentConnector = " │\n"
  56. const childConnector = "\n"
  57. if s.mode == loading {
  58. return ansi.CenterVertically("", style.Color(" Loading…"), "", uint(s.height))
  59. }
  60. var top, center, bottom string
  61. for i := -config.Parsed.Network.Context; i <= config.Parsed.Network.Context; i++ {
  62. if !s.h.Current().feed.Contains(i) {
  63. continue
  64. }
  65. var serialized string
  66. if s.h.Current().feed.IsParent(i) {
  67. serialized = s.h.Current().feed.Get(i).Preview(s.width - 4)
  68. } else if s.h.Current().feed.IsChild(i) {
  69. serialized = "→ " + ansi.Indent(s.h.Current().feed.Get(i).Preview(s.width-8), " ", false)
  70. } else {
  71. serialized = s.h.Current().feed.Get(i).String(s.width - 4)
  72. }
  73. if i == 0 {
  74. center = ansi.Indent(serialized, cursor, true)
  75. if s.h.Current().feed.IsParent(i) {
  76. bottom = parentConnector
  77. } else {
  78. bottom = childConnector
  79. }
  80. continue
  81. }
  82. serialized = ansi.Indent(serialized, " ", true) + "\n"
  83. if s.h.Current().feed.IsParent(i) {
  84. serialized += parentConnector
  85. } else {
  86. serialized += childConnector
  87. }
  88. if i < 0 {
  89. top += serialized
  90. } else {
  91. bottom += serialized
  92. }
  93. }
  94. if s.h.Current().loadingUp && !s.h.Current().feed.Contains(-config.Parsed.Network.Context-1) {
  95. top = "\n " + style.Color("Loading…") + "\n\n" + top
  96. }
  97. if s.h.Current().loadingDown && !s.h.Current().feed.Contains(config.Parsed.Network.Context+1) {
  98. bottom += " " + style.Color("Loading…") + "\n"
  99. }
  100. /* Remove trailing newlines */
  101. top = strings.TrimSuffix(top, "\n")
  102. bottom = strings.TrimSuffix(bottom, "\n")
  103. output := ansi.CenterVertically(top, center, bottom, uint(s.height))
  104. var footer string
  105. switch s.mode {
  106. case normal:
  107. break
  108. case selection:
  109. footer = "Selecting " + s.buffer + " (press . to open internally, enter to open externally)"
  110. case command:
  111. footer = ":" + s.buffer
  112. case opening:
  113. footer = "Opening " + s.buffer + "\u2026"
  114. case problem:
  115. footer = s.buffer
  116. default:
  117. panic("encountered unrecognized mode")
  118. }
  119. if footer != "" {
  120. output = ansi.ReplaceLastLine(output, style.Highlight(ansi.SetLength(footer, s.width, "\u2026")))
  121. }
  122. return output
  123. }
  124. func (s *State) Update(input byte) {
  125. s.m.Lock()
  126. defer s.m.Unlock()
  127. if s.mode == loading {
  128. return
  129. }
  130. if input == escapeKey {
  131. s.buffer = ""
  132. s.mode = normal
  133. s.output(s.view())
  134. return
  135. }
  136. if input == backspaceKey {
  137. if len(s.buffer) == 0 {
  138. s.mode = normal
  139. s.output(s.view())
  140. return
  141. }
  142. bufferRunes := []rune(s.buffer)
  143. s.buffer = string(bufferRunes[:len(bufferRunes)-1])
  144. if s.buffer == "" && s.mode == selection {
  145. s.mode = normal
  146. }
  147. s.output(s.view())
  148. return
  149. }
  150. if s.mode == command {
  151. if input == enterKey {
  152. if args := strings.SplitN(s.buffer, " ", 2); len(args) == 2 {
  153. err := s.subcommand(args[0], args[1])
  154. if err != nil {
  155. s.buffer = "Failed to run command: " + err.Error()
  156. s.mode = problem
  157. s.output(s.view())
  158. s.buffer = ""
  159. s.mode = normal
  160. }
  161. } else {
  162. s.buffer = ""
  163. s.mode = normal
  164. s.output(s.view())
  165. }
  166. return
  167. }
  168. s.buffer += string(input)
  169. s.output(s.view())
  170. return
  171. }
  172. if input == ':' {
  173. s.buffer = ""
  174. s.mode = command
  175. s.output(s.view())
  176. return
  177. }
  178. if input >= '0' && input <= '9' {
  179. if s.mode != selection {
  180. s.buffer = ""
  181. }
  182. s.buffer += string(input)
  183. s.mode = selection
  184. s.output(s.view())
  185. return
  186. }
  187. if s.mode == selection {
  188. if input == '.' || input == enterKey {
  189. number, err := strconv.Atoi(s.buffer)
  190. if err != nil {
  191. panic("buffer had a non-number while in selection mode")
  192. }
  193. link, mediaType, present := s.h.Current().feed.Current().SelectLink(number)
  194. if !present {
  195. s.buffer = ""
  196. s.mode = normal
  197. s.output(s.view())
  198. return
  199. }
  200. if input == '.' {
  201. s.openInternally(link)
  202. return
  203. }
  204. if input == enterKey {
  205. s.openExternally(link, mediaType)
  206. return
  207. }
  208. }
  209. /* At this point we know input is a non-number, non-., non-enter */
  210. s.mode = normal
  211. s.buffer = ""
  212. }
  213. switch input {
  214. case 'k': // up
  215. s.h.Current().feed.MoveUp()
  216. s.loadSurroundings()
  217. case 'j': // down
  218. s.h.Current().feed.MoveDown()
  219. s.loadSurroundings()
  220. case 'g': // return to OP
  221. s.h.Current().feed.MoveToCenter()
  222. case 'h': // back in history
  223. s.h.Back()
  224. case 'l': // forward in history
  225. s.h.Forward()
  226. case ' ': // select
  227. s.switchTo(s.h.Current().feed.Current())
  228. case 'c': // get creator of post
  229. unwrapped := s.h.Current().feed.Current()
  230. if activity, ok := unwrapped.(*pub.Activity); ok {
  231. unwrapped = activity.Target()
  232. }
  233. if post, ok := unwrapped.(*pub.Post); ok {
  234. creators := post.Creators()
  235. s.switchTo(creators)
  236. }
  237. case 'r': // get recipient of post
  238. unwrapped := s.h.Current().feed.Current()
  239. if activity, ok := unwrapped.(*pub.Activity); ok {
  240. unwrapped = activity.Target()
  241. }
  242. if post, ok := unwrapped.(*pub.Post); ok {
  243. recipients := post.Recipients()
  244. s.switchTo(recipients)
  245. }
  246. case 'a': // get actor of activity
  247. if activity, ok := s.h.Current().feed.Current().(*pub.Activity); ok {
  248. actor := activity.Actor()
  249. s.switchTo(actor)
  250. }
  251. case 'o':
  252. unwrapped := s.h.Current().feed.Current()
  253. if activity, ok := unwrapped.(*pub.Activity); ok {
  254. unwrapped = activity.Target()
  255. }
  256. if post, ok := unwrapped.(*pub.Post); ok {
  257. if link, mediaType, present := post.Media(); present {
  258. s.openExternally(link, mediaType)
  259. }
  260. }
  261. case 'p':
  262. if actor, ok := s.h.Current().feed.Current().(*pub.Actor); ok {
  263. if link, mediaType, present := actor.ProfilePic(); present {
  264. s.openExternally(link, mediaType)
  265. }
  266. }
  267. case 'b':
  268. if actor, ok := s.h.Current().feed.Current().(*pub.Actor); ok {
  269. if link, mediaType, present := actor.Banner(); present {
  270. s.openExternally(link, mediaType)
  271. }
  272. }
  273. }
  274. s.output(s.view())
  275. }
  276. func (s *State) switchTo(item any) {
  277. switch narrowed := item.(type) {
  278. case []pub.Tangible:
  279. if len(narrowed) == 0 {
  280. return
  281. }
  282. if len(narrowed) == 1 {
  283. _, frontier := narrowed[0].Parents(0)
  284. s.h.Add(&Page{
  285. feed: feed.Create(narrowed[0]),
  286. children: narrowed[0].Children(),
  287. frontier: frontier,
  288. })
  289. } else {
  290. s.h.Add(&Page{
  291. feed: feed.CreateAndAppend(narrowed),
  292. })
  293. }
  294. case pub.Tangible:
  295. _, frontier := narrowed.Parents(0)
  296. s.h.Add(&Page{
  297. feed: feed.Create(narrowed),
  298. children: narrowed.Children(),
  299. frontier: frontier,
  300. })
  301. case pub.Container:
  302. if s.mode != loading {
  303. s.mode = loading
  304. s.buffer = ""
  305. s.output(s.view())
  306. }
  307. children, nextCollection, newBasepoint := narrowed.Harvest(uint(config.Parsed.Network.Context + 1), 0)
  308. s.h.Add(&Page{
  309. basepoint: newBasepoint,
  310. children: nextCollection,
  311. feed: feed.CreateAndAppend(children),
  312. })
  313. s.mode = normal
  314. s.buffer = ""
  315. default:
  316. panic("can't switch to non-Tangible non-Container")
  317. }
  318. s.loadSurroundings()
  319. }
  320. func (s *State) SetWidthHeight(width int, height int) {
  321. s.m.Lock()
  322. defer s.m.Unlock()
  323. if s.width == width && s.height == height {
  324. return
  325. }
  326. s.width = width
  327. s.height = height
  328. s.output(s.view())
  329. }
  330. func (s *State) loadSurroundings() {
  331. page := s.h.Current()
  332. context := config.Parsed.Network.Context
  333. if !page.loadingUp && !page.feed.Contains(-context) && page.frontier != nil {
  334. page.loadingUp = true
  335. go func() {
  336. parents, newFrontier := page.frontier.Parents(uint(context))
  337. s.m.Lock()
  338. page.feed.Prepend(parents)
  339. page.frontier = newFrontier
  340. page.loadingUp = false
  341. s.output(s.view())
  342. s.m.Unlock()
  343. }()
  344. }
  345. if !page.loadingDown && !page.feed.Contains(context) && page.children != nil {
  346. page.loadingDown = true
  347. go func() {
  348. // TODO: need to do a new renaming, maybe upperFrontier, lowerFrontier
  349. children, nextCollection, newBasepoint := page.children.Harvest(uint(context), page.basepoint)
  350. s.m.Lock()
  351. page.feed.Append(children)
  352. page.children = nextCollection
  353. page.basepoint = newBasepoint
  354. page.loadingDown = false
  355. s.output(s.view())
  356. s.m.Unlock()
  357. }()
  358. }
  359. }
  360. func (s *State) openUserInput(input string) {
  361. s.mode = loading
  362. s.buffer = ""
  363. s.output(s.view())
  364. go func() {
  365. result := pub.FetchUserInput(input)
  366. s.m.Lock()
  367. s.switchTo(result)
  368. s.mode = normal
  369. s.buffer = ""
  370. s.output(s.view())
  371. s.m.Unlock()
  372. }()
  373. }
  374. func (s *State) openInternally(input string) {
  375. s.mode = loading
  376. s.buffer = ""
  377. s.output(s.view())
  378. go func() {
  379. result := pub.New(input, nil)
  380. s.m.Lock()
  381. s.switchTo(result)
  382. s.mode = normal
  383. s.buffer = ""
  384. s.output(s.view())
  385. s.m.Unlock()
  386. }()
  387. }
  388. func (s *State) openFeed(input string) {
  389. inputs, present := config.Parsed.Feeds[input]
  390. if !present {
  391. s.mode = problem
  392. s.buffer = "Failed to open feed: " + input + " is not a known feed"
  393. s.output(s.view())
  394. s.mode = normal
  395. s.buffer = ""
  396. return
  397. }
  398. s.mode = loading
  399. s.buffer = ""
  400. s.output(s.view())
  401. go func() {
  402. result := splicer.NewSplicer(inputs)
  403. s.switchTo(result)
  404. s.mode = normal
  405. s.buffer = ""
  406. s.output(s.view())
  407. }()
  408. }
  409. func NewState(width int, height int, output func(string)) *State {
  410. s := &State{
  411. h: history.History[*Page]{},
  412. width: width,
  413. height: height,
  414. output: output,
  415. m: &sync.Mutex{},
  416. mode: loading,
  417. }
  418. return s
  419. }
  420. func (s *State) Subcommand(name, argument string) error {
  421. s.m.Lock()
  422. if name == "feed" {
  423. if _, present := config.Parsed.Feeds[argument]; !present {
  424. return errors.New("failed to open feed: " + argument + " is not a known feed")
  425. }
  426. }
  427. err := s.subcommand(name, argument)
  428. if err != nil {
  429. /* Here I hold the lock indefinitely intentionally, to stop the ui thread and allow main.go to do cleanup */
  430. return err
  431. }
  432. s.m.Unlock()
  433. return nil
  434. }
  435. func (s *State) subcommand(name, argument string) error {
  436. switch name {
  437. case "open":
  438. s.openUserInput(argument)
  439. case "feed":
  440. s.openFeed(argument)
  441. default:
  442. return fmt.Errorf("unrecognized subcommand: %s", name)
  443. }
  444. return nil
  445. }
  446. func (s *State) openExternally(link string, mediaType *mime.MediaType) {
  447. s.mode = opening
  448. s.buffer = link
  449. s.output(s.view())
  450. command := make([]string, len(config.Parsed.Media.Hook))
  451. copy(command, config.Parsed.Media.Hook)
  452. foundPercentU := false
  453. for i, field := range command {
  454. if i == 0 {
  455. continue
  456. }
  457. switch field {
  458. case "%url":
  459. command[i] = link
  460. foundPercentU = true
  461. case "%mimetype":
  462. command[i] = mediaType.Essence
  463. case "%subtype":
  464. command[i] = mediaType.Subtype
  465. case "%supertype":
  466. command[i] = mediaType.Supertype
  467. }
  468. }
  469. cmd := exec.Command(command[0], command[1:]...)
  470. if !foundPercentU {
  471. cmd.Stdin = strings.NewReader(link)
  472. }
  473. go func() {
  474. outputBytes, err := cmd.CombinedOutput()
  475. output := string(outputBytes)
  476. s.m.Lock()
  477. defer s.m.Unlock()
  478. if s.mode != opening {
  479. return
  480. }
  481. if err != nil {
  482. s.mode = problem
  483. s.buffer = "Failed to open link: " + output
  484. s.output(s.view())
  485. s.mode = normal
  486. s.buffer = ""
  487. return
  488. }
  489. s.mode = normal
  490. s.buffer = ""
  491. s.output(s.view())
  492. }()
  493. }