WIP repository for a ncurses fediverse/mastodon client, using python mastodon.py

fedicurses.py 7.1KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  1. #! /usr/bin/python3
  2. import curses
  3. from html.parser import HTMLParser
  4. from mastodon import Mastodon
  5. def initColours():
  6. # 0: White on black
  7. curses.init_pair(1, 4, 0) # 1: Blue on black
  8. curses.init_pair(2, 1, 0) # 2: Red on black
  9. curses.init_pair(3, 6, 0) # 3: Cyan on black
  10. curses.init_pair(4, 2, 0) # 4: Green on black
  11. def newLine(ncwin, lines=1):
  12. cy, cx = ncwin.getyx()
  13. # my, mx = ncwin.getmaxyx()
  14. try:
  15. ncwin.move(cy + lines, 0)
  16. except curses.error:
  17. pass
  18. def bump(win):
  19. my, mx = win.getmaxyx()
  20. cy, cx = win.getyx()
  21. if cx < mx and cx != 0:
  22. try:
  23. win.move(cy, cx + 1)
  24. except curses.error:
  25. pass
  26. def typeset(text, win, attr, colour):
  27. my, mx = win.getmaxyx()
  28. line = 0
  29. position = 0
  30. strlen = len(text)
  31. while position < (strlen):
  32. cy, cx = win.getyx()
  33. if cx == mx:
  34. if cy == my:
  35. raise curses.error("Out of space")
  36. rem = (mx - cx) + 0
  37. nsp = text.find(' ', position)
  38. if (strlen - position <= rem):
  39. win.addnstr(text[position:], rem,
  40. attr | curses.color_pair(colour))
  41. position = strlen
  42. elif (nsp == -1 or nsp - position > rem): # and not strlen - position <= rem:
  43. if cx == 0:
  44. win.addnstr(text[position:], rem, attr |
  45. curses.color_pair(colour))
  46. position = position + rem
  47. else:
  48. newLine(win)
  49. else:
  50. win.addnstr(text[position:], nsp - position,
  51. attr | curses.color_pair(colour))
  52. position = max(nsp, 0) + 1
  53. bump(win)
  54. def printPost(win, post, parser):
  55. # win.addstr(post["account"]["acct"], curses.A_BOLD)
  56. typeset("@{}".format(post["account"]["acct"]), win, curses.A_BOLD, 0)
  57. if (post["reblog"] is not None):
  58. # win.addstr(" boosted ")
  59. typeset(" boosted ", win, 0, 0)
  60. # win.addstr(post["reblog"]["account"]["acct"], curses.A_BOLD)
  61. typeset("@{}".format(post["reblog"]["account"]["acct"]), win,
  62. curses.A_BOLD, 0)
  63. # win.addstr(":")
  64. typeset(":", win, 0, 0)
  65. newLine(win)
  66. if (post["spoiler_text"] != ""):
  67. # win.addstr(post["spoiler_text"], curses.A_UNDERLINE)
  68. typeset(post["spoiler_text"], win, curses.A_UNDERLINE, 0)
  69. newLine(win)
  70. parser.feed(post["content"])
  71. if parser.openp:
  72. newLine(win)
  73. for att in post["media_attachments"]:
  74. # typeset("[IMG: {}, <{}>]".format(att["alt"], att["src"]))
  75. typeset("[IMG: {}]".format(att["description"] if
  76. att["description"] is not None
  77. else "(Alt text missing)"),
  78. win, 0, 4)
  79. newLine(win)
  80. if (post["reblog"] is not None):
  81. for att in post["reblog"]["media_attachments"]:
  82. # typeset("[IMG: {}, <{}>]".format(att["alt"], att["src"]))
  83. typeset("[IMG: {}]".format(att["description"] if
  84. att["description"] is not None
  85. else "(Alt text missing)"),
  86. win, 0, 4)
  87. newLine(win)
  88. botstring = "{} @ {} UTC".format(post["visibility"],
  89. post["created_at"
  90. ].strftime(
  91. "%Y-%m-%d %H:%M:%S"))
  92. cy, cx = win.getyx()
  93. my, mx = win.getmaxyx()
  94. # win.addstr(cy, mx - len(botstring), botstring, curses.A_UNDERLINE)
  95. win.move(cy, mx - len(botstring))
  96. typeset(botstring, win, curses.A_UNDERLINE, 0)
  97. newLine(win, 1)
  98. def printAtLevel(stdscr, col1, col2, posts, parser, level):
  99. col1.clear()
  100. col2.clear()
  101. col1.move(0, 0)
  102. col2.move(0, 0)
  103. printPost(col1, posts[level], parser)
  104. newLine(col1)
  105. printPost(col1, posts[(level + 1) % len(posts)], parser)
  106. try:
  107. col2.addstr(str(posts[level]["content"]))
  108. col2.addstr(str(posts[level]["media_attachments"]))
  109. col2.addstr(str(posts[level].keys()))
  110. except curses.error:
  111. pass
  112. stdscr.noutrefresh()
  113. col1.noutrefresh()
  114. col2.noutrefresh()
  115. curses.doupdate()
  116. class PostParser(HTMLParser):
  117. def __init__(self, ncwin, defncatt):
  118. HTMLParser.__init__(self)
  119. self.win = ncwin
  120. self.defncatt = defncatt
  121. self.curatt = defncatt
  122. self.openp = False
  123. self.colour = 0
  124. self.colstack = []
  125. def handle_starttag(self, tag, attrs):
  126. if tag == 'p':
  127. self.curatt = self.defncatt
  128. self.colstack = []
  129. self.colour = 0
  130. # newLine(self.win)
  131. elif tag == 'strong' or tag == 'b':
  132. self.curatt = self.curatt ^ curses.A_REVERSE
  133. elif tag == 'em' or tag == 'i':
  134. self.curatt = self.curatt ^ curses.A_BOLD
  135. elif tag == 'br':
  136. newLine(self.win)
  137. elif tag == 'a':
  138. self.colstack.append(self.colour)
  139. self.colour = 1
  140. elif tag == 'code':
  141. self.colstack.append(self.colour)
  142. self.colour = 3
  143. elif tag == 'img':
  144. typeset("[IMG: {}, <{}>]".format(att["alt"], att["src"]), self.win,
  145. 0, 4)
  146. def handle_endtag(self, tag):
  147. if tag == 'p':
  148. newLine(self.win, 2)
  149. self.openp = False
  150. elif tag == 'strong' or tag == 'b':
  151. self.curatt = self.curatt ^ curses.A_REVERSE
  152. elif tag == 'em' or tag == 'i':
  153. self.curatt = self.curatt ^ curses.A_BOLD
  154. elif tag == 'a':
  155. self.colour = self.colstack.pop()
  156. elif tag == 'code':
  157. self.colour = self.colstack.pop()
  158. def handle_data(self, data):
  159. try:
  160. # self.win.addstr(data, self.curatt | curses.color_pair(self.colour))
  161. typeset(data, self.win, self.curatt, self.colour)
  162. except curses.error:
  163. pass
  164. self.openp = True
  165. # print(mastodon.timeline()[0]["content"])
  166. def main(stdscr):
  167. initColours()
  168. mastodon = Mastodon(
  169. access_token = 'd100.club_usercred.secret',
  170. api_base_url = 'd100.club'
  171. )
  172. posts = mastodon.timeline()
  173. stdscr.clear()
  174. # stdscr.addstr("test")
  175. curses.halfdelay(10)
  176. my, mx = stdscr.getmaxyx()
  177. c1w = c2w = (mx - 3) // 2
  178. if (mx % 2) != 0:
  179. c2w = c2w - 1
  180. colw1 = curses.newwin(my - 2, c1w, 1, 1)
  181. colw2 = curses.newwin(my - 2, c2w, 1, 1 + c1w + 2)
  182. stdscr.border()
  183. # colw1.border()
  184. # colw2.border()
  185. parser = PostParser(colw1, 0)
  186. # colw1.move(0,0)
  187. # stdscr.addstr(0, 0, 'test')
  188. # colw2.addstr(0, 0, 'test2')
  189. # colw1.addstr(0, 0, 'test3')
  190. i = 'c'
  191. q = 0
  192. printAtLevel(stdscr, colw1, colw2, posts, parser, q)
  193. con = True
  194. while con:
  195. i = stdscr.getch()
  196. if (i == ord('q')):
  197. con = False
  198. elif (i == ord('j')):
  199. q = (q + 1) % len(posts)
  200. printAtLevel(stdscr, colw1, colw2, posts, parser, q)
  201. elif (i == ord('k')):
  202. q = (q - 1) % len(posts)
  203. printAtLevel(stdscr, colw1, colw2, posts, parser, q)
  204. curses.wrapper(main)