code / slides

Lines549 Python392 Markdown127 make18 Text12
(461 lines)
1 #!/usr/bin/env python3
2 #
3 # This file contains a simple test program for demonstrating some basic Python
4 # BTUI usage.
5 #
6 import PIL
7 import btui.Python.btui as btui
8 import climage
9 import marko
10 import os
11 import re
12 import subprocess
13 import time
14 import webbrowser
16 from collections import namedtuple
17 from marko.helpers import MarkoExtension
18 from marko.inline import *
19 from pygments import highlight
20 from pygments.formatters import Terminal256Formatter
21 from pygments.lexers import get_lexer_by_name
22 from pygments.util import ClassNotFound
23 from wcwidth import wcswidth
25 Slide = namedtuple("Slide", ("filename", "text"))
26 class Demo(namedtuple("_Demo", ("element", "demo"))):
27 __slots__ = ()
28 def __call__(self): self.demo()
30 FORMATTER = Terminal256Formatter(style="native")
31 BULLET = "\033[1m\033(0`\033(B\033[m"
33 terminal_width, terminal_height = 0, 0
34 highlighted_element = None
36 def render_width(text:str)->int:
37 # Strip out escape sequences that are used:
38 text = text.replace(BULLET, "*")
39 text = re.sub("\033\\[[\\d;]*.", "", text)
40 text = re.sub("\033\\(.", "", text)
41 text = re.sub("\t", " ", text)
43 width = wcswidth(text)
44 assert(width >= 0) # Can happen if we missed some escape characters
45 return width
47 def is_image(path:str)->bool:
48 return any(path.lower().endswith(ext) for ext in ('.png', '.jpg', '.jpeg', '.bmp', '.gif', '.webp'))
50 def boxed(text:str, line_numbers=False, box_color="", min_width=0):
51 text = re.sub("\t", " ", text)
52 lines = text.splitlines()
53 width = 2 + max(render_width(line) for line in lines) + 2
54 if line_numbers: width += len(str(len(lines))) + 1
55 width = max(min_width, width)
57 rendered = [box_color + "\033(0l" + "q"*(width-2) + "k\033(B" + "\033[m"]
58 for i,line in enumerate(lines):
59 pad = width - render_width(line) - 4
60 if line_numbers:
61 num_w = len(str(len(lines)))
62 pad -= num_w+1
63 rendered.append(f"{box_color}\033(0x\033(B\033[0;2m{box_color}{i+1:>{num_w}}\033(0x\033(B\033[m {line}{' '*pad} {box_color}\033(0x\033(B\033[m")
64 else:
65 rendered.append(f"{box_color}\033(0x\033(B\033[m {line}{' '*pad} {box_color}\033(0x\033(B\033[m")
66 rendered.append(box_color + "\033(0m" + "q"*(width-2) + "j\033(B" + "\033[m")
67 return "\n".join(rendered)
69 class TerminalRenderer(marko.Renderer):
70 width = 40
71 relative_filename = "."
73 def render_document(self, element):
74 return self.render_children(element)
76 def render_children(self, element):
77 if isinstance(element, str):
78 return element # Avoid treating it as an object
79 return super().render_children(element)
81 def render_heading(self, element):
82 title = " "+self.render_children(element)+" "
83 if element.level == 1:
84 #return "\033[1;34m" + pyfiglet.figlet_format(title, width=200, font="big").rstrip('\n') + "\033[m\n\n"
85 line = "\033[1;7m" + " "*TerminalRenderer.width + "\033[22;27m\n"
86 return line + f"\033[1;7m{title:^{TerminalRenderer.width}}\033[22;27m\n" + line + "\n"
87 else:
88 return f"\033[1;7m{title:^{TerminalRenderer.width}}\033[22;27m\n\n"
90 _list_depth = 0
91 def render_list(self, element) -> str:
92 lines = []
93 if element.ordered:
94 for num, child in enumerate(element.children, element.start):
95 child.bullet = f"\033[1m{num:>2}.\033[m "
96 child_rendered = self.render(child).strip('\n').replace('\n', '\n ')
97 lines.append(child_rendered)
98 else:
99 for child in element.children:
100 child.bullet = " "+BULLET+" "
101 child_rendered = self.render(child).strip('\n').replace('\n', '\n ')
102 lines.append(child_rendered)
103 return "\n".join(lines) + "\n\n"
105 def render_list_item(self, element) -> str:
106 indent = " "*self._list_depth
107 self._list_depth += 1
108 result = indent + element.bullet + "\n".join(self.render(child).strip('\n') for child in element.children)
109 self._list_depth -= 1
110 return result
112 def render_image(self, element) -> str:
113 path = element.dest if os.path.isabs(element.dest) else os.path.join(os.path.dirname(self.relative_filename), element.dest)
114 if is_image(path):
115 img = PIL.Image.open(path)
116 img_width, img_height = img.size
117 render_width, render_height = img_width, img_height
119 two_thirds_width = max(40, 2*terminal_width//3)
120 if two_thirds_width < render_width:
121 render_width = two_thirds_width
122 render_height = int(img_height * (render_width/img_width)/2)
124 two_thirds_height = max(40, 2*terminal_height//3)
125 if two_thirds_height < render_height:
126 render_height = two_thirds_height
127 render_width = int(img_width * 2*(render_height/img_height))
129 option_string = markdown.render(element.children[0]) if element.children else ""
130 for w in re.findall(r'width=(\d+)%', option_string):
131 render_width = int(terminal_width * float(w)/100)
132 render_height = int(img_height * (render_width/img_width)/2)
134 for h in re.findall(r'height=(\d+)%', option_string):
135 render_height = int(terminal_height * float(h)/100)
136 render_width = int(img_width * 2*(render_height/img_height))
138 output = climage.convert(path, width=render_width, is_truecolor=True, is_256color=False, is_unicode=True)
139 if element is highlighted_element:
140 output = output.replace("\n", "\033[33m*\033[m\n", count=1)
141 else:
142 output = output.replace("\n", " \n", count=1)
143 return output + "\n"
145 try:
146 # Embedded file:
147 with open(path) as f:
148 contents = f.read()
149 except FileNotFoundError:
150 return f"\n\033[31;1m<File not found: {element.dest}>\033[m\n"
152 extension = path.rpartition(".")[2] if "." in path else ""
153 try:
154 lexer = get_lexer_by_name(extension, stripall=True)
155 except ClassNotFound:
156 code = contents
157 else:
158 code = highlight(contents, lexer, FORMATTER)
160 title = self.render_children(element) or path
161 heading = f"\033[1;36;7m{title:^{TerminalRenderer.width}}\033[22;27m"
162 return "\n" + heading + "\n" + boxed(code, line_numbers=True, box_color="\033[36m", min_width=TerminalRenderer.width) + "\n\n"
164 def render_link(self, element) -> str:
165 title = self.render_children(element) or element.dest
166 return f"\033[{'1;' if element is highlighted_element else ''}4;34m{title}\033[m"
168 def render_emphasis(self, element) -> str:
169 return f"\033[3m{self.render_children(element)}\033[23m"
171 def render_strong_emphasis(self, element) -> str:
172 return f"\033[1m{self.render_children(element)}\033[22m"
174 def render_strikethrough(self, element) -> str:
175 return f"\033[9m{self.render_children(element)}\033[29m"
177 def render_code_span(self, element) -> str:
178 return f"\033[1;32;48;2;40;50;40m{element.children}\033[22;39;49m"
180 def render_raw_text(self, element) -> str:
181 assert(isinstance(element.children, str))
182 return str(element.children)
184 def render_literal(self, element) -> str:
185 return self.render_raw_text(element)
187 def render_code_block(self, element) -> str:
188 raw_code = element.children[0].children
189 if element.lang == "run":
190 lexer = get_lexer_by_name("bash", stripall=True)
191 code = highlight(raw_code, lexer, FORMATTER)
192 output = subprocess.check_output(
193 ["bash", "-c", raw_code.strip()],
194 stdin=open("/dev/null", "r"),
195 cwd=os.path.dirname(TerminalRenderer.relative_filename) or '.',
196 ).decode("utf-8").rstrip("\n")
197 return boxed("\033[33;1m$\033[m " + code + "\n" + output, line_numbers=False, box_color="\033[33m", min_width=TerminalRenderer.width) + "\n\n"
198 elif element.lang == "demo":
199 lexer = get_lexer_by_name("bash", stripall=True)
200 code = highlight(raw_code, lexer, FORMATTER)
201 title = "Demo (press Enter to run)" if element is highlighted_element else "Demo"
202 heading = f"\033[1;32;7m{title:^{TerminalRenderer.width}}\033[22;27m"
203 return heading + "\n" + boxed("\033[33;1m$\033[m " + code, line_numbers=False, box_color="\033[32m", min_width=TerminalRenderer.width) + "\n\n"
205 code = raw_code
206 assert(isinstance(code, str))
207 try:
208 lexer = get_lexer_by_name(element.lang, stripall=True)
209 except ClassNotFound:
210 pass
211 else:
212 code = highlight(code, lexer, FORMATTER)
214 return boxed(code, line_numbers=True, box_color="\033[34m", min_width=TerminalRenderer.width) + "\n\n"
216 def render_fenced_code(self, element) -> str:
217 return self.render_code_block(element)
219 def render_quote(self, element) -> str:
220 return f"\033[34;3m{self.render_children(element)}\033[39;23m"
222 def render_paragraph(self, element) -> str:
223 return f"{self.render_children(element).strip()}\n\n"
225 def get_demos(element) -> list:
226 if isinstance(element, (marko.block.CodeBlock, marko.block.FencedCode)):
227 if element.lang == "demo":
228 def demo():
229 raw_code = element.children[0].children
230 subprocess.run(
231 ["bash", "-c", raw_code.strip()],
232 cwd=os.path.dirname(TerminalRenderer.relative_filename) or '.',
234 return [Demo(element, demo)]
235 elif isinstance(element, marko.inline.Link):
236 def demo():
237 webbrowser.open(element.dest, new=1)
238 return [Demo(element, demo)]
239 elif isinstance(element, marko.inline.Image):
240 path = element.dest if os.path.isabs(element.dest) else os.path.join(os.path.dirname(TerminalRenderer.relative_filename), element.dest)
241 if is_image(path):
242 return [Demo(element, lambda: PIL.Image.open(path).show())]
243 elif isinstance(element, marko.element.Element):
244 if hasattr(element, "children"):
245 demos = []
246 for child in element.children:
247 demos += get_demos(child)
248 return demos
249 else:
250 raise ValueError(f"No children! {element}")
251 elif not isinstance(element, str):
252 raise ValueError(f"Not an element! {element}")
253 return []
255 markdown = marko.Markdown(renderer=TerminalRenderer)
257 def show_slide(bt:btui.BTUI, slides:[Slide], index:int, *, scroll=0, raw=False, demo_index=0) -> int:
258 global highlighted_element
259 slide = slides[index]
260 with bt.buffered():
261 bt.clear()
263 if raw:
264 lexer = get_lexer_by_name("markdown", stripall=True)
265 code = highlight(slide.text, lexer, FORMATTER)
266 for i,line in enumerate(code.splitlines()):
267 bt.move(0, i)
268 bt.write(line)
269 return
271 if slide.text.strip():
272 TerminalRenderer.relative_filename = slide.filename
273 ast = markdown.parse(slide.text)
275 demos = get_demos(ast)
276 highlighted_element = demos[demo_index].element if demos else None
278 TerminalRenderer.width = bt.width//4
279 rendered = markdown.render(ast)
280 TerminalRenderer.width = max(render_width(line) for line in rendered.splitlines())
281 rendered = markdown.render(ast)
283 lines = rendered.splitlines()
284 width = max(render_width(line) for line in lines)
285 height = len(lines)
287 x = max(0, (bt.width - width)//2)
288 y = max(0, (bt.height - height)//2) - scroll
289 for i,line in enumerate(rendered.splitlines()):
290 if y + i in range(bt.height):
291 bt.move(x, y + i)
292 bt.write(line)
293 else:
294 width,height = 0,0
296 pos_str = f"{index+1}/{len(slides)}"
297 bt.move(bt.width-len(pos_str), bt.height)
298 with bt.attributes("dim"):
299 bt.write(pos_str)
301 return height
303 def draw_time(bt:btui.BTUI, start_time:float):
304 elapsed = time.perf_counter() - start_time
305 bt.move(0, terminal_height-1)
306 with bt.attributes("dim"):
307 if elapsed >= 3600:
308 bt.write(f" {int(elapsed//3600)}:{int((elapsed % 3600)//60):02}:{int(elapsed % 60):02}")
309 else:
310 bt.write(f" {int(elapsed//60):2}:{int(elapsed % 60):02}")
312 def present(slides:[str]):
313 global terminal_width, terminal_height
314 redraw = True
315 index, prev_index = 0, None
316 raw = False
317 scroll = 0
318 render_height = 0
319 search = ''
320 start_time = time.perf_counter()
321 with btui.open() as bt:
322 terminal_width, terminal_height = bt.width, bt.height
323 key = None
324 while key != 'q' and key != 'Ctrl-c':
325 if index != prev_index:
326 redraw = True
327 scroll = 0
329 ast = markdown.parse(slides[index].text)
330 demos = get_demos(ast)
331 demo_index = 0
333 if redraw:
334 render_height = show_slide(bt, slides, index, scroll=scroll, raw=raw, demo_index=demo_index)
335 draw_time(bt, start_time)
336 redraw = False
337 prev_index = index
339 key, mx, my = bt.getkey(10)
341 draw_time(bt, start_time)
343 if key is None:
344 pass
345 elif key == 'Left' or key == 'k' or key == 'Backspace':
346 index = max(0, index - 1)
347 elif key == 'Right' or key == 'Space' or key == 'j':
348 index = min(len(slides)-1, index + 1)
349 elif key == "Up" or key == "Mouse wheel up":
350 redraw = True
351 scroll = max(0, scroll-1)
352 elif key == "Ctrl-u":
353 redraw = True
354 scroll = max(0, scroll-10)
355 elif key == "Down" or key == "Mouse wheel down":
356 scroll = min(scroll+1, max(0, render_height-bt.height-1))
357 redraw = True
358 elif key == "Ctrl-d":
359 scroll = min(scroll+10, max(0, render_height-bt.height-1))
360 redraw = True
361 elif key == 'Ctrl-r' or key == 'r':
362 redraw = True
363 elif key == 'Home' or key == 'h':
364 index = 0
365 elif key == 'End' or key == 'l':
366 index = len(slides)-1
367 elif key == 'Ctrl-z':
368 bt.suspend()
369 redraw = True
370 elif key == 'Enter':
371 if len(demos) > 0:
372 with bt.disabled():
373 bt.flush()
374 # Clear screen and move to top:
375 print('\033[2J\033[H', flush=True, end="")
376 demos[demo_index]()
377 if demo_index + 1 < len(demos):
378 demo_index += 1
379 redraw = True
380 elif key == 'Tab':
381 if demo_index + 1 < len(demos):
382 demo_index += 1
383 redraw = True
384 elif key == 'Shift-Tab':
385 if demo_index > 0:
386 demo_index -= 1
387 redraw = True
388 elif key == '`':
389 raw = not raw
390 redraw = True
391 elif key == "Resize":
392 terminal_width, terminal_height = bt.width, bt.height
393 redraw = True
394 elif key in '0123456789':
395 bt.move(1, bt.height)
396 with bt.attributes("bold"):
397 bt.write("Go to slide: ")
398 index_str = ''
399 while key in '0123456789':
400 bt.write(key)
401 index_str += key
402 key, mx, my = bt.getkey()
404 if key == 'Enter':
405 index = max(0, min(len(slides)-1, int(index_str)-1))
406 elif key == '/':
407 bt.move(1, bt.height)
408 with bt.attributes("bold"):
409 bt.write("Go to slide: ")
411 search = ''
412 key, mx, my = bt.getkey()
413 while key not in ('Ctrl-c', 'Enter'):
414 if key == 'Backspace':
415 if search:
416 search = search[:-1]
417 bt.write('\b \b')
418 else:
419 search += key
420 bt.write(key)
421 key, mx, my = bt.getkey()
423 if key == 'Enter':
424 for offset in range(len(slides)):
425 if search.lower() in slides[(index + 1 + offset) % len(slides)].text.lower():
426 index = (index + 1 + offset) % len(slides)
427 break
428 elif key == 'n': # Next search result
429 for offset in range(len(slides)):
430 if search.lower() in slides[(index + 1 + offset) % len(slides)].text.lower():
431 index = (index + 1 + offset) % len(slides)
432 break
433 elif key == 'p': # Previous search result
434 for offset in range(len(slides)):
435 if search.lower() in slides[(index - 1 - offset + 2*len(slides)) % len(slides)].text.lower():
436 index = (index - 1 - offset + 2*len(slides)) % len(slides)
437 break
439 if __name__ == "__main__":
440 import sys
441 if len(sys.argv) < 2:
442 print(f"Usage: {sys.argv[0]} file1.slides [file2.slides...]")
443 sys.exit(1)
446 slides = []
447 for filename in sys.argv[1:]:
448 try:
449 with open(filename) as f:
450 text = f.read()
451 if any(filename.endswith(ext) for ext in (".slides", ".md", ".txt")):
452 if text.startswith("#!"):
453 _,text = text.split('\n', maxsplit=1)
454 slides += [Slide(filename, slide.strip()) for slide in re.split(r'(?m)^\-{3,}$', text)]
455 else:
456 extension = filename.rpartition(".")[2] if "." in filename else ""
457 slides += [Slide(filename, f"```{extension}\n{text.strip()}\n```")]
458 except FileNotFoundError:
459 print(f"File not found: {filename}")
460 sys.exit(1)
461 present(slides)