3 # This file contains a simple test program for demonstrating some basic Python
7 import btui.Python.btui as btui
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"))):
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
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
61 num_w = len(str(len(lines)))
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")
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):
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"
88 return f"\033[1;7m{title:^{TerminalRenderer.width}}\033[22;27m\n\n"
91 def render_list(self, element) -> str:
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)
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
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)
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)
142 output = output.replace("\n", " \n", count=1)
147 with open(path) as f:
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 ""
154 lexer = get_lexer_by_name(extension, stripall=True)
155 except ClassNotFound:
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"
206 assert(isinstance(code, str))
208 lexer = get_lexer_by_name(element.lang, stripall=True)
209 except ClassNotFound:
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":
229 raw_code = element.children[0].children
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):
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)
242 return [Demo(element, lambda: PIL.Image.open(path).show())]
243 elif isinstance(element, marko.element.Element):
244 if hasattr(element, "children"):
246 for child in element.children:
247 demos += get_demos(child)
250 raise ValueError(f"No children! {element}")
251 elif not isinstance(element, str):
252 raise ValueError(f"Not an element! {element}")
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]
264 lexer = get_lexer_by_name("markdown", stripall=True)
265 code = highlight(slide.text, lexer, FORMATTER)
266 for i,line in enumerate(code.splitlines()):
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)
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):
296 pos_str = f"{index+1}/{len(slides)}"
297 bt.move(bt.width-len(pos_str), bt.height)
298 with bt.attributes("dim"):
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"):
308 bt.write(f" {int(elapsed//3600)}:{int((elapsed % 3600)//60):02}:{int(elapsed % 60):02}")
310 bt.write(f" {int(elapsed//60):2}:{int(elapsed % 60):02}")
312 def present(slides:[str]):
313 global terminal_width, terminal_height
315 index, prev_index = 0, None
320 start_time = time.perf_counter()
321 with btui.open() as bt:
322 terminal_width, terminal_height = bt.width, bt.height
324 while key != 'q' and key != 'Ctrl-c':
325 if index != prev_index:
329 ast = markdown.parse(slides[index].text)
330 demos = get_demos(ast)
334 render_height = show_slide(bt, slides, index, scroll=scroll, raw=raw, demo_index=demo_index)
335 draw_time(bt, start_time)
339 key, mx, my = bt.getkey(10)
341 draw_time(bt, start_time)
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":
351 scroll = max(0, scroll-1)
352 elif key == "Ctrl-u":
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))
358 elif key == "Ctrl-d":
359 scroll = min(scroll+10, max(0, render_height-bt.height-1))
361 elif key == 'Ctrl-r' or key == 'r':
363 elif key == 'Home' or key == 'h':
365 elif key == 'End' or key == 'l':
366 index = len(slides)-1
367 elif key == 'Ctrl-z':
374 # Clear screen and move to top:
375 print('\033[2J\033[H', flush=True, end="")
377 if demo_index + 1 < len(demos):
381 if demo_index + 1 < len(demos):
384 elif key == 'Shift-Tab':
391 elif key == "Resize":
392 terminal_width, terminal_height = bt.width, bt.height
394 elif key in '0123456789':
395 bt.move(1, bt.height)
396 with bt.attributes("bold"):
397 bt.write("Go to slide: ")
399 while key in '0123456789':
402 key, mx, my = bt.getkey()
405 index = max(0, min(len(slides)-1, int(index_str)-1))
407 bt.move(1, bt.height)
408 with bt.attributes("bold"):
409 bt.write("Go to slide: ")
412 key, mx, my = bt.getkey()
413 while key not in ('Ctrl-c', 'Enter'):
414 if key == 'Backspace':
421 key, mx, my = bt.getkey()
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)
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)
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)
439 if __name__ == "__main__":
441 if len(sys.argv) < 2:
442 print(f"Usage: {sys.argv[0]} file1.slides [file2.slides...]")
447 for filename in sys.argv[1:]:
449 with open(filename) as f:
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)]
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}")