RLiterate 2

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.

Showcase

TODO: Show an example how literate programming is done in RLiterate.

Concepts

TODO: Explain concepts and features in greater detail.

Why literate programming?

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:

Implementation

This chapter contains the complete implementation of the second version of RLiterate.

Files

RLiterate is written in a single Python file that looks like this:

  1. rliterate2.py
#!/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:

  1. test_rliterate2.py
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.

Architecture overview

TODO: Introduce GUI framework, document model, and main frame.

Main function

  1. rliterate2.py
  2. functions
def main():
    args = parse_args()
    start_app(
        MainFrame,
        create_props(
            main_frame_props,
            Document(args["path"])
        )
    )
  1. rliterate2.py
  2. functions
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
  1. rliterate2.py
  2. functions
def usage(script):
    sys.exit(f"usage: {script} <path>")

Main frame

  1. rliterate2.py
  2. classes
frame MainFrame %layout_rows {
    Toolbar(
        #toolbar
        %align[EXPAND]
    )
    ToolbarDivider(
        #toolbar_divider
        %align[EXPAND]
    )
    MainArea(
        #main_area
        %align[EXPAND]
        %proportion[1]
    )
}
  1. rliterate2.py
  2. functions
def main_frame_props(document):
    return {
        "toolbar": toolbar_props(
            document.get(["theme", "toolbar"]),
            actions(document)
        ),
        "toolbar_divider": toolbar_divider_props(
            document.get(["theme", "toolbar_divider"])
        ),
        "main_area": main_area_props(
            document,
            actions(document)
        ),
        "title": format_title(
            document.get(["path"])
        ),
    }
  1. rliterate2.py
  2. functions
@cache()
def actions(document):
    return {
        "rotate_theme": document.rotate,
        "set_toc_width": document.set_toc_width,
        "set_hoisted_page": document.set_hoisted_page,
    }
  1. rliterate2.py
  2. functions
@cache()
def format_title(path):
    return "{} ({}) - RLiterate 2".format(
        os.path.basename(path),
        os.path.abspath(os.path.dirname(path))
    )

Toolbar

  1. rliterate2.py
  2. classes
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]
}
  1. rliterate2.py
  2. functions
@cache()
def toolbar_props(toolbar_theme, actions):
    return {
        "background": toolbar_theme["background"],
        "margin": toolbar_theme["margin"],
        "actions": actions,
    }

Toolbar divider

  1. rliterate2.py
  2. classes
panel ToolbarDivider %layout_rows {
}
  1. rliterate2.py
  2. functions
@cache()
def toolbar_divider_props(toolbar_divider_theme):
    return {
        "background": toolbar_divider_theme["color"],
        "min_size": (
            -1,
            toolbar_divider_theme["thickness"]
        ),
    }

Main area

  1. rliterate2.py
  2. classes
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>>
  1. rliterate2.py
  2. classes
  3. 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
        )
  1. rliterate2.py
  2. functions
def main_area_props(document, actions):
    return {
        "toc": table_of_contents_props(
            document,
            actions
        ),
        "toc_divider": toc_divider_props(
            document.get(["theme", "toc_divider"])
        ),
        "workspace": workspace_props(
            document
        ),
        "actions": actions,
    }

Table of contents

  1. rliterate2.py
  2. classes
panel TableOfContents %layout_rows {
    if (#has_valid_hoisted_page) {
        Button(
            label   = "unhoist"
            @button = #actions.set_hoisted_page(None)
            %margin[add(1 #row_margin),ALL]
            %align[EXPAND]
        )
    }
    TableOfContentsScrollArea(
        #scroll_area
        %align[EXPAND]
        %proportion[1]
    )
}
  1. rliterate2.py
  2. functions
def table_of_contents_props(document, actions):
    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"]),
        ),
        "row_margin": document.get(
            ["theme", "toc", "row_margin"]
        ),
        "scroll_area": table_of_contents_scroll_area_props(
            document
        ),
        "actions": actions,
    }
  1. rliterate2.py
  2. functions
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 area
  1. rliterate2.py
  2. classes
scroll TableOfContentsScrollArea %layout_rows {
    drop_target = "move_page"
    loop (#rows cache_limit=#rows_cache_limit) {
        TableOfContentsRow[rows](
            $
            #row_extra
            __reuse = $id
            __cache = True
            %align[EXPAND]
        )
    }
}

<<TableOfContentsScrollArea>>
  1. rliterate2.py
  2. classes
  3. 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_extra", "row_margin"])) +
        (level + 1) * self.prop(["row_extra", "indent_size"])
    )
  1. rliterate2.py
  2. functions
def table_of_contents_scroll_area_props(document):
    props = {
        "rows_cache_limit": document.count_pages() - 1,
        "row_extra": table_of_contents_row_extra_props(
            document
        ),
        "dragged_page": document.get(
            ["toc", "dragged_page"]
        ),
        "actions": {
            "can_move_page": document.can_move_page,
            "move_page": document.move_page,
        },
    }
    props.update(generate_rows_and_drop_points(
        document,
        document.get(["toc", "collapsed"]),
        document.get(["toc", "hoisted_page"]),
        document.get(["toc", "dragged_page"]),
        document.get_open_pages(),
        document.get(["theme", "toc", "foreground"]),
        document.get(["theme", "dragdrop_invalid_color"]),
        document.get(["theme", "toc", "font"]),
    ))
    return props
  1. rliterate2.py
  2. functions
def table_of_contents_row_extra_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"]
        ),
        "actions": {
            "set_hoisted_page": document.set_hoisted_page,
            "set_dragged_page": document.set_dragged_page,
            "toggle_collapsed": document.toggle_collapsed,
            "open_page": document.open_page,
        },
    }
  1. rliterate2.py
  2. functions
def generate_rows_and_drop_points(
    document,
    collapsed,
    hoisted_page,
    dragged_page,
    open_pages,
    foreground,
    dragdrop_invalid_color,
    font
):
    try:
        root_page = document.get_page(hoisted_page)
    except PageNotFound:
        root_page = document.get_page(None)
    return _generate_rows_and_drop_points_page(
        root_page,
        collapsed,
        dragged_page,
        open_pages,
        foreground,
        dragdrop_invalid_color,
        font,
        0,
        False,
        0
    )

@cache(limit=1000, key_path=[0, "id"])
def _generate_rows_and_drop_points_page(
    page,
    collapsed,
    dragged_page,
    open_pages,
    foreground,
    dragdrop_invalid_color,
    font,
    level,
    dragged,
    row_offset
):
    rows = []
    drop_points = []
    is_collapsed = page["id"] in collapsed
    num_children = len(page["children"])
    dragged = dragged or page["id"] == dragged_page
    rows.append({
        "id": page["id"],
        "text_props": TextPropsBuilder(**dict(font,
            bold=page["id"] in open_pages,
            color=dragdrop_invalid_color if dragged else foreground
        )).text(page["title"]).get(),
        "level": level,
        "has_children": num_children > 0,
        "collapsed": is_collapsed,
        "dragged": dragged,
    })
    if is_collapsed:
        target_index = num_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,
                collapsed,
                dragged_page,
                open_pages,
                foreground,
                dragdrop_invalid_color,
                font,
                level+1,
                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,
    }
  1. rliterate2.py
  2. classes
TableOfContentsDropPoint = namedtuple("TableOfContentsDropPoint", [
    "row_index",
    "target_index",
    "target_page",
    "level",
])
Row
  1. rliterate2.py
  2. classes
panel TableOfContentsRow %layout_rows {
    TableOfContentsTitle[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](
        indent        = 0
        active        = False
        valid         = True
        thickness     = #divider_thickness
        color         = #dragdrop_color
        invalid_color = #dragdrop_invalid_color
        %align[EXPAND]
    )
}

<<TableOfContentsRow>>
  1. rliterate2.py
  2. classes
  3. 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
            )
  1. rliterate2.py
  2. classes
  3. TableOfContentsRow
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"])
            )
        ),
    ])
  1. rliterate2.py
  2. classes
  3. TableOfContentsRow
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,
        })
  1. rliterate2.py
  2. classes
  3. TableOfContentsRow
def get_drop_line_y_offset(self):
    drop_line = self.get_widget("drop_line")
    return drop_line.get_y() + drop_line.get_height() / 2
  1. rliterate2.py
  2. classes
  3. TableOfContentsRow
def show_drop_line(self, indent, valid):
    self.get_widget("drop_line").update_props({
        "active": True,
        "valid": valid,
        "indent": indent
    })
  1. rliterate2.py
  2. classes
  3. TableOfContentsRow
def hide_drop_line(self):
    self.get_widget("drop_line").update_props({
        "active": False,
    })
Title
  1. rliterate2.py
  2. classes
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]
    )
}
Drop line
  1. rliterate2.py
  2. classes
panel TableOfContentsDropLine %layout_columns {
    %space[#indent]
    Panel(
        min_size   = makeTuple(-1 #thickness)
        background = self._get_color(#active #valid)
        %align[EXPAND]
        %proportion[1]
    )
}

<<TableOfContentsDropLine>>
  1. rliterate2.py
  2. classes
  3. TableOfContentsDropLine
def _get_color(self, active, valid):
    if active:
        if valid:
            return self.prop(["color"])
        else:
            return self.prop(["invalid_color"])
    else:
        return None

Table of contents divider

  1. rliterate2.py
  2. classes
panel TableOfContentsDivider %layout_columns {
}
  1. rliterate2.py
  2. functions
@cache()
def toc_divider_props(toc_divider_theme):
    return {
        "background": toc_divider_theme["color"],
        "min_size": (
            toc_divider_theme["thickness"],
            -1
        ),
        "cursor": "size_horizontal",
    }

Workspace

  1. rliterate2.py
  2. classes
hscroll Workspace %layout_columns {
    %space[#margin]
    loop (#columns) {
        Column(
            min_size         = makeTuple(#column_width -1)
            column           = $
            workspace_margin = #margin
            actions          = #actions
            %align[EXPAND]
        )
        Panel(
            cursor   = "size_horizontal"
            min_size = makeTuple(#margin -1)
            @drag    = self._on_divider_drag(event)
            %align[EXPAND]
        )
    }
}

<<Workspace>>
  1. rliterate2.py
  2. classes
  3. 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)
        )
  1. rliterate2.py
  2. functions
def workspace_props(document):
    return {
        "background": document.get(
            ["theme", "workspace", "background"]
        ),
        "margin": document.get(
            ["theme", "workspace", "margin"]
        ),
        "column_width": (
            document.get(["workspace", "page_body_width"]) +
            2*document.get(["theme", "page", "margin"]) +
            document.get(["theme", "page", "border", "size"])
        ),
        "columns": build_columns(
            document,
            document.get(["workspace", "columns"]),
            document.get(["workspace", "page_body_width"]),
            document.get(["theme", "page"]),
            document.get(["selection"]),
            workspace_actions(document)
        ),
        "page_body_width": document.get(
            ["workspace", "page_body_width"]
        ),
        "actions": workspace_actions(document),
    }
  1. rliterate2.py
  2. functions
@cache()
def workspace_actions(document):
    return {
        "set_page_body_width": document.set_page_body_width,
        "edit_page": document.edit_page,
        "edit_paragraph": document.edit_paragraph,
        "show_selection": document.show_selection,
        "hide_selection": document.hide_selection,
        "set_selection": document.set_selection,
    }
  1. rliterate2.py
  2. functions
@profile_sub("build_columns")
def build_columns(document, columns, page_body_width, page_theme, selection, actions):
    selection = selection.add("workspace")
    columns_prop = []
    for index, column in enumerate(columns):
        columns_prop.append(build_column(
            document,
            column,
            page_body_width,
            page_theme,
            selection.add("column", index),
            actions
        ))
    return columns_prop
Column
  1. rliterate2.py
  2. classes
vscroll Column %layout_rows {
    %space[#workspace_margin]
    loop (#column) {
        Page(
            page = $
            %align[EXPAND]
        )
        %space[#workspace_margin]
    }
}
  1. rliterate2.py
  2. functions
def build_column(document, column, page_body_width, page_theme, selection, actions):
    column_prop = []
    index = 0
    for page_id in column:
        try:
            column_prop.append(build_page(
                document.get_page(page_id),
                page_body_width,
                page_theme,
                selection.add("page", index, page_id),
                actions
            ))
            index += 1
        except PageNotFound:
            pass
    return column_prop
Page
  1. rliterate2.py
  2. classes
panel Page %layout_rows {
    PageTopRow(
        page = #page
        %align[EXPAND]
        %proportion[1]
    )
    PageBottomBorder(
        #page.border
        %align[EXPAND]
    )
}
  1. rliterate2.py
  2. classes
panel PageTopRow %layout_columns {
    PageBody(
        #page
        %align[EXPAND]
        %proportion[1]
    )
    PageRightBorder(
        #page.border
        %align[EXPAND]
    )
}
  1. rliterate2.py
  2. classes
panel PageBody %layout_rows {
    Title(
        #title
        %margin[#margin,ALL]
        %align[EXPAND]
    )
    loop (#paragraphs) {
        $widget(
            $
            %margin[#margin,LEFT|BOTTOM|RIGHT]
            %align[EXPAND]
        )
    }
}
  1. rliterate2.py
  2. classes
panel PageRightBorder %layout_rows {
    %space[#size]
    Panel(
        min_size   = makeTuple(#size -1)
        background = #color
        %align[EXPAND]
        %proportion[1]
    )
}
  1. rliterate2.py
  2. classes
panel PageBottomBorder %layout_columns {
    %space[#size]
    Panel(
        min_size   = makeTuple(-1 #size)
        background = #color
        %align[EXPAND]
        %proportion[1]
    )
}
  1. rliterate2.py
  2. functions
def build_page(page, page_body_width, page_theme, selection, actions):
    return {
        "id": page["id"],
        "title": build_title(
            page,
            page_body_width,
            page_theme,
            selection.add("title"),
            actions
        ),
        "paragraphs": build_paragraphs(
            page["paragraphs"],
            page_theme,
            page_body_width,
            selection.add("paragraphs"),
            actions
        ),
        "border": page_theme["border"],
        "background": page_theme["background"],
        "margin": page_theme["margin"],
    }
Title
  1. rliterate2.py
  2. classes
panel Title %layout_rows {
    TextEdit(
        #text_edit_props
        %align[EXPAND]
    )
}
  1. rliterate2.py
  2. functions
def build_title(page, page_body_width, page_theme, selection, actions):
    def save(new_title, new_selection):
        actions["edit_page"](
            page["id"],
            {
                "title": new_title,
            },
            selection.create(new_selection)
        )
    text_props = build_title_text_props(
        TextPropsBuilder(
            **page_theme["title_font"],
            selection_color=page_theme["selection_color"],
            cursor_color=page_theme["cursor_color"]
        ),
        page["title"],
        selection,
        page_theme["placeholder_color"]
    )
    return {
        "text_edit_props": {
            "text_props": dict(
                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": StringInputHandler(
                page["title"],
                selection.get(),
                text_props["cursors"][0][0] if text_props["cursors"] else None,
                save
            ),
            "actions": actions,
        },
    }
  1. rliterate2.py
  2. functions
def build_title_text_props(builder, title, selection, placeholder_color):
    if title:
        if selection.present():
            value = selection.get()
            builder.selection_start(value["start"])
            builder.selection_end(value["end"])
            if value["cursor_at_start"]:
                builder.cursor(value["start"])
            else:
                builder.cursor(value["end"])
        builder.text(title, index_increment=0)
    else:
        if selection.present():
            builder.cursor()
        builder.text("Enter title...", color=placeholder_color, index_constant=0)
    return builder.get()
Paragraphs
  1. rliterate2.py
  2. functions
def build_paragraphs(paragraphs, page_theme, body_width, selection, actions):
    return [
        build_paragraph(
            paragraph,
            page_theme,
            body_width,
            selection.add(index, paragraph["id"]),
            actions
        )
        for index, paragraph in enumerate(paragraphs)
    ]
  1. rliterate2.py
  2. functions
@cache(limit=1000, key_path=[0, "id"])
def build_paragraph(paragraph, page_theme, body_width, selection, actions):
    BUILDERS = {
        "text": build_text_paragraph,
        "quote": build_quote_paragraph,
        "list": build_list_paragraph,
        "code": build_code_paragraph,
        "image": build_image_paragraph,
    }
    return BUILDERS.get(
        paragraph["type"],
        build_unknown_paragraph
    )(paragraph, page_theme, body_width, selection, actions)
Text
  1. rliterate2.py
  2. classes
panel TextParagraph %layout_rows {
    TextEdit(
        #text_edit_props
        %align[EXPAND]
    )
}
  1. rliterate2.py
  2. functions
@profile_sub("build_text_paragraph")
def build_text_paragraph(paragraph, page_theme, body_width, selection, actions):
    def save(new_text_fragments, new_selection):
        actions["edit_paragraph"](
            paragraph["id"],
            {
                "fragments": new_text_fragments,
            },
            selection.create(new_selection)
        )
    return {
        "widget": TextParagraph,
        "text_edit_props": text_fragments_to_text_edit_props(
            paragraph["fragments"],
            selection,
            page_theme,
            actions,
            save,
            max_width=body_width,
        ),
    }
Quote
  1. rliterate2.py
  2. classes
panel QuoteParagraph %layout_columns {
    %space[#indent_size]
    TextEdit(
        #text_edit_props
        %align[EXPAND]
    )
}
  1. rliterate2.py
  2. functions
@profile_sub("build_quote_paragraph")
def build_quote_paragraph(paragraph, page_theme, body_width, selection, actions):
    def save(new_text_fragments, new_selection):
        actions["edit_paragraph"](
            paragraph["id"],
            {
                "fragments": new_text_fragments,
            },
            selection.create(new_selection)
        )
    return {
        "widget": QuoteParagraph,
        "text_edit_props": text_fragments_to_text_edit_props(
            paragraph["fragments"],
            selection,
            page_theme,
            actions,
            save,
            max_width=body_width-page_theme["indent_size"],
        ),
        "indent_size": page_theme["indent_size"],
    }

List
  1. rliterate2.py
  2. classes
panel ListParagraph %layout_rows {
    loop (#rows) {
        ListRow(
            $
            indent     = #indent
            body_width = #body_width
            %align[EXPAND]
        )
    }
}
  1. rliterate2.py
  2. classes
panel ListRow %layout_columns {
    %space[mul(#level #indent)]
    Text(
        #bullet_props
        foo = 1
    )
    TextEdit(
        #text_edit_props
        %align[EXPAND]
    )
}
  1. rliterate2.py
  2. functions
@profile_sub("build_list_paragraph")
def build_list_paragraph(paragraph, page_theme, body_width, selection, actions):
    return {
        "widget": ListParagraph,
        "rows": build_list_item_rows(
            paragraph,
            paragraph["children"],
            paragraph["child_type"],
            page_theme,
            body_width,
            actions,
            selection
        ),
        "indent": page_theme["indent_size"],
        "body_width": body_width,
    }
  1. rliterate2.py
  2. functions
def build_list_item_rows(paragraph, children, child_type, page_theme, body_width, actions, selection, path=[], level=0):
    rows = []
    for index, child in enumerate(children):
        rows.append(build_list_item_row(
            paragraph,
            child_type,
            index,
            child,
            page_theme,
            body_width,
            actions,
            selection.add(index, "fragments"),
            path+[index],
            level
        ))
        rows.extend(build_list_item_rows(
            paragraph,
            child["children"],
            child["child_type"],
            page_theme,
            body_width,
            actions,
            selection.add(index),
            path+[index, "children"],
            level+1
        ))
    return rows

def build_list_item_row(paragraph, child_type, index, child, page_theme, body_width, actions, selection, path, level):
    def save(new_text_fragments, new_selection):
        actions["edit_paragraph"](
            paragraph["id"],
            {
                "children": im_modify(
                    paragraph["children"],
                    path+["fragments"],
                    lambda value: new_text_fragments
                ),
            },
            selection.create(new_selection)
        )
    return {
        "text_edit_props": text_fragments_to_text_edit_props(
            child["fragments"],
            selection,
            page_theme,
            actions,
            save,
            max_width=body_width-(level+1)*page_theme["indent_size"],
        ),
        "bullet_props": dict(text_fragments_to_props(
            [{"text": _get_bullet_text(child_type, index)}],
            **page_theme["text_font"]
        ), max_width=page_theme["indent_size"], line_height=page_theme["line_height"]),
        "level": level,
    }

def _get_bullet_text(list_type, index):
    if list_type == "ordered":
        return "{}. ".format(index + 1)
    else:
        return u"\u2022 "
Code
  1. rliterate2.py
  2. classes
panel CodeParagraph %layout_rows {
    CodeParagraphHeader(
        #header
        body_width = #body_width
        %align[EXPAND]
    )
    CodeParagraphBody(
        #body
        body_width = #body_width
        %align[EXPAND]
    )
}
  1. rliterate2.py
  2. functions
@profile_sub("build_code_paragraph")
def build_code_paragraph(paragraph, page_theme, body_width, selection, actions):
    return {
        "widget": CodeParagraph,
        "header": build_code_paragraph_header(
            paragraph,
            page_theme
        ),
        "body": build_code_paragraph_body(
            paragraph,
            page_theme
        ),
        "body_width": body_width,
    }
Header
  1. rliterate2.py
  2. classes
panel CodeParagraphHeader %layout_rows {
    Text(
        #text_props
        max_width = sub(#body_width mul(2 #margin))
        %align[EXPAND]
        %margin[#margin,ALL]
    )
}
  1. rliterate2.py
  2. functions
def build_code_paragraph_header(paragraph, page_theme):
    return {
        "background": page_theme["code"]["header_background"],
        "margin": page_theme["code"]["margin"],
        "text_props": build_code_path_props(
            paragraph["filepath"],
            paragraph["chunkpath"],
            page_theme["code_font"]
        ),
    }
  1. rliterate2.py
  2. functions
def build_code_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()
Body
  1. rliterate2.py
  2. classes
panel CodeParagraphBody %layout_rows {
    Text(
        #text_props
        max_width = sub(#body_width mul(2 #margin))
        %align[EXPAND]
        %margin[#margin,ALL]
    )
}
  1. rliterate2.py
  2. functions
def build_code_paragraph_body(paragraph, page_theme):
    return {
        "background": page_theme["code"]["body_background"],
        "margin": page_theme["code"]["margin"],
        "text_props": text_fragments_to_props(
            apply_token_styles(
                build_code_body_fragments(
                    paragraph["fragments"],
                    code_pygments_lexer(
                        paragraph.get("language", ""),
                        paragraph["filepath"][-1] if paragraph["filepath"] else "",
                    )
                ),
                page_theme["token_styles"]
            ),
            **page_theme["code_font"]
        ),
    }
  1. rliterate2.py
  2. functions
def build_code_body_fragments(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)
Image
  1. rliterate2.py
  2. classes
panel ImageParagraph %layout_rows {
    Image(
        base64_image = #base64_image
        width        = #body_width
        %align[CENTER]
    )
    ImageText(
        indent          = #indent
        text_edit_props = #text_edit_props
        %align[EXPAND]
    )
}
  1. rliterate2.py
  2. classes
panel ImageText %layout_columns {
    %space[#indent]
    TextEdit(
        #text_edit_props
        %align[EXPAND]
    )
    %space[#indent]
}
  1. rliterate2.py
  2. functions
@profile_sub("build_image_paragraph")
def build_image_paragraph(paragraph, page_theme, body_width, selection, actions):
    def save(new_text_fragments, new_selection):
        actions["edit_paragraph"](
            paragraph["id"],
            {
                "fragments": new_text_fragments,
            },
            selection.create(new_selection)
        )
    return {
        "widget": ImageParagraph,
        "base64_image": paragraph.get("image_base64", None),
        "body_width": body_width,
        "indent": page_theme["indent_size"],
        "text_edit_props": text_fragments_to_text_edit_props(
            paragraph["fragments"],
            selection,
            page_theme,
            actions,
            save,
            align="center",
            max_width=body_width-2*page_theme["indent_size"],
        ),
    }
Unknown paragraph
  1. rliterate2.py
  2. classes
panel UnknownParagraph %layout_rows {
    Text(
        #text_props
        max_width = #body_width
        %align[EXPAND]
    )
}
  1. rliterate2.py
  2. functions
def build_unknown_paragraph(paragraph, page_theme, body_width, selection, actions):
    return {
        "widget": UnknownParagraph,
        "text_props": TextPropsBuilder(**page_theme["code_font"]).text(
            "Unknown paragraph type '{}'.".format(paragraph["type"])
        ).get(),
        "body_width": body_width,
    }
Utilities
  1. rliterate2.py
  2. functions
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)
  1. rliterate2.py
  2. functions
@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
    ]
  1. rliterate2.py
  2. functions
def build_style_dict(theme_style):
    styles = {}
    for name, value in theme_style.items():
        styles[string_to_tokentype(name)] = value
    return styles

Reusable widgets

TextEdit
  1. rliterate2.py
  2. classes
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     = #actions.show_selection(#selection)
        @unfocus   = #actions.hide_selection(#selection)
        %align[EXPAND]
        %margin[self._margin(),ALL]
    )
}

<<TextEdit>>
  1. rliterate2.py
  2. classes
  3. TextEdit
def _box(self, selection, selection_color):
    return {
        "width": 1 if selection.present() else 0,
        "color": selection_color,
    }
  1. rliterate2.py
  2. classes
  3. TextEdit
def _immediate(self, selection):
    return selection.present()
  1. rliterate2.py
  2. classes
  3. TextEdit
def _focus(self, selection):
    return selection.present()
  1. rliterate2.py
  2. classes
  3. TextEdit
def _margin(self):
    return self.prop(["selection_box", "width"]) + 1
  1. rliterate2.py
  2. classes
  3. TextEdit
def _on_click(self, event, selection):
    if selection.present():
        return
    index = self._get_index(event.x, event.y)
    if index is not None:
        self.prop(["actions", "set_selection"])(
            selection.create({
                "start": index,
                "end": index,
                "cursor_at_start": True,
            })
        )
  1. rliterate2.py
  2. classes
  3. TextEdit
def _on_left_down(self, event, selection):
    if not selection.present():
        return
    index = self._get_index(event.x, event.y)
    if index is not None:
        self.prop(["actions", "set_selection"])(
            selection.create({
                "start": index,
                "end": index,
                "cursor_at_start": True,
            })
        )
  1. rliterate2.py
  2. classes
  3. TextEdit
def _on_drag(self, event, selection):
    if not selection.present():
        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({
                    "start": min(self._initial_index, new_index),
                    "end": max(self._initial_index, new_index),
                    "cursor_at_start": new_index <= self._initial_index,
                })
            )
  1. rliterate2.py
  2. classes
  3. TextEdit
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:
        index = character.get("index", None)
        if right_side:
            return character.get("index_right", index)
        else:
            return character.get("index_left", index)
  1. rliterate2.py
  2. classes
  3. TextEdit
def _on_key(self, event, selection):
    if selection.present():
        self.prop(["input_handler"]).handle_key(
            event,
            self.get_widget("text")
        )
  1. rliterate2.py
  2. classes
  3. TextEdit
def _get_cursor(self, selection):
    if selection.present():
        return "beam"
    else:
        return None
Input handlers
  1. rliterate2.py
  2. classes
class StringInputHandler(object):

    def __init__(self, data, selection, cursor_index, save):
        self.data = data
        self.selection = selection
        self.cursor_index = cursor_index
        self.save = save

    @property
    def start(self):
        return self.selection["start"]

    @property
    def end(self):
        return self.selection["end"]

    @property
    def cursor_at_start(self):
        return self.selection["cursor_at_start"]

    @property
    def cursor_pos(self):
        if self.cursor_at_start:
            return self.start
        else:
            return self.end

    @cursor_pos.setter
    def cursor_pos(self, pos):
        self.selection = {
            "start": pos,
            "end": pos,
            "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 = {
            "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 = {
                    "start": text.index_left(self.cursor_index, self.cursor_pos),
                    "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 = {
                    "start": self.start,
                    "end": text.index_right(self.cursor_index, self.cursor_pos),
                    "cursor_at_start": False,
                }
                self.replace("")
        elif key_event.key == "\x02": # Ctrl-B
            self.cursor_pos = text.index_left(self.cursor_index, self.cursor_pos)
        elif key_event.key == "\x06": # Ctrl-F
            self.cursor_pos = text.index_right(self.cursor_index, self.cursor_pos)
        else:
            self.replace(key_event.key)
        self.save(self.data, self.selection)
  1. rliterate2.py
  2. classes
class TextFragmentsInputHandler(StringInputHandler):

    def replace(self, text):
        before = self.data[:self.start[0]]
        left = self.data[self.start[0]]
        right = self.data[self.end[0]]
        after = self.data[self.end[0]+1:]
        if left is right:
            middle = [
                im_modify(
                    left,
                    ["text"],
                    lambda value: value[:self.start[1]] + text + value[self.end[1]:]
                ),
            ]
            position = [self.start[0], self.start[1]+len(text)]
        elif self.cursor_at_start:
            middle = [
                im_modify(
                    left,
                    ["text"],
                    lambda value: value[:self.start[1]] + text
                ),
                im_modify(
                    right,
                    ["text"],
                    lambda value: value[self.end[1]:]
                ),
            ]
            if not middle[1]["text"]:
                middle.pop(1)
            position = [self.start[0], self.start[1]+len(text)]
        else:
            middle = [
                im_modify(
                    left,
                    ["text"],
                    lambda value: value[:self.start[1]]
                ),
                im_modify(
                    right,
                    ["text"],
                    lambda value: text + value[self.end[1]:]
                ),
            ]
            if not middle[0]["text"]:
                middle.pop(0)
                position = [self.end[0]-1, len(text)]
            else:
                position = [self.end[0], len(text)]
        self.data = before + middle + after
        self.selection = {
            "start": position,
            "end": position,
            "cursor_at_start": True,
        }

    def handle_key(self, key_event, text):
        StringInputHandler.handle_key(self, key_event, text)

Document

  1. rliterate2.py
  2. classes
class Document(Immutable):

    ROOT_PAGE_PATH = ["doc", "root_page"]

    def __init__(self, path):
        Immutable.__init__(self, {
            "path": path,
            "doc": load_document_from_file(path),
            "selection": Selection.empty(),
            <<Document/init>>
        })
        self._build_page_index()

    <<Document>>
  1. rliterate2.py
  2. functions
def load_document_from_file(path):
    if os.path.exists(path):
        return load_json_from_file(path)
    else:
        return create_new_document()
  1. rliterate2.py
  2. functions
def load_json_from_file(path):
    with open(path) as f:
        return json.load(f)
  1. rliterate2.py
  2. functions
def create_new_document():
    return {
        "root_page": create_new_page(),
        "variables": {},
    }

Page index

  1. rliterate2.py
  2. classes
  3. Document
def _build_page_index(self):
    def build(page, path, parent, index):
        page_meta = PageMeta(page["id"], path, parent, index)
        page_index[page["id"]] = page_meta
        for index, child in enumerate(page["children"]):
            build(child, path+["children", index], page_meta, index)
    page_index = {}
    build(self.get(self.ROOT_PAGE_PATH), self.ROOT_PAGE_PATH, None, 0)
    self._page_index = page_index
  1. rliterate2.py
  2. classes
  3. Document
def _get_page_meta(self, page_id):
    if page_id not in self._page_index:
        raise PageNotFound()
    return self._page_index[page_id]
  1. rliterate2.py
  2. classes
class PageNotFound(Exception):
    pass
  1. rliterate2.py
  2. classes
class PageMeta(object):

    def __init__(self, id, path, parent, index):
        self.id = id
        self.path = path
        self.parent = parent
        self.index = index

Pages

  1. rliterate2.py
  2. functions
def create_new_page():
    return {
        "id": genid(),
        "title": "New page...",
        "children": [],
        "paragraphs": [],
    }
  1. rliterate2.py
  2. classes
  3. Document
def get_page(self, page_id=None):
    if page_id is None:
        return self.get(self.ROOT_PAGE_PATH)
    else:
        return self.get(self._get_page_meta(page_id).path)
  1. rliterate2.py
  2. classes
  3. Document
def count_pages(self):
    return len(self._page_index)
  1. rliterate2.py
  2. classes
  3. Document
def move_page(self, source_id, target_id, target_index):
    try:
        self._move_page(
            self._get_page_meta(source_id),
            self._get_page_meta(target_id),
            target_index
        )
    except PageNotFound:
        pass
  1. rliterate2.py
  2. classes
  3. Document
def can_move_page(self, source_id, target_id, target_index):
    try:
        return self._can_move_page(
            self._get_page_meta(source_id),
            self._get_page_meta(target_id),
            target_index
        )
    except PageNotFound:
        return False
  1. rliterate2.py
  2. classes
  3. Document
def _move_page(self, source_meta, target_meta, target_index):
    if not self._can_move_page(source_meta, target_meta, target_index):
        return
    source_page = self.get(source_meta.path)
    operation_insert = (
        target_meta.path + ["children"],
        lambda children: (
            children[:target_index] +
            [source_page] +
            children[target_index:]
        )
    )
    operation_remove = (
        source_meta.parent.path + ["children"],
        lambda children: (
            children[:source_meta.index] +
            children[source_meta.index+1:]
        )
    )
    if target_meta.id == source_meta.parent.id:
        insert_first = target_index > source_meta.index
    else:
        insert_first = len(target_meta.path) > len(source_meta.parent.path)
    if insert_first:
        operations = [operation_insert, operation_remove]
    else:
        operations = [operation_remove, operation_insert]
    with self.transaction():
        for path, fn in operations:
            self.modify(path, fn)
        self._build_page_index()
  1. rliterate2.py
  2. classes
  3. Document
def _can_move_page(self, source_meta, target_meta, target_index):
    page_meta = target_meta
    while page_meta is not None:
        if page_meta.id == source_meta.id:
            return False
        page_meta = page_meta.parent
    if (target_meta.id == source_meta.parent.id and
        target_index in [source_meta.index, source_meta.index+1]):
        return False
    return True
  1. rliterate2.py
  2. classes
  3. Document
def edit_page(self, source_id, attributes, new_selection):
    try:
        with self.transaction():
            path = self._get_page_meta(source_id).path
            for key, value in attrib