123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466 | #!/usr/bin/env python3
#
# This file contains a simple test program for demonstrating some basic Python
# BTUI usage.
#
import PIL
import btui.Python.btui as btui
import climage
import marko
import os
import re
import subprocess
import time
import webbrowser
from collections import namedtuple
from marko.helpers import MarkoExtension
from marko.inline import *
from pygments import highlight
from pygments.formatters import Terminal256Formatter
from pygments.lexers import get_lexer_by_name
from pygments.util import ClassNotFound
from tomo import TomoLexer, MyStyle
from wcwidth import wcswidth
Slide = namedtuple("Slide", ("filename", "text"))
class Demo(namedtuple("_Demo", ("element", "demo"))):
__slots__ = ()
def __call__(self): self.demo()
FORMATTER = Terminal256Formatter(style=MyStyle)
BULLET = "\033[1m\033(0`\033(B\033[m"
terminal_width, terminal_height = 0, 0
highlighted_element = None
def render_width(text:str)->int:
# Strip out escape sequences that are used:
text = text.replace(BULLET, "*")
text = re.sub("\033\\[[\\d;]*.", "", text)
text = re.sub("\033\\(.", "", text)
text = re.sub("\t", " ", text)
width = wcswidth(text)
assert(width >= 0) # Can happen if we missed some escape characters
return width
def is_image(path:str)->bool:
return any(path.lower().endswith(ext) for ext in ('.png', '.jpg', '.jpeg', '.bmp', '.gif', '.webp'))
def boxed(text:str, line_numbers=False, box_color="", min_width=0):
text = re.sub("\t", " ", text)
lines = text.splitlines()
width = 2 + max(render_width(line) for line in lines) + 2
if line_numbers: width += len(str(len(lines))) + 1
width = max(min_width, width)
rendered = [box_color + "\033(0l" + "q"*(width-2) + "k\033(B" + "\033[m"]
for i,line in enumerate(lines):
pad = width - render_width(line) - 4
if line_numbers:
num_w = len(str(len(lines)))
pad -= num_w+1
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")
else:
rendered.append(f"{box_color}\033(0x\033(B\033[m {line}{' '*pad} {box_color}\033(0x\033(B\033[m")
rendered.append(box_color + "\033(0m" + "q"*(width-2) + "j\033(B" + "\033[m")
return "\n".join(rendered)
class TerminalRenderer(marko.Renderer):
width = 40
relative_filename = "."
def render_document(self, element):
return self.render_children(element)
def render_children(self, element):
if isinstance(element, str):
return element # Avoid treating it as an object
return super().render_children(element)
def render_heading(self, element):
title = " "+self.render_children(element)+" "
if element.level == 1:
#return "\033[1;34m" + pyfiglet.figlet_format(title, width=200, font="big").rstrip('\n') + "\033[m\n\n"
line = "\033[1;7m" + " "*TerminalRenderer.width + "\033[22;27m\n"
return line + f"\033[1;7m{title:^{TerminalRenderer.width}}\033[22;27m\n" + line + "\n"
else:
return f"\033[1;7m{title:^{TerminalRenderer.width}}\033[22;27m\n\n"
_list_depth = 0
def render_list(self, element) -> str:
lines = []
if element.ordered:
for num, child in enumerate(element.children, element.start):
child.bullet = f"\033[1m{num:>2}.\033[m "
child_rendered = self.render(child).strip('\n').replace('\n', '\n ')
lines.append(child_rendered)
else:
for child in element.children:
child.bullet = " "+BULLET+" "
child_rendered = self.render(child).strip('\n').replace('\n', '\n ')
lines.append(child_rendered)
return "\n".join(lines) + "\n\n"
def render_list_item(self, element) -> str:
indent = " "*self._list_depth
self._list_depth += 1
result = indent + element.bullet + "\n".join(self.render(child).strip('\n') for child in element.children)
self._list_depth -= 1
return result
def render_image(self, element) -> str:
path = element.dest if os.path.isabs(element.dest) else os.path.join(os.path.dirname(self.relative_filename), element.dest)
if is_image(path):
img = PIL.Image.open(path)
img_width, img_height = img.size
render_width, render_height = img_width, img_height
two_thirds_width = max(40, 2*terminal_width//3)
if two_thirds_width < render_width:
render_width = two_thirds_width
render_height = int(img_height * (render_width/img_width)/2)
two_thirds_height = max(40, 2*terminal_height//3)
if two_thirds_height < render_height:
render_height = two_thirds_height
render_width = int(img_width * 2*(render_height/img_height))
option_string = markdown.render(element.children[0]) if element.children else ""
for w in re.findall(r'width=(\d+)%', option_string):
render_width = int(terminal_width * float(w)/100)
render_height = int(img_height * (render_width/img_width)/2)
for h in re.findall(r'height=(\d+)%', option_string):
render_height = int(terminal_height * float(h)/100)
render_width = int(img_width * 2*(render_height/img_height))
output = climage.convert(path, width=render_width, is_truecolor=True, is_256color=False, is_unicode=True)
if element is highlighted_element:
output = output.replace("\n", "\033[33m*\033[m\n", count=1)
else:
output = output.replace("\n", " \n", count=1)
return output + "\n"
try:
# Embedded file:
with open(path) as f:
contents = f.read()
except FileNotFoundError:
return f"\n\033[31;1m<File not found: {element.dest}>\033[m\n"
extension = path.rpartition(".")[2] if "." in path else ""
try:
lexer = get_lexer_by_name(extension, stripall=True)
except ClassNotFound:
if extension == "tm":
code = highlight(contents, TomoLexer(), FORMATTER)
else:
code = contents
else:
code = highlight(contents, lexer, FORMATTER)
title = self.render_children(element) or path
heading = f"\033[1;36;7m{title:^{TerminalRenderer.width}}\033[22;27m"
return "\n" + heading + "\n" + boxed(code, line_numbers=True, box_color="\033[36m", min_width=TerminalRenderer.width) + "\n\n"
def render_link(self, element) -> str:
title = self.render_children(element) or element.dest
return f"\033[{'1;' if element is highlighted_element else ''}4;34m{title}\033[m"
def render_emphasis(self, element) -> str:
return f"\033[3m{self.render_children(element)}\033[23m"
def render_strong_emphasis(self, element) -> str:
return f"\033[1m{self.render_children(element)}\033[22m"
def render_strikethrough(self, element) -> str:
return f"\033[9m{self.render_children(element)}\033[29m"
def render_code_span(self, element) -> str:
return f"\033[1;32;48;2;40;50;40m{element.children}\033[22;39;49m"
def render_raw_text(self, element) -> str:
assert(isinstance(element.children, str))
return str(element.children)
def render_literal(self, element) -> str:
return self.render_raw_text(element)
def render_code_block(self, element) -> str:
raw_code = element.children[0].children
if element.lang == "run":
lexer = get_lexer_by_name("bash", stripall=True)
code = highlight(raw_code, lexer, FORMATTER)
output = subprocess.check_output(
["bash", "-c", raw_code.strip()],
stdin=open("/dev/null", "r"),
cwd=os.path.dirname(TerminalRenderer.relative_filename) or '.',
).decode("utf-8").rstrip("\n")
return boxed("\033[33;1m$\033[m " + code + "\n" + output, line_numbers=False, box_color="\033[33m", min_width=TerminalRenderer.width) + "\n\n"
elif element.lang == "demo":
lexer = get_lexer_by_name("bash", stripall=True)
code = highlight(raw_code, lexer, FORMATTER)
title = "Demo (press Enter to run)" if element is highlighted_element else "Demo"
heading = f"\033[1;32;7m{title:^{TerminalRenderer.width}}\033[22;27m"
return heading + "\n" + boxed("\033[33;1m$\033[m " + code, line_numbers=False, box_color="\033[32m", min_width=TerminalRenderer.width) + "\n\n"
code = raw_code
assert(isinstance(code, str))
try:
lexer = get_lexer_by_name(element.lang, stripall=True)
except ClassNotFound:
if element.lang == "tomo":
code = highlight(code, TomoLexer(), FORMATTER)
else:
code = highlight(code, lexer, FORMATTER)
return boxed(code, line_numbers=True, box_color="\033[34m", min_width=TerminalRenderer.width) + "\n\n"
def render_fenced_code(self, element) -> str:
return self.render_code_block(element)
def render_quote(self, element) -> str:
return f"\033[34;3m{self.render_children(element)}\033[39;23m"
def render_paragraph(self, element) -> str:
return f"{self.render_children(element).strip()}\n\n"
def get_demos(element) -> list:
if isinstance(element, (marko.block.CodeBlock, marko.block.FencedCode)):
if element.lang == "demo":
def demo():
raw_code = element.children[0].children
subprocess.run(
["bash", "-c", raw_code.strip()],
cwd=os.path.dirname(TerminalRenderer.relative_filename) or '.',
)
return [Demo(element, demo)]
elif isinstance(element, marko.inline.Link):
def demo():
webbrowser.open(element.dest, new=1)
return [Demo(element, demo)]
elif isinstance(element, marko.inline.Image):
path = element.dest if os.path.isabs(element.dest) else os.path.join(os.path.dirname(TerminalRenderer.relative_filename), element.dest)
if is_image(path):
return [Demo(element, lambda: PIL.Image.open(path).show())]
elif isinstance(element, marko.element.Element):
if hasattr(element, "children"):
demos = []
for child in element.children:
demos += get_demos(child)
return demos
else:
raise ValueError(f"No children! {element}")
elif not isinstance(element, str):
raise ValueError(f"Not an element! {element}")
return []
markdown = marko.Markdown(renderer=TerminalRenderer)
def show_slide(bt:btui.BTUI, slides:[Slide], index:int, *, scroll=0, raw=False, demo_index=0) -> int:
global highlighted_element
slide = slides[index]
with bt.buffered():
bt.clear()
if raw:
lexer = get_lexer_by_name("markdown", stripall=True)
code = highlight(slide.text, lexer, FORMATTER)
for i,line in enumerate(code.splitlines()):
bt.move(0, i)
bt.write(line)
return
if slide.text.strip():
TerminalRenderer.relative_filename = slide.filename
ast = markdown.parse(slide.text)
demos = get_demos(ast)
highlighted_element = demos[demo_index].element if demos else None
TerminalRenderer.width = bt.width//4
rendered = markdown.render(ast)
TerminalRenderer.width = max(render_width(line) for line in rendered.splitlines())
rendered = markdown.render(ast)
lines = rendered.splitlines()
width = max(render_width(line) for line in lines)
height = len(lines)
x = max(0, (bt.width - width)//2)
y = max(0, (bt.height - height)//2) - scroll
for i,line in enumerate(rendered.splitlines()):
if y + i in range(bt.height):
bt.move(x, y + i)
bt.write(line)
else:
width,height = 0,0
pos_str = f"{index+1}/{len(slides)}"
bt.move(bt.width-len(pos_str), bt.height)
with bt.attributes("dim"):
bt.write(pos_str)
return height
def draw_time(bt:btui.BTUI, start_time:float):
elapsed = time.perf_counter() - start_time
bt.move(0, terminal_height-1)
with bt.attributes("dim"):
if elapsed >= 3600:
bt.write(f" {int(elapsed//3600)}:{int((elapsed % 3600)//60):02}:{int(elapsed % 60):02}")
else:
bt.write(f" {int(elapsed//60):2}:{int(elapsed % 60):02}")
def present(slides:[str]):
global terminal_width, terminal_height
redraw = True
index, prev_index = 0, None
raw = False
scroll = 0
render_height = 0
search = ''
start_time = time.perf_counter()
with btui.open() as bt:
terminal_width, terminal_height = bt.width, bt.height
key = None
while key != 'q' and key != 'Ctrl-c':
if index != prev_index:
redraw = True
scroll = 0
ast = markdown.parse(slides[index].text)
demos = get_demos(ast)
demo_index = 0
if redraw:
render_height = show_slide(bt, slides, index, scroll=scroll, raw=raw, demo_index=demo_index)
draw_time(bt, start_time)
redraw = False
prev_index = index
key, mx, my = bt.getkey(10)
draw_time(bt, start_time)
if key is None:
pass
elif key == 'Left' or key == 'k' or key == 'Backspace':
index = max(0, index - 1)
elif key == 'Right' or key == 'Space' or key == 'j':
index = min(len(slides)-1, index + 1)
elif key == "Up" or key == "Mouse wheel up":
redraw = True
scroll = max(0, scroll-1)
elif key == "Ctrl-u":
redraw = True
scroll = max(0, scroll-10)
elif key == "Down" or key == "Mouse wheel down":
scroll = min(scroll+1, max(0, render_height-bt.height-1))
redraw = True
elif key == "Ctrl-d":
scroll = min(scroll+10, max(0, render_height-bt.height-1))
redraw = True
elif key == 'Ctrl-r' or key == 'r':
redraw = True
elif key == 'Home' or key == 'h':
index = 0
elif key == 'End' or key == 'l':
index = len(slides)-1
elif key == 'Ctrl-z':
bt.suspend()
redraw = True
elif key == 'Enter':
if len(demos) > 0:
with bt.disabled():
bt.flush()
# Clear screen and move to top:
print('\033[2J\033[H', flush=True, end="")
demos[demo_index]()
if demo_index + 1 < len(demos):
demo_index += 1
redraw = True
elif key == 'Tab':
if demo_index + 1 < len(demos):
demo_index += 1
redraw = True
elif key == 'Shift-Tab':
if demo_index > 0:
demo_index -= 1
redraw = True
elif key == '`':
raw = not raw
redraw = True
elif key == "Resize":
terminal_width, terminal_height = bt.width, bt.height
redraw = True
elif key in '0123456789':
bt.move(1, bt.height)
with bt.attributes("bold"):
bt.write("Go to slide: ")
index_str = ''
while key in '0123456789':
bt.write(key)
index_str += key
key, mx, my = bt.getkey()
if key == 'Enter':
index = max(0, min(len(slides)-1, int(index_str)-1))
elif key == '/':
bt.move(1, bt.height)
with bt.attributes("bold"):
bt.write("Go to slide: ")
search = ''
key, mx, my = bt.getkey()
while key not in ('Ctrl-c', 'Enter'):
if key == 'Backspace':
if search:
search = search[:-1]
bt.write('\b \b')
else:
search += key
bt.write(key)
key, mx, my = bt.getkey()
if key == 'Enter':
for offset in range(len(slides)):
if search.lower() in slides[(index + 1 + offset) % len(slides)].text.lower():
index = (index + 1 + offset) % len(slides)
break
elif key == 'n': # Next search result
for offset in range(len(slides)):
if search.lower() in slides[(index + 1 + offset) % len(slides)].text.lower():
index = (index + 1 + offset) % len(slides)
break
elif key == 'p': # Previous search result
for offset in range(len(slides)):
if search.lower() in slides[(index - 1 - offset + 2*len(slides)) % len(slides)].text.lower():
index = (index - 1 - offset + 2*len(slides)) % len(slides)
break
if __name__ == "__main__":
import sys
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} file1.slides [file2.slides...]")
sys.exit(1)
slides = []
for filename in sys.argv[1:]:
try:
with open(filename) as f:
text = f.read()
if any(filename.endswith(ext) for ext in (".slides", ".md", ".txt")):
if text.startswith("#!"):
_,text = text.split('\n', maxsplit=1)
slides += [Slide(filename, slide.strip()) for slide in re.split(r'(?m)^\-{3,}$', text)]
else:
extension = filename.rpartition(".")[2] if "." in filename else ""
slides += [Slide(filename, f"```{extension}\n{text.strip()}\n```")]
except FileNotFoundError:
print(f"File not found: {filename}")
sys.exit(1)
present(slides)
|