help-browser.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508
  1. #!/usr/bin/python3
  2. """
  3. Help Documentation Browser
  4. """
  5. import os
  6. import sys
  7. import subprocess
  8. import cairo
  9. import yutani
  10. import toaru_fonts
  11. import text_region
  12. from menu_bar import MenuBarWidget, MenuEntryAction, MenuEntrySubmenu, MenuEntryDivider, MenuWindow
  13. from about_applet import AboutAppletWindow
  14. from dialog import DialogWindow
  15. import yutani_mainloop
  16. app_name = "Help Browser"
  17. version = "1.0.0"
  18. _description = f"<b>{app_name} {version}</b>\n© 2017 Kevin Lange\n\nRich text help document viewer.\n\n<color 0x0000FF>http://github.com/klange/toaruos</color>"
  19. class ScrollableText(object):
  20. def __init__(self):
  21. self.tr = None
  22. self.width = 0
  23. self.height_ext = 0
  24. self.height_int = 0
  25. self.text_buffer = None
  26. self.background = (1,1,1)
  27. self.pad = 10
  28. def destroy(self):
  29. if self.text_buffer:
  30. self.text_buffer.destroy()
  31. def update(self, width):
  32. needs_resize = False
  33. if width != self.width:
  34. needs_resize = True
  35. self.width = width
  36. self.tr.resize(self.width-self.pad*2, self.tr.line_height)
  37. h = self.tr.line_height * len(self.tr.lines) + self.pad*2
  38. if h != self.height_int:
  39. needs_resize = True
  40. self.height_int = h
  41. if self.height_int - self.pad * 2 > 30000:
  42. # Shit...
  43. self.height_int = 30000 - self.pad * 2
  44. self.tr.resize(self.width-self.pad*2, self.height_int-self.pad*2)
  45. self.tr.move(self.pad,self.pad)
  46. if needs_resize or not self.text_buffer:
  47. if self.text_buffer:
  48. self.text_buffer.destroy()
  49. self.text_buffer = yutani.GraphicsBuffer(self.width,self.height_int)
  50. surface = self.text_buffer.get_cairo_surface()
  51. ctx = cairo.Context(surface)
  52. ctx.rectangle(0,0,surface.get_width(),surface.get_height())
  53. ctx.set_source_rgb(*self.background)
  54. ctx.fill()
  55. self.tr.draw(self.text_buffer)
  56. def scroll_max(self):
  57. if self.height_ext > self.height_int:
  58. return 0
  59. return self.height_int - self.height_ext
  60. def draw(self,ctx,x,y,height,scroll):
  61. self.height_ext = height
  62. surface = self.text_buffer.get_cairo_surface()
  63. ctx.rectangle(x,y,self.width,height)
  64. ctx.set_source_surface(surface,x,y-scroll)
  65. ctx.fill()
  66. class HelpBrowserWindow(yutani.Window):
  67. base_width = 800
  68. base_height = 600
  69. def __init__(self, decorator):
  70. super(HelpBrowserWindow, self).__init__(self.base_width + decorator.width(), self.base_height + decorator.height(), title=app_name, icon="help", doublebuffer=True)
  71. self.move(100,100)
  72. self.decorator = decorator
  73. self.current_topic = "0_index.trt"
  74. self.text_buffer = None
  75. self.text_offset = 0
  76. self.tr = None
  77. self.size_changed = True
  78. self.text_scroller = ScrollableText()
  79. self.special = {}
  80. self.special['contents'] = self.special_contents
  81. self.special['demo'] = self.special_demo
  82. self.down_text = None
  83. self.cache = {}
  84. self.history = []
  85. self.history_index = 0
  86. self.title_cache = {}
  87. def herp(action):
  88. print(action)
  89. self.history_menu = MenuEntrySubmenu('History...',[MenuEntryDivider()])
  90. def exit_app(action):
  91. menus = [x for x in self.menus.values()]
  92. for x in menus:
  93. x.definitely_close()
  94. self.close()
  95. sys.exit(0)
  96. def about_window(action):
  97. AboutAppletWindow(self.decorator,f"About {app_name}","/usr/share/icons/48/help.png",_description,"help")
  98. menus = [
  99. ("File", [
  100. #MenuEntryAction("Open...",None,print_derp,None),
  101. #MenuEntryDivider(),
  102. MenuEntryAction("Exit","exit",exit_app,None),
  103. ]),
  104. ("Go", [
  105. MenuEntryAction("Home","home",self.go_page,"0_index.trt"),
  106. MenuEntryAction("Topics","bookmark",self.go_page,"special:contents"),
  107. MenuEntryDivider(),
  108. self.history_menu,
  109. MenuEntryAction("Back","back",self.go_back,None),
  110. MenuEntryAction("Forward","forward",self.go_forward,None),
  111. ]),
  112. ("Help", [
  113. MenuEntryAction("Contents","help",self.go_page,"help_browser.trt"),
  114. MenuEntryDivider(),
  115. MenuEntryAction(f"About {app_name}","star",about_window,None),
  116. ]),
  117. ]
  118. self.menubar = MenuBarWidget(self,menus)
  119. self.menus = {}
  120. self.hovered_menu = None
  121. self.update_text_buffer()
  122. self.navigate("0_index.trt")
  123. def get_title(self, document):
  124. if document.startswith("special:"):
  125. if document[8:] in self.special:
  126. return self.special[document[8:]].__doc__
  127. return "???"
  128. elif document.startswith("http:") or document.startswith('https:'):
  129. if document in self.title_cache:
  130. return self.title_cache[document]
  131. if document in self.cache:
  132. lines = self.cache[document].split('\n')
  133. for x in lines:
  134. x = x.strip()
  135. if x.startswith('<h1>') and x.endswith('</h1>'):
  136. return x[4:-5]
  137. return document.split('/')[-1].replace('.trt','').title()
  138. else:
  139. return document
  140. elif document.startswith("file:"):
  141. path = document.replace("file:","")
  142. else:
  143. path = f'/usr/share/help/{document}'
  144. if not os.path.exists(path):
  145. return "(file not found)"
  146. with open(path,'r') as f:
  147. lines = f.readlines()
  148. for x in lines:
  149. x = x.strip()
  150. if x.startswith('<h1>') and x.endswith('</h1>'):
  151. return x[4:-5]
  152. return document.replace('.trt','').title()
  153. def special_contents(self):
  154. """Table of Contents"""
  155. # List all things.
  156. output = "\n<h1>Table of Contents</h1>\n\nThis table of contents is automatically generated.\n\n"
  157. output += "<h2>Special Pages</h2>\n\n"
  158. for k in self.special:
  159. output += f"➤ <link target=\"special:{k}\">{self.special[k].__doc__}</link>\n"
  160. output += "\n<h2>Documentation</h2>\n\n"
  161. for k in sorted(os.listdir('/usr/share/help')):
  162. if k.endswith('.trt'):
  163. output += f"➤ <link target=\"{k}\">{self.get_title(k)}</link>\n"
  164. for directory,_,files in os.walk('/usr/share/help'):
  165. if directory == '/usr/share/help':
  166. continue
  167. files = sorted([x for x in files if not x.startswith('.')])
  168. if files:
  169. d = directory.replace('/usr/share/help/','')
  170. output += "\n<h3>" + d.title() + "</h3>\n\n"
  171. for k in files:
  172. if k.endswith('.trt'):
  173. k = d + '/' + k
  174. output += f"➤ <link target=\"{k}\">{self.get_title(k)}</link>\n"
  175. return output
  176. def special_demo(self):
  177. """Formatting demo"""
  178. return f"""
  179. <h1>This is a big header</h1>
  180. This is text below that.
  181. <h2>This is a medium header</h2>
  182. <h3>This is a small header</h3>
  183. This is normal text. <b>This is bold text.</b> <i>This is italic text.</i> <b><i>This is both.</i></b>
  184. <link target=\"0_index.trt\">go home</link>"""
  185. def get_cache(self, url):
  186. if url in self.cache:
  187. return self.cache[url]
  188. else:
  189. try:
  190. text = subprocess.check_output(['fetch',url])
  191. if text.startswith(b'\x89PNG'):
  192. text = f"<html><body><img src=\"{url}\"></body></html>"
  193. else:
  194. text = text.decode('utf-8')
  195. except:
  196. text = '\n<h1>Error</h1>\n\nThere was an error obtaining this file.'
  197. self.cache[url] = text
  198. return text
  199. def get_document_text(self):
  200. if self.current_topic.startswith("special:"):
  201. if self.current_topic[8:] in self.special:
  202. return self.special[self.current_topic[8:]]()
  203. elif self.current_topic.startswith("http:") or self.current_topic.startswith('https:'):
  204. # Good luck
  205. return self.get_cache(self.current_topic)
  206. elif self.current_topic.startswith("file:"):
  207. path = self.current_topic.replace("file:","")
  208. else:
  209. path = f'/usr/share/help/{self.current_topic}'
  210. if os.path.exists(path):
  211. with open(path,'r') as f:
  212. return f.read()
  213. return f"""
  214. <h1>Document Not Found</h1>
  215. Uh oh, looks like the help document you tried to open ({self.current_topic}) wasn't available. Do you want to <link target=\"0_index.trt\">return to the index</link>?
  216. You can also <link target=\"special:contents\">check the Table of Contents</link>.
  217. """
  218. def is_html(self):
  219. if self.current_topic.endswith('.html') or self.current_topic.endswith('.htm'): return True
  220. if self.current_topic.startswith('http') and not self.current_topic.endswith('.trt'): return True
  221. if '<html' in self.get_document_text(): return True
  222. return False
  223. def update_history(self):
  224. def go_history(action):
  225. self.navigate(self.history[action],touch_history=False)
  226. self.history_index = action
  227. self.update_history()
  228. entries = []
  229. for x in range(len(self.history)):
  230. t = self.get_title(self.history[x])
  231. e = MenuEntryAction(t,None,go_history,x)
  232. if x == self.history_index:
  233. e.title = f'<b>{t}</b>'
  234. e.rich = True
  235. e.update_text()
  236. entries.append(e)
  237. entries.reverse()
  238. self.history_menu.entries = entries
  239. def navigate(self, target, touch_history=True):
  240. #if target.startswith('https:'):
  241. # DialogWindow(self.decorator,app_name,f"<mono>https</mono> is not supported. Could not load the URL <mono>{target}</mono>",callback=lambda: None,window=self,cancel_label=False)
  242. # return
  243. if touch_history:
  244. del self.history[self.history_index+1:]
  245. self.history.append(target)
  246. self.history_index = len(self.history)-1
  247. self.current_topic = target
  248. self.text_offset = 0
  249. if self.is_html():
  250. self.tr.base_dir = os.path.dirname(target) + '/'
  251. else:
  252. self.tr.base_dir = '/usr/share/help/'
  253. self.tr.set_richtext(self.get_document_text(),html=self.is_html())
  254. self.update_text_buffer()
  255. if self.tr.title:
  256. self.set_title(f"{self.tr.title} - {app_name}","help")
  257. self.title_cache[target] = self.tr.title
  258. else:
  259. self.set_title(f"{self.get_title(self.current_topic)} - {app_name}","help")
  260. self.update_history()
  261. def update_text_buffer(self):
  262. if not self.tr:
  263. self.tr = text_region.TextRegion(0,0,100,100)
  264. self.tr.set_line_height(18)
  265. self.tr.base_dir = '/usr/share/help/'
  266. self.tr.set_richtext(self.get_document_text(),html=self.is_html())
  267. self.text_scroller.tr = self.tr
  268. if self.size_changed:
  269. self.text_scroller.update(self.width - self.decorator.width())
  270. #self.tr.scroll = self.scroll_offset
  271. #self.tr.draw(self.text_buffer)
  272. def draw(self):
  273. surface = self.get_cairo_surface()
  274. WIDTH, HEIGHT = self.width - self.decorator.width(), self.height - self.decorator.height()
  275. ctx = cairo.Context(surface)
  276. ctx.translate(self.decorator.left_width(), self.decorator.top_height())
  277. ctx.rectangle(0,0,WIDTH,HEIGHT)
  278. #ctx.set_source_rgb(204/255,204/255,204/255)
  279. ctx.set_source_rgb(1,1,1)
  280. ctx.fill()
  281. ctx.save()
  282. ctx.translate(0,self.menubar.height)
  283. """
  284. text = self.text_buffer.get_cairo_surface()
  285. ctx.set_source_surface(text,0,-self.text_offset)
  286. ctx.paint()
  287. """
  288. self.text_scroller.draw(ctx,0,0,HEIGHT-self.menubar.height,self.text_offset)
  289. ctx.restore()
  290. self.menubar.draw(ctx,0,0,WIDTH)
  291. self.decorator.render(self)
  292. self.flip()
  293. def finish_resize(self, msg):
  294. """Accept a resize."""
  295. if msg.width < 100 or msg.height < 100:
  296. self.resize_offer(max(msg.width,100),max(msg.height,100))
  297. return
  298. self.resize_accept(msg.width, msg.height)
  299. self.reinit()
  300. self.size_changed = True
  301. self.update_text_buffer()
  302. self.draw()
  303. self.resize_done()
  304. self.flip()
  305. def scroll(self, amount):
  306. self.text_offset += amount
  307. if self.text_offset < 0:
  308. self.text_offset = 0
  309. if self.text_offset > self.text_scroller.scroll_max():
  310. self.text_offset = self.text_scroller.scroll_max()
  311. def text_under_cursor(self, msg):
  312. """Get the text unit under the cursor."""
  313. x = msg.new_x - self.decorator.left_width()
  314. y = msg.new_y - self.decorator.top_height() + self.text_offset - self.menubar.height
  315. return self.tr.click(x,y)
  316. def go_page(self, action):
  317. """Navigate to a page."""
  318. self.navigate(action)
  319. self.draw()
  320. def go_back(self,action):
  321. """Go back."""
  322. if self.history and self.history_index > 0:
  323. self.history_index -= 1
  324. self.navigate(self.history[self.history_index], touch_history=False)
  325. self.update_history()
  326. self.draw()
  327. def go_forward(self,action):
  328. """Go forward."""
  329. if self.history and self.history_index < len(self.history)-1:
  330. self.history_index += 1
  331. self.navigate(self.history[self.history_index], touch_history=False)
  332. self.update_history()
  333. self.draw()
  334. def mouse_event(self, msg):
  335. if self.mouse_check(msg):
  336. self.draw()
  337. def mouse_check(self, msg):
  338. if d.handle_event(msg) == yutani.Decor.EVENT_CLOSE:
  339. window.close()
  340. sys.exit(0)
  341. x,y = msg.new_x - self.decorator.left_width(), msg.new_y - self.decorator.top_height()
  342. w,h = self.width - self.decorator.width(), self.height - self.decorator.height()
  343. if x >= 0 and x < w and y >= 0 and y < self.menubar.height:
  344. self.menubar.mouse_event(msg, x, y)
  345. if x >= 0 and x < w and y >= self.menubar.height and y < h:
  346. if msg.buttons & yutani.MouseButton.BUTTON_RIGHT:
  347. if not self.menus:
  348. menu_entries = [
  349. MenuEntryAction("Back","back",self.go_back,None),
  350. MenuEntryAction("Forward","forward",self.go_forward,None),
  351. ]
  352. menu = MenuWindow(menu_entries,(self.x+msg.new_x,self.y+msg.new_y),root=self)
  353. if msg.buttons & yutani.MouseButton.SCROLL_UP:
  354. self.scroll(-30)
  355. return True
  356. elif msg.buttons & yutani.MouseButton.SCROLL_DOWN:
  357. self.scroll(30)
  358. return True
  359. if msg.command == yutani.MouseEvent.DOWN:
  360. e = self.text_under_cursor(msg)
  361. r = False
  362. if self.down_text and e != self.down_text:
  363. for u in self.down_text.tag_group:
  364. if u.unit_type == 4:
  365. u.set_extra('hilight',False)
  366. else:
  367. u.set_font(self.down_font[u])
  368. del self.down_font
  369. self.down_text = None
  370. self.update_text_buffer()
  371. r = True
  372. if e and 'link' in e.extra and e.tag_group:
  373. self.down_font = {}
  374. for u in e.tag_group:
  375. if u.unit_type == 4:
  376. u.set_extra('hilight',True)
  377. else:
  378. new_font = toaru_fonts.Font(u.font.font_number,u.font.font_size,0xFFFF0000)
  379. self.down_font[u] = u.font
  380. u.set_font(new_font)
  381. self.update_text_buffer()
  382. r = True
  383. self.down_text = e
  384. else:
  385. self.down_text = None
  386. return r
  387. if msg.command == yutani.MouseEvent.CLICK or msg.command == yutani.MouseEvent.RAISE:
  388. e = self.text_under_cursor(msg)
  389. if self.down_text and e == self.down_text:
  390. self.navigate(e.extra['link'])
  391. return True
  392. elif self.down_text:
  393. for u in self.down_text.tag_group:
  394. if u.unit_type == 4:
  395. u.set_extra('hilight',False)
  396. else:
  397. u.set_font(self.down_font[u])
  398. del self.down_font
  399. self.down_text = None
  400. self.update_text_buffer()
  401. return True
  402. return False
  403. def keyboard_event(self, msg):
  404. if self.keyboard_check(msg):
  405. self.draw()
  406. def keyboard_check(self,msg):
  407. if msg.event.action != 0x01:
  408. return False # Ignore anything that isn't a key down.
  409. if msg.event.keycode == yutani.Keycode.HOME:
  410. self.text_offset = 0
  411. return True
  412. elif msg.event.keycode == yutani.Keycode.END:
  413. n = (len(self.tr.lines)-self.tr.visible_lines())+5
  414. self.text_offset = self.text_scroller.scroll_max()
  415. return True
  416. elif msg.event.keycode == yutani.Keycode.PAGE_UP:
  417. self.scroll(int(-self.height/2))
  418. return True
  419. elif msg.event.keycode == yutani.Keycode.PAGE_DOWN:
  420. self.scroll(int(self.height/2))
  421. return True
  422. elif msg.event.key == b"q":
  423. self.close()
  424. sys.exit(0)
  425. if __name__ == '__main__':
  426. yutani.Yutani()
  427. d = yutani.Decor()
  428. window = HelpBrowserWindow(d)
  429. if len(sys.argv) > 1:
  430. window.navigate(sys.argv[-1])
  431. window.draw()
  432. yutani_mainloop.mainloop()