RLiterate is a graphical tool for doing literate programming. Literate programming is a style of programming in which you don't write source code directly. Instead you write a document which has code snippets interspersed with regular text. It shifts the mindset of the programmer from writing code to writing a document in which the program is presented in small pieces.
This book describes RLiterate, its history, and also includes the complete implementation of the second version.
TODO: Show an example how literate programming is done in RLiterate.
TODO: Explain concepts and features in greater detail.
To me, the central idea in literate programming is that you should write programs for other humans. Only secondary for the machine. I find code easier to understand if I can understand it in isolated pieces. One example where it is difficult to isolate a piece without literate programming is test code. Usually the test code is located in one file, and the implementation in another. To fully understand the piece it is useful to both read the tests and the implementation. To do this you have to find this related information in two unrelated files. I wrote about this problem in 2013 in Related things are not kept together. Literate programming allows you to present the test and the implementation in the same place, yet have the different pieces written to different files. The compiler might require that they are in separate files, but with literate programming, you care first about the other human that will read our code, and only second about the compiler.
Another argument for literate programming is to express the "why". Why is this code here? Timothy Daly talks about it in his talk Literate Programming in the Large. He also argues that programmers must change the mindset from wring a program to writing a book. Some more can be read in Example of Literate Programming in HTML.
Some more resources about literate programming:
This chapter contains the complete implementation of the second version of RLiterate.
RLiterate is written in a single Python file that looks like this:
#!/usr/bin/env python3 from collections import namedtuple, defaultdict, OrderedDict from operator import add, sub, mul, floordiv import base64 import contextlib import cProfile import io import json import math import os import pstats import sys import textwrap import time import uuid from pygments.token import Token as TokenType from pygments.token import string_to_tokentype import pygments.lexers import pygments.token import wx <<globals>> <<decorators>> <<functions>> <<base base classes>> <<base classes>> <<classes>> if __name__ == "__main__": main()
There is also a corresponding test file:
from unittest.mock import Mock, call import rliterate2
The remainder of this chapter will fill in the sections in those files to create the complete implementation.
TODO: Introduce GUI framework, document model, and main frame.
The architecture of the GUI language is to have a top level widget and a function that generates props for it like this:
frame MainFrame %layout_rows { ... }
def main_frame_props(...): return { ... }
Let's say that the main frame has two sub components. It would probably look like this:
frame MainFrame %layout_rows { Foo { #foo %align[EXPAND] } Bar { #bar %align[EXPAND] } }
def main_frame_props(...): return { "foo": foo_props(...), "bar": bar_props(...), } def foo_props(...): return { ... } def bar_props(...): return { ... }
Now let's say that there is a common prop in foo and bar called baz. Should foo_props
and bar_props
change to the following?
def foo_props(...): return { "baz": ..., ... } def bar_props(...): return { "baz": ..., ... }
Or should the common prop be promoted to a main frame prop and passed in the GUI code like this?
frame MainFrame %layout_rows { Foo { #foo baz = #baz %align[EXPAND] } Bar { #bar baz = #baz %align[EXPAND] } }
def main_frame_props(...): return { "foo": foo_props(...), "bar": bar_props(...), "baz": ..., } def foo_props(...): return { ... } def bar_props(...): return { ... }
Both ways are supported by the GUI language.
From a performance perspective, it probably doesn't matter too much. If the common prop is used deep in the widget hierarchy, it needs to be passed at each level if it's passed in the GUI code. If it's inserted in the prop function it can be inserted just where it needs to be.
The pattern with one prop function per widget is quite straight forward. If that is the way to go, perhaps the GUI language can be restricted to only allow that. Not sure if that is a good idea. But I like this pattern, and I will try to follow it.
The problem I encountered with this pattern was that when the column width changed, props for code had to be generated again and code had to be highlighted again. That shouldn't be necessary. But because caching only happened at the paragraph level, and the column width is part of the arguments to paragraph prop, it resulted in a cache miss. The solution is probably to cache deeper down as well.
def main(): args = parse_args() start_app( MainFrame, create_props( main_frame_props, Document(args["path"]) ) )
def parse_args(): args = { "path": None, } script = sys.argv[0] rest = sys.argv[1:] if len(rest) != 1: usage(script) args["path"] = rest[0] return args
def usage(script): sys.exit(f"usage: {script} <path>")
For reference: start_app, create_props.
frame MainFrame %layout_rows { Toolbar { #toolbar %align[EXPAND] } ToolbarDivider { #toolbar_divider %align[EXPAND] } MainArea { #main_area %align[EXPAND] %proportion[1] } }
def main_frame_props(document): return { "title": format_title( document.get(["path"]) ), "toolbar": toolbar_props( document ), "toolbar_divider": toolbar_divider_props( document ), "main_area": main_area_props( document ), }
@cache() def format_title(path): return "{} ({}) - RLiterate 2".format( os.path.basename(path), os.path.abspath(os.path.dirname(path)) )
panel Toolbar %layout_columns { %space[#margin] ToolbarButton { icon = "quit" %margin[#margin,TOP|BOTTOM] } ToolbarButton { icon = "settings" @button = #actions.rotate_theme() %margin[#margin,TOP|BOTTOM] } %space[#margin] if (#text_fragment_selection) { Panel { #bar %align[EXPAND] %margin[#margin,TOP|BOTTOM] } %space[#margin] ToolbarButton { icon = "bold" @button = self._add_text() %margin[#margin,TOP|BOTTOM] } %space[#margin] } } <<Toolbar>>
def _add_text(self): selection = self.prop(["selection"]) self.prop(["actions", "modify_paragraph"])( selection.value["paragraph_id"], selection.value["path"], lambda fragments: [{"type": "text", "text": "Hej!"}]+fragments, selection.update_value(dict(selection.value, **{ "start": [0, 0], "end": [0, 3], "cursor_at_start": False, })) )
def toolbar_props(document): toolbar_theme = document.get(["theme", "toolbar"]) selection = document.get(["selection"]) if (selection.value and selection.value.get("what", None) == "text_fragments"): text_fragment_selection = True else: text_fragment_selection = False return { "background": toolbar_theme["background"], "margin": toolbar_theme["margin"], "bar": { "background": document.get(["theme", "toolbar_divider", "color"]), "size": (1, -1), }, "text_fragment_selection": text_fragment_selection, "selection": selection, "actions": document.actions, }
panel ToolbarDivider %layout_rows { }
def toolbar_divider_props(document): toolbar_divider_theme = document.get(["theme", "toolbar_divider"]) return { "background": toolbar_divider_theme["color"], "min_size": ( -1, toolbar_divider_theme["thickness"] ), }
panel MainArea %layout_columns { TableOfContents[toc] { #toc %align[EXPAND] } TableOfContentsDivider { #toc_divider @drag = self._on_toc_divider_drag(event) %align[EXPAND] } Workspace { #workspace %align[EXPAND] %proportion[1] } } <<MainArea>>
def _on_toc_divider_drag(self, event): if event.initial: toc = self.get_widget("toc") self._start_width = toc.get_width() else: self.prop(["actions", "set_toc_width"])( self._start_width + event.dx )
def main_area_props(document): return { "actions": document.actions, "toc": toc_props( document ), "toc_divider": toc_divider_props( document ), "workspace": workspace_props( document ), }
panel TableOfContents %layout_rows { if (#has_valid_hoisted_page) { Button { label = "unhoist" @button = #actions.set_hoisted_page(None) %margin[#margin,ALL] %align[EXPAND] } } TableOfContentsScrollArea { #scroll_area %align[EXPAND] %proportion[1] } }
def toc_props(document): return { "background": document.get( ["theme", "toc", "background"] ), "min_size": ( max(50, document.get(["toc", "width"])), -1 ), "has_valid_hoisted_page": is_valid_hoisted_page( document, document.get(["toc", "hoisted_page"]), ), "margin": 1 + document.get( ["theme", "toc", "row_margin"] ), "actions": document.actions, "scroll_area": toc_scroll_area_props( document ), }
def is_valid_hoisted_page(document, page_id): try: page = document.get_page(page_id) root_page = document.get_page() if page["id"] != root_page["id"]: return True except PageNotFound: pass return False
scroll TableOfContentsScrollArea %layout_rows { drop_target = "move_page" loop (#rows cache_limit=#rows_cache_limit) { TableOfContentsRow[rows] { $ __reuse = $id __cache = True %align[EXPAND] } } } <<TableOfContentsScrollArea>>
_last_drop_row = None def on_drag_drop_over(self, x, y): self._hide() drop_point = self._get_drop_point(x, y) if drop_point is not None: self._last_drop_row = self._get_drop_row(drop_point) if self._last_drop_row is not None: valid = self.prop(["actions", "can_move_page"])( self.prop(["dragged_page"]), drop_point.target_page, drop_point.target_index ) self._last_drop_row.show_drop_line( self._calculate_indent(drop_point.level), valid=valid ) return valid return False def on_drag_drop_leave(self): self._hide() def on_drag_drop_data(self, x, y, page_info): drop_point = self._get_drop_point(x, y) if drop_point is not None: self.prop(["actions", "move_page"])( self.prop(["dragged_page"]), drop_point.target_page, drop_point.target_index ) def _hide(self): if self._last_drop_row is not None: self._last_drop_row.hide_drop_line() def _get_drop_point(self, x, y): lines = defaultdict(list) for drop_point in self.prop(["drop_points"]): lines[ self._y_distance_to( self._get_drop_row(drop_point), y ) ].append(drop_point) if lines: columns = {} for drop_point in lines[min(lines.keys())]: columns[self._x_distance_to(drop_point, x)] = drop_point return columns[min(columns.keys())] def _get_drop_row(self, drop_point): return self.get_widget("rows", drop_point.row_index) def _y_distance_to(self, row, y): span_y_center = row.get_y() + row.get_drop_line_y_offset() return int(abs(span_y_center - y)) def _x_distance_to(self, drop_point, x): return int(abs(self._calculate_indent(drop_point.level + 1.5) - x)) def _calculate_indent(self, level): return ( (2 * self.prop(["row_margin"])) + (level + 1) * self.prop(["indent_size"]) )
def toc_scroll_area_props(document): return dict(generate_rows_and_drop_points(document), **{ "rows_cache_limit": document.count_pages() - 1, "row_margin": document.get( ["theme", "toc", "row_margin"] ), "indent_size": document.get( ["theme", "toc", "indent_size"] ), "dragged_page": document.get( ["toc", "dragged_page"] ), "actions": document.actions, })
def generate_rows_and_drop_points(document): try: root_page = document.get_page( document.get(["toc", "hoisted_page"]) ) except PageNotFound: root_page = document.get_page(None) return generate_rows_and_drop_points_page( root_page, toc_row_common_props(document), document.get(["toc", "collapsed"]), document.get(["toc", "dragged_page"]), document.get_open_pages(), 0, False, 0 )
@cache(limit=1000, key_path=[0, "id"]) def generate_rows_and_drop_points_page( page, row_common_props, collapsed, dragged_page, open_pages, level, is_dragged, row_offset ): rows = [] drop_points = [] is_collapsed = page["id"] in collapsed is_dragged = is_dragged or page["id"] == dragged_page rows.append(toc_row_props( row_common_props, page, level, is_dragged, is_collapsed, open_pages )) if is_collapsed: target_index = len(page["children"]) else: target_index = 0 drop_points.append(TableOfContentsDropPoint( row_index=row_offset+len(rows)-1, target_index=target_index, target_page=page["id"], level=level+1 )) if not is_collapsed: for target_index, child in enumerate(page["children"]): sub_result = generate_rows_and_drop_points_page( child, row_common_props, collapsed, dragged_page, open_pages, level+1, is_dragged, row_offset+len(rows) ) rows.extend(sub_result["rows"]) drop_points.extend(sub_result["drop_points"]) drop_points.append(TableOfContentsDropPoint( row_index=row_offset+len(rows)-1, target_index=target_index+1, target_page=page["id"], level=level+1 )) return { "rows": rows, "drop_points": drop_points, }
TableOfContentsDropPoint = namedtuple("TableOfContentsDropPoint", [ "row_index", "target_index", "target_page", "level", ])
panel TableOfContentsRow %layout_rows { TableOfContentsTitle[title] { #title @click = #actions.open_page(#id) @drag = self._on_drag(event) @right_click = self._on_right_click(event) @hover = self._set_background(event.mouse_inside) %align[EXPAND] } TableOfContentsDropLine[drop_line] { #drop_line %align[EXPAND] } } <<TableOfContentsRow>>
def _on_drag(self, event): if not event.initial: self.prop(["actions", "set_dragged_page"])( self.prop(["id"]) ) try: event.initiate_drag_drop("move_page", {}) finally: self.prop(["actions", "set_dragged_page"])( None )
def _on_right_click(self, event): event.show_context_menu([ ("Hoist", self.prop(["level"]) > 0, lambda: self.prop(["actions", "set_hoisted_page"])( self.prop(["id"]) ) ), ])
def _set_background(self, hover): if hover: self.get_widget("title").update_props({ "background": self.prop(["hover_background"]), }) else: self.get_widget("title").update_props({ "background": None, })
def get_drop_line_y_offset(self): drop_line = self.get_widget("drop_line") return drop_line.get_y() + drop_line.get_height() / 2
def show_drop_line(self, indent, valid): self.get_widget("drop_line").update_props({ "active": True, "valid": valid, "indent": indent })
def hide_drop_line(self): self.get_widget("drop_line").update_props({ "active": False, })
def toc_row_common_props(document): return { "row_margin": document.get( ["theme", "toc", "row_margin"] ), "indent_size": document.get( ["theme", "toc", "indent_size"] ), "foreground": document.get( ["theme", "toc", "foreground"] ), "hover_background": document.get( ["theme", "toc", "hover_background"] ), "divider_thickness": document.get( ["theme", "toc", "divider_thickness"] ), "dragdrop_color": document.get( ["theme", "dragdrop_color"] ), "dragdrop_invalid_color": document.get( ["theme", "dragdrop_invalid_color"] ), "placeholder_color": document.get( ["theme", "toc", "placeholder_color"] ), "font": document.get( ["theme", "toc", "font"] ), "actions": document.actions, }
def toc_row_props( row_common_props, page, level, is_dragged, is_collapsed, open_pages ): return { "id": page["id"], "hover_background": row_common_props["hover_background"], "actions": row_common_props["actions"], "level": level, "title": toc_row_title_props( row_common_props, page, level, is_dragged, is_collapsed, open_pages ), "drop_line": toc_row_drop_line_props( row_common_props ), }
panel TableOfContentsTitle %layout_columns { %space[add(#row_margin mul(#level #indent_size))] if (#has_children) { ExpandCollapse { cursor = "hand" size = #indent_size collapsed = #collapsed @click = #actions.toggle_collapsed(#id) @drag = None @right_click = None %align[EXPAND] } } else { %space[#indent_size] } Text { #text_props %align[EXPAND] %margin[#row_margin,ALL] } }
def toc_row_title_props( row_common_props, page, level, is_dragged, is_collapsed, open_pages ): if page["title"]: text = page["title"] if is_dragged: color = row_common_props["dragdrop_invalid_color"] else: color = row_common_props["foreground"] else: text = "Enter title..." color = row_common_props["placeholder_color"] return dict(row_common_props, **{ "id": page["id"], "text_props": TextPropsBuilder(**dict(row_common_props["font"], bold=page["id"] in open_pages, color=color )).text(text).get(), "level": level, "has_children": bool(page["children"]), "collapsed": is_collapsed, "dragged": is_dragged, })
panel TableOfContentsDropLine %layout_columns { %space[#indent] Panel { background = self._get_color(#active #valid) %align[EXPAND] %proportion[1] } } <<TableOfContentsDropLine>>
def _get_color(self, active, valid): if active: if valid: return self.prop(["color"]) else: return self.prop(["invalid_color"]) else: return None
def toc_row_drop_line_props(row_common_props): return { "indent": 0, "active": False, "valid": True, "min_size": (-1, row_common_props["divider_thickness"]), "color": row_common_props["dragdrop_color"], "invalid_color": row_common_props["dragdrop_invalid_color"], }
panel TableOfContentsDivider %layout_columns { }
def toc_divider_props(document): toc_divider_theme = document.get(["theme", "toc_divider"]) return { "background": toc_divider_theme["color"], "min_size": ( toc_divider_theme["thickness"], -1 ), "cursor": "size_horizontal", }
hscroll Workspace %layout_columns { %space[#margin] loop (#columns) { Column { $ %align[EXPAND] } Panel { #divider_panel @drag = self._on_divider_drag(event) %align[EXPAND] } } } <<Workspace>>
def _on_divider_drag(self, event): if event.initial: self._initial_width = self.prop(["page_body_width"]) else: self.prop(["actions", "set_page_body_width"])( max(50, self._initial_width + event.dx) )
def workspace_props(document): return { "background": document.get( ["theme", "workspace", "background"] ), "margin": document.get( ["theme", "workspace", "margin"] ), "page_body_width": document.get( ["workspace", "page_body_width"] ), "actions": document.actions, "columns": columns_props( document ), "divider_panel": { "cursor": "size_horizontal", "min_size": (document.get(["theme", "workspace", "margin"]), -1) } }
@profile_sub("columns_props") def columns_props(document): return [ column_props( document, column, document.get(["selection"]).add("workspace", "column", index) ) for index, column in enumerate(document.get(["workspace", "columns"])) ]
vscroll Column %layout_rows { %space[#margin] loop (#column) { Page { $ %align[EXPAND] } %space[#margin] } }
def column_props(document, column, selection): column_prop = [] index = 0 for page_id in column: try: column_prop.append(page_props( document, document.get_page(page_id), selection.add("page", index, page_id) )) index += 1 except PageNotFound: pass return { "min_size": ( document.get(["workspace", "page_body_width"]) + 2*document.get(["theme", "page", "margin"]) + document.get(["theme", "page", "border", "size"]), -1 ), "margin": document.get( ["theme", "workspace", "margin"] ), "column": column_prop, }
panel Page %layout_rows { PageTopRow { # %align[EXPAND] %proportion[1] } PageBottomBorder { #border %align[EXPAND] } }
panel PageTopRow %layout_columns { PageBody { #body %align[EXPAND] %proportion[1] } PageRightBorder { #border %align[EXPAND] } }
panel PageBody %layout_rows { Title { #title %margin[#margin,ALL] %align[EXPAND] } loop (#paragraphs) { $widget { $ %margin[#margin,LEFT|BOTTOM|RIGHT] %align[EXPAND] } } }
panel PageRightBorder %layout_rows { %space[#size] Panel { min_size = makeTuple(#size -1) background = #color %align[EXPAND] %proportion[1] } }
panel PageBottomBorder %layout_columns { %space[#size] Panel { min_size = makeTuple(-1 #size) background = #color %align[EXPAND] %proportion[1] } }
def page_props(document, page, selection): page_theme = document.get(["theme", "page"]) return { "body": { "id": page["id"], "title": page_title_props( page, document.get(["workspace", "page_body_width"]), page_theme, selection.add("title"), document.actions ), "paragraphs": paragraphs_props( document, page["paragraphs"], selection.add("paragraphs") ), "background": page_theme["background"], "margin": page_theme["margin"], }, "border": page_theme["border"], }
panel Title %layout_rows { TextEdit { #text_edit_props %align[EXPAND] } }
@cache() def page_title_props(page, page_body_width, page_theme, selection, actions): input_handler = TitleInputHandler(page, page_theme, selection, actions) return { "text_edit_props": { "text_props": dict( input_handler.text_props, break_at_word=True, line_height=page_theme["line_height"] ), "max_width": page_body_width, "selection": selection, "selection_color": page_theme["selection_border"], "input_handler": input_handler, "actions": actions, }, }
class TitleInputHandler(StringInputHandler): def __init__(self, page, page_theme, selection, actions): self.page = page self.page_theme = page_theme self.selection = selection self.actions = actions StringInputHandler.__init__( self, self.page["title"], self.selection.value_here ) def create_selection_value(self, values): return dict(values, what="page_title", page_id=self.page["id"]) def build(self): builder = TextPropsBuilder( **self.page_theme["title_font"], selection_color=self.page_theme["selection_color"], cursor_color=self.page_theme["cursor_color"] ) self.build_with_selection_value(builder, self.selection_value) return builder def build_with_selection_value(self, builder, selection_value): if self.data: if selection_value is not None: builder.selection_start(selection_value["start"]) builder.selection_end(selection_value["end"]) if self.selection.active: if selection_value["cursor_at_start"]: builder.cursor(selection_value["start"], main=True) else: builder.cursor(selection_value["end"], main=True) builder.text(self.data, index_increment=0) else: if self.selection.active: if selection_value is not None: builder.cursor(main=True) builder.text("Enter title...", color=self.page_theme["placeholder_color"], index_constant=0) return builder.get() def save(self, new_title, new_selection_value): self.actions["edit_page"]( self.page["id"], { "title": new_title, }, self.selection.create(new_selection_value) )
def paragraphs_props(document, paragraphs, selection): return [ paragraph_props( dict( paragraph, meta=build_paragraph_meta(document, paragraph) ), document.get(["theme", "page"]), document.get(["workspace", "page_body_width"]), selection.add(index, paragraph["id"]), document.actions ) for index, paragraph in enumerate(paragraphs) ]
def build_paragraph_meta(document, paragraph): meta = { "variables": {}, "page_titles": {}, } if paragraph["type"] in ["text", "quote", "image"]: build_text_fragments_meta(meta, document, paragraph["fragments"]) if paragraph["type"] == "list": build_list_meta(meta, document, paragraph["children"]) return meta def build_list_meta(meta, document, children): for child in children: build_text_fragments_meta(meta, document, child["fragments"]) build_list_meta(meta, document, child["children"]) def build_text_fragments_meta(meta, document, fragments): for fragment in fragments: if fragment["type"] == "variable": meta["variables"][fragment["id"]] = document.get_variable(fragment["id"]) elif fragment["type"] == "reference": meta["page_titles"][fragment["page_id"]] = document.get_page(fragment["page_id"])["title"]
@cache(limit=1000, key_path=[0, "id"]) def paragraph_props(paragraph, page_theme, body_width, selection, actions): BUILDERS = { "text": text_paragraph_props, "quote": quote_paragraph_props, "list": list_paragraph_props, "code": code_paragraph_props, "image": image_paragraph_props, } return BUILDERS.get( paragraph["type"], unknown_paragraph_props )(paragraph, page_theme, body_width, selection, actions)
panel TextParagraph %layout_rows { TextEdit { #text_edit_props %align[EXPAND] } }
@profile_sub("text_paragraph_props") def text_paragraph_props(paragraph, page_theme, body_width, selection, actions): return { "widget": TextParagraph, "text_edit_props": text_fragments_to_text_edit_props( paragraph, ["fragments"], selection, page_theme, actions, max_width=body_width, ), }
panel QuoteParagraph %layout_columns { %space[#indent_size] TextEdit { #text_edit_props %align[EXPAND] } }
@profile_sub("quote_paragraph_props") def quote_paragraph_props(paragraph, page_theme, body_width, selection, actions): return { "widget": QuoteParagraph, "text_edit_props": text_fragments_to_text_edit_props( paragraph, ["fragments"], selection, page_theme, actions, max_width=body_width-page_theme["indent_size"], ), "indent_size": page_theme["indent_size"], }
panel ListParagraph %layout_rows { loop (#rows) { ListRow { $ %align[EXPAND] } } }
panel ListRow %layout_columns { %space[mul(#level #indent)] Text { #bullet_props } TextEdit { #text_edit_props %align[EXPAND] } }
@profile_sub("list_paragraph_props") def list_paragraph_props(paragraph, page_theme, body_width, selection, actions): return { "widget": ListParagraph, "rows": list_item_rows_props( paragraph, paragraph["children"], paragraph["child_type"], page_theme, body_width, actions, selection ), }
def list_item_rows_props(paragraph, children, child_type, page_theme, body_width, actions, selection, path=[], level=0): rows = [] for index, child in enumerate(children): rows.append(list_item_row_props( paragraph, child_type, index, child, page_theme, body_width, actions, selection.add(index, "fragments"), path+[index], level )) rows.extend(list_item_rows_props( paragraph, child["children"], child["child_type"], page_theme, body_width, actions, selection.add(index), path+[index, "children"], level+1 )) return rows def list_item_row_props(paragraph, child_type, index, child, page_theme, body_width, actions, selection, path, level): return { "level": level, "indent": page_theme["indent_size"], "bullet_props": dict( TextPropsBuilder( **page_theme["text_font"] ).text(_get_bullet_text(child_type, index)).get(), max_width=page_theme["indent_size"], line_height=page_theme["line_height"] ), "text_edit_props": text_fragments_to_text_edit_props( paragraph, ["children"]+path+["fragments"], selection, page_theme, actions, max_width=body_width-(level+1)*page_theme["indent_size"], line_height=page_theme["line_height"] ), } def _get_bullet_text(list_type, index): if list_type == "ordered": return "{}. ".format(index + 1) else: return u"\u2022 "
panel CodeParagraph %layout_rows { CodeParagraphHeader { #header %align[EXPAND] } CodeParagraphBody { #body %align[EXPAND] } }
@profile_sub("code_paragraph_props") def code_paragraph_props(paragraph, page_theme, body_width, selection, actions): return { "widget": CodeParagraph, "header": code_paragraph_header_props( paragraph, page_theme, body_width ), "body": code_paragraph_body_props( paragraph, page_theme, body_width ), }
panel CodeParagraphHeader %layout_rows { Text { #text_props %align[EXPAND] %margin[#margin,ALL] } }
def code_paragraph_header_props(paragraph, page_theme, body_width): return { "background": page_theme["code"]["header_background"], "margin": page_theme["code"]["margin"], "text_props": dict(code_paragraph_header_path_props( paragraph["filepath"], paragraph["chunkpath"], page_theme["code_font"] ), max_width=body_width-2*page_theme["code"]["margin"]), }
@cache() def code_paragraph_header_path_props(filepath, chunkpath, font): builder = TextPropsBuilder(**font) for index, x in enumerate(filepath): if index > 0: builder.text("/") builder.text(x) if filepath and chunkpath: builder.text(" ") for index, x in enumerate(chunkpath): if index > 0: builder.text("/") builder.text(x) return builder.get()
panel CodeParagraphBody %layout_rows { Text { #text_props %align[EXPAND] %margin[#margin,ALL] } }
def code_paragraph_body_props(paragraph, page_theme, body_width): return { "background": page_theme["code"]["body_background"], "margin": page_theme["code"]["margin"], "text_props": dict( code_paragraph_body_text_props( paragraph, page_theme ), max_width=body_width-2*page_theme["code"]["margin"] ), }
@cache(limit=100, key_path=[0, "id"]) def code_paragraph_body_text_props(paragraph, page_theme): builder = TextPropsBuilder(**page_theme["code_font"]) for fragment in apply_token_styles( code_body_fragments_props( paragraph["fragments"], code_pygments_lexer( paragraph.get("language", ""), paragraph["filepath"][-1] if paragraph["filepath"] else "", ) ), page_theme["token_styles"] ): builder.text(**fragment) return builder.get()
@profile_sub("apply_token_styles") def apply_token_styles(fragments, token_styles): styles = build_style_dict(token_styles) def style_fragment(fragment): if "token_type" in fragment: token_type = fragment["token_type"] while token_type not in styles: token_type = token_type.parent style = styles[token_type] new_fragment = dict(fragment) new_fragment.update(style) return new_fragment else: return fragment return [ style_fragment(fragment) for fragment in fragments ]
def build_style_dict(theme_style): styles = {} for name, value in theme_style.items(): styles[string_to_tokentype(name)] = value return styles
def code_body_fragments_props(fragments, pygments_lexer): code_chunk = CodeChunk() for fragment in fragments: if fragment["type"] == "code": code_chunk.add(fragment["text"]) elif fragment["type"] == "chunk": code_chunk.add( "{}<<{}>>\n".format( fragment["prefix"], "/".join(fragment["path"]) ), {"token_type": TokenType.Comment.Preproc} ) return code_chunk.tokenize(pygments_lexer)
def code_pygments_lexer(language, filename): try: if language: return pygments.lexers.get_lexer_by_name( language, stripnl=False ) else: return pygments.lexers.get_lexer_for_filename( filename, stripnl=False ) except: return pygments.lexers.TextLexer(stripnl=False)
panel ImageParagraph %layout_rows { Image { #image %align[CENTER] } ImageText { #image_text %align[EXPAND] } }
panel ImageText %layout_columns { %space[#indent] TextEdit { #text_edit_props %align[EXPAND] } %space[#indent] }
@profile_sub("image_paragraph_props") def image_paragraph_props(paragraph, page_theme, body_width, selection, actions): return { "widget": ImageParagraph, "image": { "base64_image": paragraph.get("image_base64", None), "width": body_width, }, "image_text": { "indent": page_theme["indent_size"], "text_edit_props": text_fragments_to_text_edit_props( paragraph, ["fragments"], selection, page_theme, actions, align="center", max_width=body_width-2*page_theme["indent_size"], ), }, }
panel UnknownParagraph %layout_rows { Text { #text_props %align[EXPAND] } }
def unknown_paragraph_props(paragraph, page_theme, body_width, selection, actions): return { "widget": UnknownParagraph, "text_props": dict( TextPropsBuilder(**page_theme["code_font"]).text( "Unknown paragraph type '{}'.".format(paragraph["type"]) ).get(), max_width=body_width ), }
The Text widget has support for rendering text. It has support for styling text as well as showing selections and cursors. This edit widget adds generic editing capabilities for data that has been projected to text.
Implementing a text editor has two parts: first the data has to be projected as text, and then an input handler needs to respond to events and change the data/selection accordingly.
selection
selection_color
actions
set_selection
show_selection
hide_selection
max_with
text_props
input_handler
panel TextEdit %layout_rows { selection_box = self._box(#selection #selection_color) Text[text] { #text_props max_width = sub(#max_width mul(2 self._margin())) immediate = self._immediate(#selection) focus = self._focus(#selection) cursor = self._get_cursor(#selection) @click = self._on_click(event #selection) @left_down = self._on_left_down(event #selection) @drag = self._on_drag(event #selection) @key = self._on_key(event #selection) @focus = self._on_focus(#selection) @unfocus = self._on_unfocus(#selection) %align[EXPAND] %margin[self._margin(),ALL] } } <<TextEdit>>
def _box(self, selection, selection_color): return { "width": 1 if selection.value_here else 0, "color": selection_color, }
def _immediate(self, selection): return selection.value_here
def _focus(self, selection): if selection.value_here: return selection.stamp
def _margin(self): width = self.prop(["selection_box", "width"]) if width > 0: return width + 2 else: return 0
def _on_click(self, event, selection): if selection.value_here: return index = self._get_index(event.x, event.y) if index is not None: self.prop(["actions", "set_selection"])( selection.create( self.prop(["input_handler"]).create_selection_value({ "start": index, "end": index, "cursor_at_start": True, }) ) )
def _on_left_down(self, event, selection): if not selection.value_here: return index = self._get_index(event.x, event.y) if index is not None: self.prop(["actions", "set_selection"])( selection.create( self.prop(["input_handler"]).create_selection_value({ "start": index, "end": index, "cursor_at_start": True, }) ) )
def _on_drag(self, event, selection): if not selection.value_here: return if event.initial: self._initial_index = self._get_index(event.x, event.y) else: new_index = self._get_index(event.x, event.y) if self._initial_index is not None and new_index is not None: self.prop(["actions", "set_selection"])( selection.create( self.prop(["input_handler"]).create_selection_value({ "start": min(self._initial_index, new_index), "end": max(self._initial_index, new_index), "cursor_at_start": new_index <= self._initial_index, }) ) )
def _get_index(self, x, y): character, right_side = self.get_widget("text").get_closest_character_with_side( x, y ) if character is not None: if right_side: return character.get("index_right", None) else: return character.get("index_left", None)
def _on_key(self, event, selection): if selection.value_here: self.prop(["input_handler"]).handle_key( event, self.get_widget("text") )
def _get_cursor(self, selection): if selection.value_here: return "beam" else: return None
def _on_focus(self, selection): self.prop(["actions", "activate_selection"])(selection.trail)
def _on_unfocus(self, selection): self.prop(["actions", "deactivate_selection"])(selection.trail)
class TextPropsBuilder(object): def __init__(self, **styles): self._characters = [] self._cursors = [] self._selections = [] self._base_style = dict(styles) self._main_cursor_char_index = None def get(self): return { "characters": self._characters, "cursors": self._cursors, "selections": self._selections, "base_style": self._base_style, } def get_main_cursor_char_index(self): return self._main_cursor_char_index def text(self, text, **kwargs): fragment = {} for field in TextStyle._fields: if field in kwargs: fragment[field] = kwargs[field] index_prefix = kwargs.get("index_prefix", None) if index_prefix is None: create_index = lambda x: x else: create_index = lambda x: index_prefix + [x] index_increment = kwargs.get("index_increment", None) index_constant = kwargs.get("index_constant", None) for index, character in enumerate(text): x = dict(fragment, text=character) if index_increment is not None: x["index_left"] = create_index( index_increment + index ) x["index_right"] = create_index( index_increment + index + 1 ) if index_constant is not None: x["index_left"] = x["index_right"] = create_index( index_constant ) self._characters.append(x) return self def selection_start(self, offset=0, color=None): self._selections.append(( self._index(offset), self._index(offset), color )) def selection_end(self, offset=0): last_selection = self._selections[-1] self._selections[-1] = ( last_selection[0], self._index(offset), last_selection[2] ) def cursor(self, offset=0, color=None, main=False): index = self._index(offset) self._cursors.append((index, color)) if main: self._main_cursor_char_index = index def _index(self, offset): return len(self._characters) + offset
class StringInputHandler(object): def __init__(self, data, selection_value): self.data = data self.selection_value = selection_value builder = self.build() self.text_props = builder.get() self.main_cursor_char_index = builder.get_main_cursor_char_index() @property def start(self): return self.selection_value["start"] @property def end(self): return self.selection_value["end"] @property def cursor_at_start(self): return self.selection_value["cursor_at_start"] @property def cursor(self): if self.cursor_at_start: return self.start else: return self.end @cursor.setter def cursor(self, position): self.selection_value = self.create_selection_value({ "start": position, "end": position, "cursor_at_start": True, }) @property def has_selection(self): return self.start != self.end def replace(self, text): self.data = self.data[:self.start] + text + self.data[self.end:] position = self.start + len(text) self.selection_value = self.create_selection_value({ "start": position, "end": position, "cursor_at_start": True, }) def handle_key(self, key_event, text): print(key_event) if key_event.key == "\x08": # Backspace if self.has_selection: self.replace("") else: self.selection_value = self.create_selection_value({ "start": self._next_cursor(self._cursors_left(text)), "end": self.start, "cursor_at_start": True, }) self.replace("") elif key_event.key == "\x00": # Del (and many others) if self.has_selection: self.replace("") else: self.selection_value = self.create_selection_value({ "start": self.start, "end": self._next_cursor(self._cursors_right(text)), "cursor_at_start": False, }) self.replace("") elif key_event.key == "\x02": # Ctrl-B self.cursor = self._next_cursor(self._cursors_left(text)) elif key_event.key == "\x06": # Ctrl-F self.cursor = self._next_cursor(self._cursors_right(text)) else: self.replace(key_event.key) self.save(self.data, self.selection_value) def _next_cursor(self, cursors): for cursor in cursors: if self._cursor_differs(cursor): return cursor return