RLiterate is a tool for authoring documents. You can think of it as a programmer's version of a word processor. This book describes RLiterate and also includes the complete implementation presented in small pieces.
Screenshot of RLiterate editing itself.
This chapter gives an overview of what types of document you can create with RLiterate and presents the main features of the program.
The main GUI consists of the table of contents and the workspace.
The main GUI window.
The table of contents shows the outline of the document, and from it, pages can be opened in the workspace. Each page is drawn with a border in the workspace.
RLiterate is not only a tool for authoring documents, but also a tool for reading documents. The following features support that.
This chapter gives practical advice for using RLiterate on your computer.
RLiterate is currently a prototype. Experimentation is encouraged, but it may have serious bugs.
The source code for RLiterate is in a Git repository hosted on Github:
https://github.com/rickardlindberg/rliterate
Assuming you have Git installed, you can clone the repository like this:
git clone https://github.com/rickardlindberg/rliterate.git
Before you can run RLiterate, you need Python with the following libraries installed:
Here is how they can be installed on a Fedora system:
dnf install python wxPython python2-pygments
Once they are installed, you can run RLiterate like this:
python rliterate.py rliterate.rliterate
This will open the RLiterate document. Enter a different filename as last parameter to create a new document.
When *.rliterate
files are version controlled, the textual diff is hard to read. This problem can be sovled in Git by defining a textconv command that converts the *.rliterate
file to text that is suitable for diffing. The --diff
option to RLiterate outputs a file in a diff friendly format.
First associate *.rliterate
files with rliterate
using a Git attributes file. For example in $HOME/.config/git/attributes
:
*.rliterate diff=rliterate
Then define the textconv
command in your git config ($HOME/.gitconfig
):
[diff "rliterate"] textconv=bash -c 'python $RLITERATE_ROOT/rliterate.py "$0" --diff'
RLiterate documents have pages organized in a hierarchy. Pages have a title and paragraphs. Paragraphs can be of different types. RLiterate documents can also be exported to different formats for display in different mediums.
The different paragraph types is what make RLiterate documents special. Code paragraphs for example enable literate programming by allowing chunks of code to be defined and then be automatically assembled into the final source file. Text paragraphs are used for writing prose.
TODO: Explain how code paragraphs enable literate programming.
Hoisting a page in the table of contents allows you to focus on a subset of the document.
Page 1 hoisted.
Openining a page and all immediate children allows you to read a subset of the document breath first. It's like reading only the first section of each chapter in an entire book.
Demo page with its children opened. Notice that Sub page is not an immediate child and therefore not shown.
This chapter explains how RLiterate came about.
I started to think about what would become RLiterate when I read the paper Active Essays on the Web. In it they talk about embedding code in documents that the reader can interact with:
An Active Essay combines a written essay, program fragments, and the resulting live simulations into a single cohesive narrative.
They also mention literate programming as having a related goal of combining literature and programming.
At the time I was working on a program that I thought would be nice to write in this way. I wanted to write an article about the program and have the code for the program embedded in the article. I could have used a literate programming tool for this, but the interactive aspect of active essays made me think that a tool would be much more powerful if the document could be edited "live", similar to WYSIWYG editors. Literate programming tools I were aware of worked by editing plain text files with a special syntax for code and documentation blocks, thus lacking the interactive aspect.
To me, the most central idea in literate programming is that we 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 we 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 us 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, we 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:
So the tool I envisoned was one that would allow literate programming and also allow more interaction with the content.
So I decided to build a prototype to learn what such a tool might be like.
First I came up with a document model where pages were organized in a hierarchy. Each page had paragraphs that could be of different types. This idea was stolen from Smallest Federated Wiki. The code paragraph would allow for literate programming, while the text paragraph would be for prose. I also envisioned other paragraph types that would allow for more interaction. Perhaps one paragraph type could be Graphviz code, and when edited, a generated graph would appear instead of the code.
After coming up with a document model, I implement a GUI that would allow editing such documents. This GUI had to be first class as it would be the primary way author (and read) documents.
The first version of the GUI was not first class though. I started to experiment with a workspace for showing pages.
First GUI prototype of workspace.
Then I contintued with a table of contents. At this point it was based on a tree control present in wxPython.
First GUI prototype of table of contents. Workspace pages also have titles.
All the data so far had been static and hard coded. I started to flesh out the document model and implement GUI controls for manipulating the document. Here is the add button on pages.
Add button that creates the factory paragraph.
Drag and drop was implemented fairly early. I wanted good visual feedback. This was part of the first class idea.
Drag and drop with red divider line indicating drop point.
Drag and drop with good visual feedback was hard to achieve with the tree control from wxWidgets, so at this point I rewrote the table of contents widget as a custom widget.
Custom table of contents that allows drag and drop and custom rendering.
I added more operations for adding and deleting pages.
Context menu that allows adding and deleting pages.
Then finally I added a second paragraph type: the code paragraph type that would enable literate programming.
Code paragraphs showing literate programming in action.
At this point I had all the functionality in place for writing documents and embedding code in them. I imported all the code into an RLiterate document (previously it was written as a single Python file) and started extracting pieces and adding prose to explain the program. This was a tremendously rewarding experience. RLiterate was now bootstrapped.
Dogfeeding. I imporoved RLiterate inside RLiterate.
As I continued to improve the RLiterate document, I noticed features I was lacking. I added them to the program and iterated.
ProjecturED was an influence for introducing variables.
When researching ideas for RLiterate, I stumbled across ProjecturED. It is similar to RLiterate in the sense that it is an editor for more structured documents. Not just text. The most interesting aspect for me was that a variable name exists in one place, but can be rendered in multiple. So a rename is really simple. With RLiterate, you have to do a search and replace. But with ProjecturED you just change the name and it replicates everywhere. This is an attractive feature and is made possible by the different document model.
I started writing the article and noticed other features I was missing.
Started work on getting rid of modes.
Inspired by Alan Kay and Lary Tesler.
Successful experiment with no-modes editing of titles in dec 2018.
RLiterate works in modes: view and edit. This is in conflict with "no modes". ProjecturED is more direct. It can show multiple projections of the same data structure, but full direct editing is available in both projections. RLiterate can show two projections (view and edit) but only supports edits in one.
Support for more direct editing might be desierable. But I'm a Vim user, and I think modes are powerful. I need to investigate this area more. Can direct manipulation feel as "precise" as different modes? How can automatic save work if there is no explicit "edit ends"?
PDF by Lary Tesler about modes: A Personal History of Modeless Text Editing and Cut/Copy-Paste .
If modes must be used, it should be visually clearly indicated that you are in a mode.
On WYSIWYG by Alan Kay.
This chapter gives a complete description of the implementation of RLiterate presented in small pieces.
RLiterate is implemented in Python. The implementation consists of four files: one for a compiler of gui widgets, one for the program, one for the test code, and a Makefile for various operations.
# This file is extracted from rliterate.rliterate. # DO NOT EDIT MANUALLY!
# This file is extracted from rliterate.rliterate. # DO NOT EDIT MANUALLY! <<imports>> <<constants>> <<decorators>> <<base base base classes>> <<base base classes>> <<base classes>> <<classes>> <<functions>> if __name__ == "__main__": <<entry point>>
# This file is extracted from rliterate.rliterate. # DO NOT EDIT MANUALLY! <<imports>> <<fixtures>> <<test cases>>
# This file is extracted from rliterate.rliterate. # DO NOT EDIT MANUALLY! <<rules>>
The remainder of this chapter will fill in the sections in those files to create the complete implementation.
An RLiterate document is represented as a Python dictionary with the following stucture:
{ "root_page": { "id": "106af6f8665c45e8ab751993a6abc876", "title": "Root", "paragraphs": [ { "id": "8f0a9f84821540e89d7c9ca93ed0fbe7", "type": "text", "fragments": [ {"text": "This ", "type": "text" }, {"text": "is", "type": "strong" }, {"text": "cool", "type": "emphasis" } ] }, ... ], "children": [ ... ] }, "variables": { ... } }
It has a root page and variables. The root page has a unique id, a title, paragraphs, and child pages. Paragraphs also have unique ids and different attributes depending on type. (The text paragraph has a list of text fragments with different styles.)
The Document
class defined in this section provides a friendly interface for working with an RLiterate document.
@classmethod def from_file(cls, path): if os.path.exists(path): return cls(path, load_json_from_file(path)) else: return cls(path, new_document_dict())
def new_document_dict(): return { "root_page": new_page_dict(), "variables": {}, }
class Document(Observable): def __init__(self, path, document_dict): Observable.__init__(self) self.path = path self._load(document_dict) <<Document>>
def _load(self, document_dict): self._history = History( self._convert_to_latest(document_dict), size=UNDO_BUFFER_SIZE )
UNDO_BUFFER_SIZE = 10
@property def document_dict(self): return self._history.value
def save(self): write_json_to_file( self.path, self.document_dict )
There are only three ways in which a document should be modified:
`0397a216ff674867aa422a0778df2f44
` method@contextlib.contextmanager def transaction(self, name): with self.notify(): with self._history.new_value(name, modify_fn=lambda x: x): yield def modify(self, name, modify_fn): with self.notify(): with self._history.new_value(name, modify_fn=modify_fn): pass def get_undo_operation(self): def undo(): with self.notify(): self._history.back() if self._history.can_back(): return (self._history.back_name(), undo) def get_redo_operation(self): def redo(): with self.notify(): self._history.forward() if self._history.can_forward(): return (self._history.forward_name(), redo)
def new_page_dict(): return { "id": genid(), "title": "New page...", "children": [], "paragraphs": [], }
def get_page(self, page_id): for page in self.iter_pages(): if page.id == page_id: return page def iter_pages(self): def iter_pages(page): yield page for child in page.children: for sub_page in iter_pages(child): yield sub_page return iter_pages(self.get_root_page()) def get_root_page(self): return Page( self, ["root_page"], self.document_dict["root_page"], None, None )
class Page(DocumentFragment): def __init__(self, document, path, fragment, parent, index): DocumentFragment.__init__(self, document, path, fragment) self._parent = parent self._index = index <<Page>>
@property def parent(self): return self._parent @property def full_title(self): return " / ".join(page.title for page in self.chain) @property def chain(self): result = [] page = self while page is not None: result.insert(0, page) page = page.parent return result @property def depth(self): return len(self.chain) def iter_code_fragments(self): for paragraph in self.paragraphs: for fragment in paragraph.iter_code_fragments(): yield fragment def iter_text_fragments(self): for paragraph in self.paragraphs: for fragment in paragraph.iter_text_fragments(): yield fragment @property def id(self): return self._fragment["id"] @property def title(self): return self._fragment["title"] def set_title(self, title): self._document.modify("Change title", lambda document_dict: im_replace(document_dict, self._path+["title"], title) )
def delete(self): if self.parent is not None: self.parent.replace_child_at_index(self._index, self._fragment["children"])
def move(self, target_page, target_index): # Abort if invalid move page = target_page while page is not None: if page.id == self.id: return page = page.parent # Abort if no-op mode if target_page.id == self.parent.id and target_index in [self._index, self._index+1]: return # Do the move with self._document.transaction("Move page"): if target_page.id == self.parent.id: insert_first = target_index > self._index else: insert_first = target_page.depth > self.parent.depth if insert_first: target_page.insert_child_at_index(self._fragment, target_index) self.parent.replace_child_at_index(self._index, []) else: self.parent.replace_child_at_index(self._index, []) target_page.insert_child_at_index(self._fragment, target_index)
@property def paragraphs(self): return [ Paragraph.create( self._document, self._path+["paragraphs", index], paragraph_dict, index, self ) for index, paragraph_dict in enumerate(self._fragment["paragraphs"]) ]
def get_paragraph(self, paragraph_id): for paragraph in self.paragraphs: if paragraph.id == paragraph_id: return paragraph
def delete_paragraph_at_index(self, index): self._document.modify("Delete paragraph", lambda document_dict: im_modify( document_dict, self._path+["paragraphs"], lambda paragraphs: paragraphs[:index]+paragraphs[index+1:] ) )
def insert_paragraph_at_index(self, paragraph_dict, index): self._document.modify("Insert paragraph", lambda document_dict: im_modify( document_dict, self._path+["paragraphs"], lambda paragraphs: paragraphs[:index]+[paragraph_dict]+paragraphs[index:] ) )
@property def children(self): return [ Page( self._document, self._path+["children", index], child_dict, self, index ) for index, child_dict in enumerate(self._fragment["children"]) ]
def add_child(self): self.insert_child_at_index(new_page_dict(), len(self._fragment["children"]))
def insert_child_at_index(self, page_dict, index): self._document.modify("Insert page", lambda document_dict: im_modify( document_dict, self._path+["children"], lambda children: children[:index]+[page_dict]+children[index:] ) )
def replace_child_at_index(self, index, page_dicts): self._document.modify("Replace page", lambda document_dict: im_modify( document_dict, self._path+["children"], lambda children: children[:index]+page_dicts+children[index+1:] ) )
def add_paragraph(self, page_id, target_index=None, paragraph_dict={"type": "factory"}): page = self.get_page(page_id) if target_index is None: target_index = len(page.paragraphs) page.insert_paragraph_at_index( dict(paragraph_dict, id=genid()), target_index ) def get_paragraph(self, page_id, paragraph_id): return self.get_page(page_id).get_paragraph(paragraph_id)
class Paragraph(DocumentFragment): @staticmethod def create(document, path, paragraph_dict, index, page): return { "text": TextParagraph, "quote": QuoteParagraph, "list": ListParagraph, "code": CodeParagraph, "image": ImageParagraph, "expanded_code": ExpandedCodeParagraph, }.get(paragraph_dict["type"], Paragraph)(document, path, paragraph_dict, index, page) def __init__(self, document, path, paragraph_dict, index, page): DocumentFragment.__init__(self, document, path, paragraph_dict) self._index = index self._page = page @property def id(self): return self._fragment["id"] @property def type(self): return self._fragment["type"] @contextlib.contextmanager def multi_update(self): with self._document.transaction("Edit paragraph"): yield def update(self, data): self._document.modify("Edit paragraph", lambda document_dict: im_modify( document_dict, self._path, lambda paragraph: dict(paragraph, **data) ) ) def delete(self): self._page.delete_paragraph_at_index(self._index) def move(self, target_page, target_index): if target_page.id == self._page.id and target_index in [self._index, self._index+1]: return with self._document.transaction("Move paragraph"): if target_index > self._index: target_page.insert_paragraph_at_index(self._fragment, target_index) self._page.delete_paragraph_at_index(self._index) else: self._page.delete_paragraph_at_index(self._index) target_page.insert_paragraph_at_index(self._fragment, target_index) def duplicate(self): with self._document.transaction("Duplicate paragraph"): self._page.insert_paragraph_at_index( dict(copy.deepcopy(self._fragment), id=genid()), self._index+1 ) @property def filename(self): return "paragraph.txt" def iter_code_fragments(self): return iter([]) def iter_text_fragments(self): return iter([]) def insert_paragraph_before(self, **kwargs): self._document.add_paragraph( self._page.id, target_index=self._index, **kwargs ) def insert_paragraph_after(self, **kwargs): self._document.add_paragraph( self._page.id, target_index=self._index+1, **kwargs )
class TextParagraph(Paragraph): @property def fragments(self): return TextFragment.create_list( self._document, self._path+["fragments"], self._fragment["fragments"] ) @property def tokens(self): return [x.token for x in self.fragments] def get_text_index(self, index): return self._text_version.get_selection(index)[0] @property def text_version(self): return self._text_version.text @property def _text_version(self): text_version = TextVersion() for fragment in self.fragments: fragment.fill_text_version(text_version) return text_version @text_version.setter def text_version(self, value): self.update({"fragments": text_to_fragments(value)}) def iter_text_fragments(self): return iter(self.fragments)
def fragments_to_text(fragments): text_version = TextVersion() for fragment in fragments: fragment.fill_text_version(text_version) return text_version.text def text_to_fragments(text): return TextParser().parse(text)
class TextParser(object): SPACE_RE = re.compile(r"\s+") PATTERNS = [ ( re.compile(r"\*\*(.+?)\*\*", flags=re.DOTALL), lambda parser, match: { "type": "strong", "text": match.group(1), } ), ( re.compile(r"\*(.+?)\*", flags=re.DOTALL), lambda parser, match: { "type": "emphasis", "text": match.group(1), } ), ( re.compile(r"``(.+?)``", flags=re.DOTALL), lambda parser, match: { "type": "variable", "id": match.group(1), } ), ( re.compile(r"`(.+?)`", flags=re.DOTALL), lambda parser, match: { "type": "code", "text": match.group(1), } ), ( re.compile(r"\[\[(.+?)(:(.+?))?\]\]", flags=re.DOTALL), lambda parser, match: { "type": "reference", "text": match.group(3), "page_id": match.group(1), } ), ( re.compile(r"\[(.*?)\]\((.+?)\)", flags=re.DOTALL), lambda parser, match: { "type": "link", "text": match.group(1), "url": match.group(2), } ), ] def parse(self, text): text = self._normalise_space(text) fragments = [] partial = "" while text: result = self._get_special_fragment(text) if result is None: partial += text[0] text = text[1:] else: match, fragment = result if partial: fragments.append({"type": "text", "text": partial}) partial = "" fragments.append(fragment) text = text[match.end(0):] if partial: fragments.append({"type": "text", "text": partial}) return fragments def _normalise_space(self, text): return self.SPACE_RE.sub(" ", text).strip() def _get_special_fragment(self, text): for pattern, fn in self.PATTERNS: match = pattern.match(text) if match: return match, fn(self, match)
class QuoteParagraph(TextParagraph): pass
class ListParagraph(Paragraph): @property def child_type(self): return self._fragment["child_type"] @property def children(self): return [ ListItem(self._document, self._path+["children", index], x) for index, x in enumerate(self._fragment["children"]) ] def get_text_index(self, list_and_fragment_index): return self._text_version.get_selection(list_and_fragment_index)[0] @property def text_version(self): return self._text_version.text @property def _text_version(self): def list_item_to_text(text_version, child_type, item, indent=0, index=0): text_version.add(" "*indent) if child_type == "ordered": text_version.add("{}. ".format(index+1)) else: text_version.add("* ") for fragment in item.fragments: fragment.fill_text_version(text_version) text_version.add("\n") for index, child in enumerate(item.children): with text_version.index(index): list_item_to_text(text_version, item.child_type, child, index=index, indent=indent+1) text_version = TextVersion() for index, child in enumerate(self.children): with text_version.index(index): list_item_to_text(text_version, self.child_type, child, index=index) return text_version @text_version.setter def text_version(self, value): child_type, children = ListParser(value).parse_items() self.update({ "child_type": child_type, "children": children }) def iter_text_fragments(self): for item in self.children: for fragment in item.iter_text_fragments(): yield fragment
class ListItem(DocumentFragment): @property def fragments(self): return TextFragment.create_list( self._document, self._path+["fragments"], self._fragment["fragments"] ) @property def child_type(self): return self._fragment["child_type"] @property def children(self): return [ ListItem(self._document, self._path+["children", index], x) for index, x in enumerate(self._fragment["children"]) ] @property def tokens(self): return [x.token for x in self.fragments] def iter_text_fragments(self): for fragment in self.fragments: yield fragment for child in self.children: for fragment in child.iter_text_fragments(): yield fragment
class ListParser(object): ITEM_START_RE = re.compile(r"( *)([*]|\d+[.]) (.*)") def __init__(self, text): self.lines = text.strip().split("\n") def parse_items(self, level=0): items = [] list_type = None while True: type_and_item = self.parse_item(level) if type_and_item is None: return list_type, items else: item_type, item = type_and_item if list_type is None: list_type = item_type items.append(item) def parse_item(self, level): parts = self.consume_bodies() next_level = level + 1 item_type = None if self.lines: match = self.ITEM_START_RE.match(self.lines[0]) if match: matched_level = len(match.group(1)) if matched_level >= level: parts.append(match.group(3)) self.lines.pop(0) parts.extend(self.consume_bodies()) next_level = matched_level + 1 if "*" in match.group(2): item_type = "unordered" else: item_type = "ordered" if parts: child_type, children = self.parse_items(next_level) return (item_type, { "fragments": TextParser().parse(" ".join(parts)), "children": children, "child_type": child_type, }) def consume_bodies(self): bodies = [] while self.lines: if self.ITEM_START_RE.match(self.lines[0]): break else: bodies.append(self.lines.pop(0)) return bodies
class CodeParagraph(Paragraph): <<CodeParagraph>>
@property def path(self): return Path( [x for x in self._fragment["filepath"] if x], [x for x in self._fragment["chunkpath"] if x] ) @path.setter def path(self, path): self.update({ "filepath": copy.deepcopy(path.filepath), "chunkpath": copy.deepcopy(path.chunkpath), }) @property def filepath(self): return self._fragment["filepath"] @filepath.setter def filepath(self, filepath): self.update({ "filepath": filepath, }) @property def chunkpath(self): return self._fragment["chunkpath"] @chunkpath.setter def chunkpath(self, chunkpath): self.update({ "chunkpath": chunkpath, })
@property def filename(self): return self.path.filename
class Path(object): @classmethod def from_text_version(cls, text): try: filepath_text, chunkpath_text = text.split(" // ", 1) except: filepath_text = text chunkpath_text = "" return cls( filepath_text.split("/") if filepath_text else [], chunkpath_text.split("/") if chunkpath_text else [], ) @property def text_version(self): if self.has_both(): sep = " // " else: sep = "" return "{}{}{}".format( "/".join(self.filepath), sep, "/".join(self.chunkpath) ) @property def text_start(self): return self.text_end - len(self.last) @property def text_end(self): return len(self.text_version) def extend_chunk(self, chunk): return Path( copy.deepcopy(self.filepath), copy.deepcopy(self.chunkpath)+copy.deepcopy(chunk) ) @property def filename(self): return self.filepath[-1] if self.filepath else "" @property def last(self): if len(self.chunkpath) > 0: return self.chunkpath[-1] elif len(self.filepath) > 0: return self.filepath[-1] else: return "" @property def is_empty(self): return self.length == 0 @property def length(self): return len(self.chunkpath) + len(self.filepath) def __init__(self, filepath, chunkpath): self.filepath = filepath self.chunkpath = chunkpath def __eq__(self, other): return ( isinstance(other, Path) and self.filepath == other.filepath and self.chunkpath == other.chunkpath ) def __ne__(self, other): return not (self == other) def is_prefix(self, other): if len(self.chunkpath) > 0: return self.filepath == other.filepath and self.chunkpath == other.chunkpath[:len(self.chunkpath)] else: return self.filepath == other.filepath[:len(self.filepath)] def has_both(self): return len(self.filepath) > 0 and len(self.chunkpath) > 0 @property def filepaths(self): for index in range(len(self.filepath)): yield ( self.filepath[index], Path(self.filepath[:index+1], []) ) @property def chunkpaths(self): for index in range(len(self.chunkpath)): yield ( self.chunkpath[index], Path(self.filepath[:], self.chunkpath[:index+1]) )
def rename_path(self, path, name): with self.transaction("Rename path"): for p in self._code_paragraph_iterator(): filelen = len(p.path.filepath) chunklen = len(p.path.chunkpath) if path.is_prefix(p.path): if path.length > filelen: self.modify("", lambda document_dict: im_replace( document_dict, p._path+["chunkpath", path.length-1-filelen], name )) else: self.modify("", lambda document_dict: im_replace( document_dict, p._path+["filepath", path.length-1], name )) else: for f in p.fragments: if f.type == "chunk": if path.is_prefix(Path(p.path.filepath, p.path.chunkpath+f.path)): self.modify("", lambda document_dict: im_replace( document_dict, f._path+["path", path.length-1-filelen-chunklen], name )) def _code_paragraph_iterator(self): for page in self.iter_pages(): for p in page.paragraphs: if p.type == "code": yield p
@property def fragments(self): return CodeFragment.create_list( self._document, self._path+["fragments"], self, self._fragment["fragments"] ) def iter_code_fragments(self): return iter(self.fragments) @property def body_data(self): data = { "fragments": copy.deepcopy(self._fragment["fragments"]), "ids": {}, } for fragment in self.fragments: if fragment.type == "variable": data["ids"][fragment.id] = fragment.name return data
class CodeFragment(DocumentFragment): @staticmethod def create_list(document, path, code_paragraph, code_fragment_dicts): return [ CodeFragment.create(document, path+[index], code_paragraph, code_fragment_dict) for index, code_fragment_dict in enumerate(code_fragment_dicts) ] @staticmethod def create(document, path, code_paragraph, code_fragment_dict): return { "variable": VariableCodeFragment, "chunk": ChunkCodeFragment, "code": CodeCodeFragment, "tabstop": TabstopCodeFragment, }.get(code_fragment_dict["type"], CodeFragment)(document, path, code_paragraph, code_fragment_dict) def __init__(self, document, path, code_paragraph, code_fragment_dict): DocumentFragment.__init__(self, document, path, code_fragment_dict) self._code_paragraph = code_paragraph self._code_fragment_dict = code_fragment_dict @property def type(self): return self._code_fragment_dict["type"]
They can be converted to text for editing:
@property def text_version(self): self._variable_map = {} text_version = TextVersion() for fragment in self.fragments: fragment.fill_text_version(text_version) return text_version.text def add_variable_map(self, name, id_): entry = name index = 1 while entry in self._variable_map and self._variable_map[entry] != id_: entry = "{}{}".format(name, index) index += 1 self._variable_map[entry] = id_ return entry
And converted back again from text:
@text_version.setter def text_version(self, value): self.update({ "fragments": self._parse(value) }) def _parse(self, value): self._parsed_fragments = [] self._parse_buffer = "" for line in value.splitlines(): match = re.match(self._chunk_fragment_re(), line) if match: self._parse_clear() body_match = re.match(r"^(.*?)(, blank_lines_before=(\d+))?$", match.group(2)) if body_match.group(2): blank_lines_before = int(body_match.group(3)) else: blank_lines_before = 0 self._parsed_fragments.append({ "type": "chunk", "path": body_match.group(1).split("/"), "prefix": match.group(1), "blank_lines_before": blank_lines_before, }) else: while line: variable_match = re.match(self._variable_fragment_re(), line) tabstop_match = re.match(self._tabstop_fragment_re(), line) if variable_match: self._parse_clear() self._parsed_fragments.append({ "type": "variable", "id": self._get_variable_id(variable_match.group(1)) }) line = line[len(variable_match.group(0)):] elif tabstop_match: self._parse_clear() self._parsed_fragments.append({ "type": "tabstop", "index": int(tabstop_match.group(1)) }) line = line[len(tabstop_match.group(0)):] else: self._parse_buffer += line[0] line = line[1:] self._parse_buffer += "\n" self._parse_clear() return self._parsed_fragments def _get_variable_id(self, identifier): if identifier in self._variable_map: return self._variable_map[identifier] elif self._document.lookup_variable(identifier) is None: return self._document.new_variable(identifier) else: return identifier def _parse_clear(self): if self._parse_buffer: self._parsed_fragments.append({"type": "code", "text": self._parse_buffer}) self._parse_buffer = "" def _chunk_fragment_re(self): start, end = self.chunk_delimiters return r"^(\s*){}(.*){}\s*$".format(re.escape(start), re.escape(end)) def _variable_fragment_re(self): start, end = self.variable_delimiters return r"{}(.*?){}".format(re.escape(start), re.escape(end)) def _tabstop_fragment_re(self): start, end = self.chunk_delimiters return r"{}{{(\d+)}}{}".format(re.escape(start), re.escape(end))
class VariableCodeFragment(CodeFragment): @property def id(self): return self._code_fragment_dict["id"] @property def name(self): name = self._document.lookup_variable(self.id) if name is None: return self.id else: return name def fill_text_version(self, text_version): start, end = self._code_paragraph.variable_delimiters text_version.add(start) text_version.add(self._code_paragraph.add_variable_map(self.name, self.id)) text_version.add(end)
class ChunkCodeFragment(CodeFragment): @property def prefix(self): return self._code_fragment_dict["prefix"] @property def blank_lines_before(self): return self._code_fragment_dict.get("blank_lines_before", 0) @property def path(self): return copy.deepcopy(self._code_fragment_dict["path"]) def fill_text_version(self, text_version, for_view=False): start, end = self._code_paragraph.chunk_delimiters text_version.add(self.prefix) text_version.add(start) text_version.add("/".join(self.path)) if self.blank_lines_before > 0 and not for_view: text_version.add(", blank_lines_before=") text_version.add(str(self.blank_lines_before)) text_version.add(end) text_version.add("\n")
class CodeCodeFragment(CodeFragment): @property def text(self): return self._code_fragment_dict["text"] @property def text_version(self): text_version = TextVersion() self.fill_text_version(text_version) return text_version.text def fill_text_version(self, text_version): text_version.add(self.text)
class TabstopCodeFragment(CodeFragment): @property def index(self): return self._code_fragment_dict["index"] def fill_text_version(self, text_version): start, end = self._code_paragraph.chunk_delimiters text_version.add(start) text_version.add("{") text_version.add(str(self.index)) text_version.add("}") text_version.add(end)
@property def tokens(self): chain = CharChain() for fragment in self.fragments: if fragment.type == "chunk": text_version = TextVersion() fragment.fill_text_version(text_version, for_view=True) chain.append( text_version.text, token_type=TokenType.Comment.Preproc, subpath=self.path.extend_chunk(fragment.path) ) elif fragment.type == "variable": chain.append(fragment.name, variable=fragment.id) elif fragment.type == "code": chain.append(fragment.text) elif fragment.type == "tabstop": chain.mark_tabstop(fragment.index) chain.align_tabstops() chain.colorize(self.pygments_lexer) return chain.to_tokens()
class CharChain(object): def __init__(self): self.head = None self.tail = None self.tabstops = defaultdict(list) def __iter__(self): char = self.head while char is not None: yield char char = char.next def mark_tabstop(self, index): self.tabstops[index].append(self.tail) def align_tabstops(self): align_length = 0 for index in sorted(self.tabstops.keys()): align_length = max([align_length+1]+[ char.count_chars_to_start_of_line() for char in self.tabstops[index] ]) for char in self.tabstops[index]: x = range(align_length - char.count_chars_to_start_of_line()) for _ in x: self._insert_after(char, Char(" ", {})) def append(self, string, **meta): for char in string: self._insert_after(self.tail, Char(char, dict(meta))) def to_tokens(self): return [Token(char.value, **char.meta) for char in self] def colorize(self, pygments_lexer): pygments_text = self._extract_non_colored_text() pygments_tokens = pygments_lexer.get_tokens(pygments_text) self._apply_pygments_tokens(pygments_tokens) def _extract_non_colored_text(self): return "".join( char.value for char in self if char.meta.get("token_type") is None ) def _apply_pygments_tokens(self, pygments_tokens): char = self.head for pygments_token, text in pygments_tokens: for _ in text: while char is not None and char.meta.get("token_type") is not None: char = char.next if char is not None: char.meta["token_type"] = pygments_token char = char.next def _insert_after(self, before, char): if self.tail is None: self.head = char self.tail = char else: char.next = before.next char.previuos = before if before.next is not None: before.next.previuos = char before.next = char if before is self.tail: self.tail = char class Char(object): def __init__(self, value, meta): self.previuos = None self.next = None self.value = value self.meta = meta def count_chars_to_start_of_line(self): count = 0 char = self while char is not None and not char.is_newline(): char = char.previuos count += 1 return count def is_newline(self): return self.value == "\n"
@property def language(self): if self.raw_language: return self.raw_language else: return "".join(self.pygments_lexer.aliases[:1]) @property def raw_language(self): return self._fragment.get("language", "") @raw_language.setter def raw_language(self, value): self.update({ "language": value, }) @property def pygments_lexer(self): try: if self.raw_language: return pygments.lexers.get_lexer_by_name( self.raw_language, stripnl=False ) else: return pygments.lexers.get_lexer_for_filename( self.filename, stripnl=False ) except: return pygments.lexers.TextLexer(stripnl=False)
@property def chunk_delimiters(self): return ("<<", ">>")
@property def variable_delimiters(self): return ( "__RL_", "__" )
@property def post_process(self): return self._fragment.get("post_process", []) @post_process.setter def post_process(self, value): self.update({ "post_process": value, })
class ImageParagraph(Paragraph): @property def fragments(self): return TextFragment.create_list( self._document, self._path+["fragments"], self._fragment["fragments"] ) @property def tokens(self): return [x.token for x in self.fragments] @property def image_base64(self): return self._fragment.get("image_base64", None) @property def text_version(self): return fragments_to_text(self.fragments) @text_version.setter def text_version(self, value): self.update({"fragments": text_to_fragments(value)}) def iter_text_fragments(self): return iter(self.fragments)
class ExpandedCodeParagraph(Paragraph): @property def tokens(self): paragraph, chain = CodeExpander(self._document).expand_id(self.code_id) if paragraph is not None: chain.colorize(paragraph.pygments_lexer) return chain.to_tokens() @property def code_id(self): return self._fragment.get("code_id")
class TextFragment(DocumentFragment): @staticmethod def create_list(document, path, text_fragment_dicts): return [ TextFragment.create(document, path+[index], text_fragment_dict, index) for index, text_fragment_dict in enumerate(text_fragment_dicts) ] @staticmethod def create(document, path, text_fragment_dict, index): return { "strong": StrongTextFragment, "emphasis": EmphasisTextFragment, "code": CodeTextFragment, "variable": VariableTextFragment, "reference": ReferenceTextFragment, "link": LinkTextFragment, }.get(text_fragment_dict["type"], TextFragment)(document, path, text_fragment_dict, index) def __init__(self, document, path, text_fragment_dict, index): DocumentFragment.__init__(self, document, path, text_fragment_dict) self._index = index @property def type(self): return self._fragment["type"] @property def text(self): return self._fragment["text"] @text.setter def text(self, value): self._document.modify("Edit text", lambda document_dict: im_modify( document_dict, self._path, lambda fragment: dict(fragment, text=value) ) ) @property def token(self): return Token(self.text, fragment_index=self._index) def fill_text_version(self, text_version): text_version.add_with_index(self.text, self._index)
class StrongTextFragment(TextFragment): @property def token(self): return Token(self.text, token_type=TokenType.RLiterate.Strong, fragment_index=self._index) def fill_text_version(self, text_version): text_version.add("**") text_version.add_with_index(self.text, self._index) text_version.add("**")
class EmphasisTextFragment(TextFragment): @property def token(self): return Token(self.text, token_type=TokenType.RLiterate.Emphasis, fragment_index=self._index) def fill_text_version(self, text_version): text_version.add("*") text_version.add_with_index(self.text, self._index) text_version.add("*")
class CodeTextFragment(TextFragment): @property def token(self): return Token(self.text, token_type=TokenType.RLiterate.Code, fragment_index=self._index) def fill_text_version(self, text_version): text_version.add("`") text_version.add_with_index(self.text, self._index) text_version.add("`")
class VariableTextFragment(TextFragment): @property def id(self): return self._fragment["id"] @property def name(self): name = self._document.lookup_variable(self.id) if name is None: return self.id else: return name text = name def fill_text_version(self, text_version): text_version.add("``") text_version.add_with_index(self.id, self._index) text_version.add("``") @property def token(self): return Token(self.name, token_type=TokenType.RLiterate.Variable, fragment_index=self._index)
class ReferenceTextFragment(TextFragment): @property def page_id(self): return self._fragment["page_id"] @property def title(self): if self.text: return self.text if self._document.get_page(self.page_id) is not None: return self._document.get_page(self.page_id).title return self.page_id @property def token(self): return Token(self.title, token_type=TokenType.RLiterate.Reference, page_id=self.page_id, fragment_index=self._index) def fill_text_version(self, text_version): text_version.add("[[") text_version.add_with_index(self.page_id, self._index) if self.text: text_version.add(":") text_version.add(self.text) text_version.add("]]")
class LinkTextFragment(TextFragment): @property def url(self): return self._fragment["url"] @url.setter def url(self, value): self._document.modify("Edit url", lambda document_dict: im_modify( document_dict, self._path, lambda fragment: dict(fragment, url=value) ) ) @property def title(self): if self.text: return self.text return self.url @property def token(self): return Token(self.title, token_type=TokenType.RLiterate.Link, url=self.url, fragment_index=self._index) def fill_text_version(self, text_version): text_version.add("[") text_version.add_with_index(self.text, self._index) text_version.add("]") text_version.add("(") text_version.add(self.url) text_version.add(")")
def new_variable(self, name): variable_id = genid() self.modify("New variable", lambda document_dict: im_replace( document_dict, ["variables"], dict(document_dict["variables"], **{variable_id: name}) ) ) return variable_id def rename_variable(self, variable_id, name): self.modify("Rename variable", lambda document_dict: im_replace( document_dict, ["variables", variable_id], name ) ) def lookup_variable(self, variable_id): return self.document_dict["variables"].get(variable_id)
class DocumentFragment(object): def __init__(self, document, path, fragment): self._document = document self._path = path self._fragment = fragment
def im_replace(obj, path, new_value): return im_modify(obj, path, lambda x: new_value)
def im_modify(obj, path, modify_fn): if path: if isinstance(obj, list): new_obj = list(obj) elif isinstance(obj, dict): new_obj = dict(obj) else: raise ValueError("unknown type") new_obj[path[0]] = im_modify(new_obj[path[0]], path[1:], modify_fn) return new_obj return modify_fn(obj)
The JSON structure of an RLiterate document changes over time. If a document with an old structure is opened, it is automatically converted to the current structure.
def _convert_to_latest(self, document_dict): for fn in [ <<legacy handlers>> ]: document_dict = fn(document_dict) return document_dict
Paragraphs that contain inline text used to store it with special syntax like this:
INLINE_TEXT = "This **is** very *cool*. [[106af6f8665c45e8ab751993a6abc876:page]] is it. Some `code`. [link](http://example.com)."
This should be converted to fragments:
FRAGMENTS = [ {"type": "text", "text": "This "}, {"type": "strong", "text": "is"}, {"type": "text", "text": " very "}, {"type": "emphasis", "text": "cool"}, {"type": "text", "text": ". "}, {"type": "reference", "text": "page", "page_id": "106af6f8665c45e8ab751993a6abc876"}, {"type": "text", "text": " is it. Some "}, {"type": "code", "text": "code"}, {"type": "text", "text": ". "}, {"type": "link", "text": "link", "url": "http://example.com"}, {"type": "text", "text": "."}, ]
This conversion needs to be done for paragraph types text, quote, image, and list. In addition, the list paragraph used to store the whole list in the text attribute. In order to convert list item text to fragments, the list structure also needs to change.
Here is how text paragraphs change:
TEXT_OLD = {"id": "abc1", "type": "text", "text": INLINE_TEXT} TEXT_NEW = {"id": "abc1", "type": "text", "fragments": FRAGMENTS}
Here is how quote paragraphs change:
QUOTE_OLD = {"id": "abc2", "type": "quote", "text": INLINE_TEXT} QUOTE_NEW = {"id": "abc2", "type": "quote", "fragments": FRAGMENTS}
Here is how image paragraphs change:
IMAGE_OLD = {"id": "abc3", "type": "image", "image_base64": "data", "text": INLINE_TEXT} IMAGE_NEW = {"id": "abc3", "type": "image", "image_base64": "data", "fragments": FRAGMENTS}
Here is how list paragraphs change:
LIST_OLD = { "id": "abc4", "type": "list", "text": "\n".join([ "* **a**{}".format(INLINE_TEXT), "* **b**{}".format(INLINE_TEXT), " 1. **c**{}".format(INLINE_TEXT), " 1. **d**{}".format(INLINE_TEXT), ]), } LIST_NEW = { "id": "abc4", "type": "list", "child_type": "unordered", "children": [ { "fragments": [{"type": "strong", "text": "a"}]+FRAGMENTS, "child_type": None, "children": [], }, { "fragments": [{"type": "strong", "text": "b"}]+FRAGMENTS, "child_type": "ordered", "children": [ { "fragments": [{"type": "strong", "text": "c"}]+FRAGMENTS, "child_type": None, "children": [], }, { "fragments": [{"type": "strong", "text": "d"}]+FRAGMENTS, "child_type": None, "children": [], }, ], }, ] }
Here is a test that ensures that the paragraphs are properly converted:
def test_converts_inline_text_to_fragments(tmpfile): old_paragraphs = [TEXT_OLD, QUOTE_OLD, IMAGE_OLD, LIST_OLD] new_paragraphs = [TEXT_NEW, QUOTE_NEW, IMAGE_NEW, LIST_NEW] document = write_read_document(tmpfile, { "title": "Root", "id": "abc1", "paragraphs": old_paragraphs, "children": [ { "title": "Child", "children": [], "id": "abc2", "paragraphs": old_paragraphs, } ], }) assert document["root_page"]["paragraphs"] == new_paragraphs assert document["root_page"]["children"][0]["paragraphs"] == new_paragraphs
Here is the implementation:
self._legacy_inline_text,
def _legacy_inline_text(self, document_dict): for paragraph in document_dict.get("paragraphs", []): if paragraph["type"] in ["text", "quote", "image"] and "text" in paragraph: paragraph["fragments"] = LegacyInlineTextParser().parse(paragraph["text"]) del paragraph["text"] elif paragraph["type"] in ["list"] and "text" in paragraph: paragraph["child_type"], paragraph["children"] = LegacyListParser(paragraph["text"]).parse_items() del paragraph["text"] for child in document_dict.get("children", []): self._legacy_inline_text(child) return document_dict
class LegacyInlineTextParser(object): SPACE_RE = re.compile(r"\s+") PATTERNS = [ ( re.compile(r"\*\*(.+?)\*\*", flags=re.DOTALL), lambda parser, match: { "type": "strong", "text": match.group(1), } ), ( re.compile(r"\*(.+?)\*", flags=re.DOTALL), lambda parser, match: { "type": "emphasis", "text": match.group(1), } ), ( re.compile(r"`(.+?)`", flags=re.DOTALL), lambda parser, match: { "type": "code", "text": match.group(1), } ), ( re.compile(r"\[\[(.+?)(:(.+?))?\]\]", flags=re.DOTALL), lambda parser, match: { "type": "reference", "text": match.group(3), "page_id": match.group(1), } ), ( re.compile(r"\[(.*?)\]\((.+?)\)", flags=re.DOTALL), lambda parser, match: { "type": "link", "text": match.group(1), "url": match.group(2), } ), ] def parse(self, text): text = self._normalise_space(text) fragments = [] partial = "" while text: result = self._get_special_fragment(text) if result is None: partial += text[0] text = text[1:] else: match, fragment = result if partial: fragments.append({"type": "text", "text": partial}) partial = "" fragments.append(fragment) text = text[match.end(0):] if partial: fragments.append({"type": "text", "text": partial}) return fragments def _normalise_space(self, text): return self.SPACE_RE.sub(" ", text).strip() def _get_special_fragment(self, text): for pattern, fn in self.PATTERNS: match = pattern.match(text) if match: return match, fn(self, match)
class LegacyListParser(object): ITEM_START_RE = re.compile(r"( *)([*]|\d+[.]) (.*)") def __init__(self, text): self.lines = text.strip().split("\n") def parse_items(self, level=0): items = [] list_type = None while True: type_and_item = self.parse_item(level) if type_and_item is None: return list_type, items else: item_type, item = type_and_item if list_type is None: list_type = item_type items.append(item) def parse_item(self, level): parts = self.consume_bodies() next_level = level + 1 item_type = None if self.lines: match = self.ITEM_START_RE.match(self.lines[0]) if match: matched_level = len(match.group(1)) if matched_level >= level: parts.append(match.group(3)) self.lines.pop(0) parts.extend(self.consume_bodies()) next_level = matched_level + 1 if "*" in match.group(2): item_type = "unordered" else: item_type = "ordered" if parts: child_type, children = self.parse_items(next_level) return (item_type, { "fragments": LegacyInlineTextParser().parse(" ".join(parts)), "children": children, "child_type": child_type, }) def consume_bodies(self): bodies = [] while self.lines: if self.ITEM_START_RE.match(self.lines[0]): break else: bodies.append(self.lines.pop(0)) return bodies
Code paragraphs used to have path and text attributes like this:
CODE_OLD = { "id": "abc1", "type": "code", "path": ["foo.py", "<<functions>>"], "text": "one\n<<chunk>>\ntwo\n", }
Those should be converted to filepath, chunkpath, and fragments attributes:
CODE_NEW = { "id": "abc1", "type": "code", "filepath": ["foo.py"], "chunkpath": ["functions"], "fragments": [ {"type": "code", "text": "one\n"}, {"type": "chunk", "prefix": "", "path": ["chunk"]}, {"type": "code", "text": "two\n"}, ], }
Here is a test that ensures that the conversion is done in all pages:
def test_converts_legacy_code_paragraphs(tmpfile): document = write_read_document(tmpfile, { "title": "Root", "children": [ { "title": "Child", "children": [], "id": "abc2", "paragraphs": [CODE_OLD], }, ], "id": "abc1", "paragraphs": [CODE_OLD], }) assert document["root_page"]["paragraphs"] == [CODE_NEW] assert document["root_page"]["children"][0]["paragraphs"] == [CODE_NEW]
The implementation:
self._legacy_inline_code,
def _legacy_inline_code(self, document_dict): for p in document_dict.get("paragraphs", []): if p["type"] == "code" and "path" in p: p["filepath"], p["chunkpath"] = split_legacy_path(p["path"]) del p["path"] if p["type"] == "code" and "text" in p: p["fragments"] = legacy_code_text_to_fragments(p["text"]) del p["text"] for child in document_dict.get("children", []): self._legacy_inline_code(child) return document_dict
More details about the path split function:
@pytest.mark.parametrize("path,expected_split", [ ( ["foo.py", "<<functions>>"], (["foo.py"], ["functions"]) ), ( ["foo.py", "<<Foo/__init__>>"], (["foo.py"], ["Foo", "__init__"]) ), ( ["foo.py", "<<functions>>", "<<with", "missing>>", "stuff/or"], (["foo.py"], ["functions", "with", "missing", "stuff", "or"]) ), ( ["foo.py", "<<with", "missing>>", "stuff/or"], (["foo.py", "<<with", "missing>>", "stuff", "or"], []) ), ( ["bar/foo.py"], (["bar", "foo.py"], []) ), ( [], ([], []) ), ]) def test_split_legacy_path(path, expected_split): assert split_legacy_path(path) == expected_split
def split_legacy_path(path): filepath = [] chunkpath = [] while path and not (path[0].startswith("<<") and path[0].endswith(">>")): filepath.extend(path.pop(0).split("/")) for chunk in path: if chunk.startswith("<<"): chunk = chunk[2:] if chunk.endswith(">>"): chunk = chunk[:-2] chunkpath.extend(chunk.split("/")) return filepath, chunkpath
More details about the text to fragments function:
@pytest.mark.parametrize("text,expected_fragments", [ ( "def foo()\n", [ {"type": "code", "text": "def foo()\n"}, ] ), ( "def foo()\n <<foo/pre>>\n return False", [ {"type": "code", "text": "def foo()\n"}, {"type": "chunk", "prefix": " ", "path": ["foo", "pre"]}, {"type": "code", "text": " return False\n"}, ] ), ]) def test_legacy_code_text_to_fragments(text, expected_fragments): assert legacy_code_text_to_fragments(text) == expected_fragments
def legacy_code_text_to_fragments(text): fragments = [] current_text = "" for line in text.splitlines(): match = re.match(r"^(\s*)<<(.*)>>\s*$", line) if match: if current_text: fragments.append({"type": "code", "text": current_text}) current_text = "" fragments.append({"type": "chunk", "path": match.group(2).split("/"), "prefix": match.group(1)}) else: current_text += line current_text += "\n" if current_text: fragments.append({"type": "code", "text": current_text}) return fragments
The root JSON object used to represent the root page. It changed to represent a document so that variables could also be stored somewhere.
def test_converts_legacy_root_document(tmpfile): root_page = { "title": "Root", "children": [], "id": "abc1", "paragraphs": [], } assert write_read_document(tmpfile, root_page) == { "root_page": root_page, "variables": {}, }
The implementation:
self._legacy_root_page,
def _legacy_root_page(self, document_dict): if "root_page" not in document_dict: return { "root_page": document_dict, "variables": {}, } else: return document_dict
The write_read_document
function is used in legacy tests to write a legacy document, read it, write it, and then ensure that the written is according to new structure.
def write_read_document(path, document): write_json_to_file(path, document) doc = Document.from_file(path) write_json_to_file(path, doc.document_dict) return load_json_from_file(path)
Different parts of a docunet requires unique ids. We use uuid and assume that we will never get collisions.
def genid(): return uuid.uuid4().hex
It is common that paragraphs are converted to text for editing. The edited text then needs to be parsed to convert it back to a "rich object". The TextVersion
class helps keep track of where items are rendered to the text version.
class TextVersion(object): def __init__(self): self.text = "" self.indicies = {} self._index_prefix = [] def get_selection(self, index): return self.indicies.get(index, (0, 0)) @contextlib.contextmanager def index(self, index): self._index_prefix.append(index) yield self._index_prefix.pop(-1) def add_with_index(self, text, index): start = len(self.text) end = start + len(text) self.indicies[tuple(self._index_prefix + [index])] = (start, end) self.add(text) def add(self, text): self.text += text
The UI model consists of classes that models the non-specific part of the GUI.
A project is a container for a few other objects:
class Project(Observable): def __init__(self, filepath): Observable.__init__(self) <<Project/__init__>> self.title="{} ({})".format( os.path.basename(filepath), os.path.dirname(os.path.abspath(filepath)) ) self.document = Document.from_file(filepath) self.document.listen(self.save) self.document.listen(self.notify_forwarder()) self.layout = Layout.from_file(os.path.join( os.path.dirname(filepath), ".{}.layout".format(os.path.basename(filepath)) )) self.layout.listen(self.notify_forwarder()) self.global_settings = GlobalSettings.from_file( os.path.join( wx.StandardPaths.Get().GetUserConfigDir(), ".rliterate.settings" ) ) self.global_settings.listen(self.notify_forwarder()) <<Project>>
@property def theme(self): return self.global_settings
Selection:
self._selection = Selection.empty()
@property def selection(self): return self._selection @selection.setter def selection(self, value): with self.notify(): self._selection = value
Wrapper methods for document:
def get_page(self, *args, **kwargs): return self.document.get_page(*args, **kwargs) def get_root_page(self, *args, **kwargs): return self.document.get_root_page(*args, **kwargs) def iter_pages(self, *args, **kwargs): return self.document.iter_pages(*args, **kwargs) def get_paragraph(self, *args, **kwargs): return self.document.get_paragraph(*args, **kwargs) def add_paragraph(self, *args, **kwargs): return self.document.add_paragraph(*args, **kwargs) def can_undo(self): return self.get_undo_operation() is not None def undo(self): self.get_undo_operation()[1]() def get_undo_operation(self, *args, **kwargs): return self.document.get_undo_operation(*args, **kwargs) def can_redo(self): return self.get_redo_operation() is not None def redo(self): self.get_redo_operation()[1]() def get_redo_operation(self, *args, **kwargs): return self.document.get_redo_operation(*args, **kwargs) def rename_path(self, *args, **kwargs): return self.document.rename_path(*args, **kwargs) def lookup_variable(self, *args, **kwargs): return self.document.lookup_variable(*args, **kwargs) def rename_variable(self, *args, **kwargs): return self.document.rename_variable(*args, **kwargs)
Wrapper methods for layout:
def toggle_collapsed(self, *args, **kwargs): return self.layout.toggle_collapsed(*args, **kwargs) def is_collapsed(self, *args, **kwargs): return self.layout.is_collapsed(*args, **kwargs) @property def columns(self): return self.layout.columns def is_open(self, *args, **kwargs): return self.layout.is_open(*args, **kwargs) def open_pages(self, *args, **kwargs): return self.layout.open_pages(*args, **kwargs) def can_back(self, *args, **kwargs): return self.layout.can_back(*args, **kwargs) def back(self, *args, **kwargs): return self.layout.back(*args, **kwargs) def can_forward(self, *args, **kwargs): return self.layout.can_forward(*args, **kwargs) def forward(self, *args, **kwargs): return self.layout.forward(*args, **kwargs) @property def hoisted_page(self): return self.layout.hoisted_page @hoisted_page.setter def hoisted_page(self, value): self.layout.hoisted_page = value
Wrapper for theme:
def get_style(self, *args, **kwargs): return SolarizedTheme().get_style(*args, **kwargs)
Misc:
self._highlighted_variable = None
@property def highlighted_variable(self): return self._highlighted_variable @highlighted_variable.setter def highlighted_variable(self, variable_id): with self.notify(): self._highlighted_variable = variable_id
self._save_status = {"text": "", "working": False} self._save_thread = SavingThread(self)
def save(self): self._save_thread.queue_save(self.document)
def wait_for_save(self): self._save_thread.wait_until_done()
@property def save_status(self): return self._save_status @save_status.setter def save_status(self, value): with self.notify(): self._save_status = value
class SavingThread(threading.Thread): def __init__(self, project): threading.Thread.__init__(self) self.project = project self.daemon = True self.queue = Queue.Queue() self.start() def queue_save(self, document): self.queue.put_nowait((document.path, document.document_dict)) def wait_until_done(self): self.queue.join() def run(self): while True: self._report("All saved!", working=False) path, document_dict = self.queue.get() self._report("Waiting...") while True: try: path, document_dict = self.queue.get(timeout=3) self.queue.task_done() except Queue.Empty: break self._report("Saving document...") document = Document(path, document_dict) document.save() self._report("Saving files...") CodeExpander(document).generate_files() self.queue.task_done() def _report(self, text, working=True): wx.CallAfter( setattr, self.project, "save_status", {"text": text, "working": working} )
class Selection(namedtuple("Selection", ["current", "trail"])): @classmethod def empty(self): return Selection(None, []) @property def present(self): return self.current is not None @property def value(self): if self.current is not None and len(self.current) == 1: return self.current[0] else: return self.current def get(self, key): if self.current is not None and len(self.current) > 0 and self.current[0] == key: new_current = self.current[1:] else: new_current = None return Selection(new_current, trail=self.trail+[key]) def create(self, value): return Selection(self.trail+[value], []) def create_this(self): return Selection(self.trail, [])
class GlobalSettings(JsonSettings): page_body_width = JsonSettings.property( "theme.page_body_width", 600 ) page_padding = JsonSettings.property( "theme.page_padding", 15 ) shadow_size = JsonSettings.property( "theme.shadow_size", 2 ) paragraph_space = JsonSettings.property( "theme.paragraph_space", 15 ) container_border = JsonSettings.property( "theme.container_border", 15 ) editor_font = JsonSettings.property( "theme.fonts.editor", {"monospace": True, "size": 9} ) toc_font = JsonSettings.property( "theme.fonts.toc", {"size": 10} ) title_font = JsonSettings.property( "theme.fonts.title", {"size": 16} ) code_font = JsonSettings.property( "theme.fonts.code", {"monospace": True} ) text_font = JsonSettings.property( "theme.fonts.text", {"size": 10} ) workspace_background = JsonSettings.property( "theme.workspace.background", "#cccccc" ) border_color = JsonSettings.property( "theme.border_color", "#aaaaaa" )
A layout knows the visual state of the program. It for example knows what pages are expanded/collapsed in the table of contents and what is shown in the workspace.
class Layout(JsonSettings): def __init__(self, *args, **kwargs): JsonSettings.__init__(self, *args, **kwargs) <<Layout/handle legacy settings dict>> <<Layout/__init__>> <<Layout>>
The hoisted page is stored in toc.hoisted_page_id
:
hoisted_page = JsonSettings.property( "toc.hoisted_page_id", None )
The collapsed pages are stored in toc.collapsed
:
def is_collapsed(self, page_id): return page_id in self.get("toc.collapsed", []) def toggle_collapsed(self, page_id): collapsed = self.get("toc.collapsed", []) if page_id in collapsed: collapsed.remove(page_id) else: collapsed.append(page_id) self.set("toc.collapsed", collapsed)
The pages displayed in the columns in the workspace are stored in workspace.columns
:
self._workspace_columns_history = History( self.columns, size=20 )
columns = JsonSettings.property( "workspace.columns", [] ) def open_pages(self, page_ids, column_index=None): with self._workspace_columns_history.new_value() as value: if column_index is None: column_index = len(self.columns) value[column_index:] = [page_ids[:]] self.columns = value def can_back(self): return self._workspace_columns_history.can_back() def back(self): self._workspace_columns_history.back() self.columns = self._workspace_columns_history.value def can_forward(self): return self._workspace_columns_history.can_forward() def forward(self): self._workspace_columns_history.forward() self.columns = self._workspace_columns_history.value def is_open(self, page_id): for column in self.columns: if page_id in column: return True return False
Scratch pages where replaced by columns.
def test_reads_legacy_scratch_pages(tmpfile): write_json_to_file(tmpfile, { "workspace": { "scratch": ["abc"], } }) layout = Layout.from_file(tmpfile) with layout.notify(): pass assert load_json_from_file(tmpfile)["workspace"] == { "columns": [ ["abc"], ] }
if "workspace" in self._settings_dict: workspace = self._settings_dict["workspace"] if "scratch" in workspace: if "columns" not in workspace: workspace["columns"] = [workspace["scratch"]] del workspace["scratch"]
Some parts of the application can be themed. Token types from pygments denote different things that can be styled.
class BaseTheme(object): def get_style(self, token_type): if token_type in self.styles: return self.styles[token_type] return self.get_style(token_type.parent)
class Style(namedtuple("Style", "foreground background bold underlined italic monospace")): @classmethod def create(cls, **kwargs): values = { "foreground": "#000000", "background": None, "bold": False, "underlined": False, "italic": False, "monospace": False, } values.update(kwargs) return cls(**values) def apply_to_wx_dc(self, dc, base_font, highlight=False): font = base_font if self.bold: font = font.Bold() if self.underlined: font = font.Underlined() if self.italic: font = font.Italic() if self.monospace: font = wx.Font( pointSize=font.GetPointSize(), family=wx.FONTFAMILY_TELETYPE, style=font.GetStyle(), weight=font.GetWeight(), underline=font.GetUnderlined(), ) dc.SetTextForeground(self.foreground) if self.background is None: dc.SetBackgroundMode(wx.TRANSPARENT) else: dc.SetTextBackground(self.background) dc.SetBackgroundMode(wx.SOLID) dc.SetFont(font) def highlight(self): return self._replace(foreground="#fcf4df", background="#b58900")
Here is a theme based on solarized. Mostly stolen from https://github.com/honza/solarized-pygments/blob/master/solarized.py.
class SolarizedTheme(BaseTheme): base03 = "#002b36" base02 = "#073642" base01 = "#586e75" base00 = "#657b83" base0 = "#839496" base1 = "#93a1a1" base2 = "#eee8d5" base3 = "#fdf6e3" yellow = "#b58900" orange = "#cb4b16" red = "#dc322f" magenta = "#d33682" violet = "#6c71c4" blue = "#268bd2" cyan = "#2aa198" green = "#859900" text = "#2e3436" empty = "#cccccc" styles = { TokenType: Style.create(foreground=base00), TokenType.Keyword: Style.create(foreground=green), TokenType.Keyword.Constant: Style.create(foreground=cyan), TokenType.Keyword.Declaration: Style.create(foreground=blue), TokenType.Keyword.Namespace: Style.create(foreground=orange), TokenType.Name.Builtin: Style.create(foreground=red), TokenType.Name.Builtin.Pseudo: Style.create(foreground=blue), TokenType.Name.Class: Style.create(foreground=blue), TokenType.Name.Decorator: Style.create(foreground=blue), TokenType.Name.Entity: Style.create(foreground=violet), TokenType.Name.Exception: Style.create(foreground=yellow), TokenType.Name.Function: Style.create(foreground=blue), TokenType.String: Style.create(foreground=cyan), TokenType.Number: Style.create(foreground=cyan), TokenType.Operator.Word: Style.create(foreground=green), TokenType.Comment: Style.create(foreground=base1), TokenType.Comment.Preproc: Style.create(foreground=magenta, bold=True), TokenType.RLiterate: Style.create(foreground=text), TokenType.RLiterate.Empty: Style.create(foreground=empty), TokenType.RLiterate.Emphasis: Style.create(foreground=text, italic=True), TokenType.RLiterate.Strong: Style.create(foreground=text, bold=True), TokenType.RLiterate.Code: Style.create(foreground=text, monospace=True), TokenType.RLiterate.Variable: Style.create(foreground=text, monospace=True, italic=True), TokenType.RLiterate.Link: Style.create(foreground=blue, underlined=True), TokenType.RLiterate.Reference: Style.create(foreground=blue, italic=True), TokenType.RLiterate.Path: Style.create(foreground=text, italic=True, bold=True), TokenType.RLiterate.Chunk: Style.create(foreground=magenta, bold=True), TokenType.RLiterate.Sep: Style.create(foreground=base1), TokenType.RLiterate.Working: Style.create(foreground=yellow), TokenType.RLiterate.Success: Style.create(foreground=green), }
The main GUI is written in wxPython. I chose it for two reasons:
frame MainFrameGui %vbox { #project #min_size = tuple([920 500]) #title = self._get_title() Toolbar( project = self.project main_frame = self %expand ) HBorder(project=self.project %expand) panel( %expand %proportion[1] ) %hbox { TableOfContents( project = self.project selection = self.project.selection.get("main_frame").get("toc") %expand ) VBorder(project=self.project %expand) Workspace( project = self.project selection = self.project.selection.get("main_frame").get("workspace") %expand %proportion[1] ) } @close = self._show_closing_dialog(event) }
class MainFrame(MainFrameGui): def _get_title(self): return "{} - RLiterate".format(project.title) def _create_gui(self): MainFrameGui._create_gui(self) self.set_keyboard_shortcuts([ { "key": "esc", "condition_fn": lambda: self.project.selection.present, "action_fn": lambda: setattr(self.project, "selection", Selection.empty()), }, { "ctrl": True, "key": "z", "condition_fn": lambda: self.project.can_undo(), "action_fn": lambda: self.project.undo(), }, { "ctrl": True, "key": "q", "action_fn": lambda: self.Close(), }, ]) @rltime_reset() @rltime("update main frame") def _update_gui(self): MainFrameGui._update_gui(self) def _show_closing_dialog(self, event): self.ShowModal(WaitDialog, { "project": self.project, "text": "Waiting for save...", }) event.Skip()
dialog WaitDialogGui %vbox { #project #text #title = self.text BoxSpace(self.BORDER) Label( label = self.text %border[self.BORDER, LEFT|BOTTOM|RIGHT] %align_center ) Gauge[gauge]( %border[self.BORDER, LEFT|BOTTOM|RIGHT] %align_center ) @timer = self.gauge.Pulse() @close = self._close(event) }
class WaitDialog(WaitDialogGui): BORDER = 8 def _create_gui(self): WaitDialogGui._create_gui(self) self.Fit() thread = threading.Thread(target=self._save) thread.start() self.timer = wx.Timer(self) self.timer.Start(100) self.done = False def _close(self, event): if self.done: event.Skip() else: event.Veto() def _save(self): self.project.wait_for_save() wx.CallAfter(setattr, self, "done", True) wx.CallAfter(self.Close)
panel ToolbarGui %hbox { #project #main_frame BoxSpace(self.BORDER) IconButton( icon = "back" tooltip = "Go back" enabled = self.project.can_back() @button = self.project.back() %border[self.BORDER, TOP|BOTTOM] %align_center_vertical ) IconButton( icon = "forward" tooltip = "Go forward" %border[self.BORDER, TOP|BOTTOM] enabled = self.project.can_forward() @button = self.project.forward() %align_center_vertical ) VBorder(project=self.project %border[self.BORDER, ALL] %expand) IconButton( icon = "undo" tooltip = "Undo" enabled = self.project.can_undo() @button = self.project.undo() %border[self.BORDER, TOP|BOTTOM] %align_center_vertical ) IconButton( icon = "redo" tooltip = "Redo" enabled = self.project.can_redo() @button = self.project.redo() %border[self.BORDER, TOP|BOTTOM] %align_center_vertical ) VBorder(project=self.project %border[self.BORDER, ALL] %expand) IconButton( icon = "quit" tooltip = "Quit RLiterate" @button = self.main_frame.Close() %border[self.BORDER, TOP|BOTTOM] %align_center_vertical ) panel(%proportion[1]) %hbox {} IconButton( icon = "save" tooltip = "Force save" @button = self.project.save() %border[self.BORDER, TOP|BOTTOM] %align_center_vertical ) TextProjection( characters = self._get_save_characters() %border[self.BORDER, TOP|BOTTOM] %align_center_vertical ) BoxSpace(10) }
class Toolbar(ToolbarGui): BORDER = 4 @rltime("update toolbar") def _update_gui(self): ToolbarGui._update_gui(self) def _get_save_characters(self): status = self.project.save_status return [ Character.create( x, self.project.get_style( TokenType.RLiterate.Working if status["working"] else TokenType.RLiterate.Success ) ) for x in status["text"] ]
The table of contents shows the outline of the document. It can show only subtrees (hoisting) and expand/collapse subtrees. It also provides functions to open pages in the workspace.
The table of contents widget lays out two components in a vertical container: the unhoist button and the page container.
panel TableOfContentsGui %vbox { #project #selection #min_size = tuple([250 -1]) #background = "#ffffff" Button( visible = self._has_hoisted_page() label = "unhoist" %expand %border[3,ALL] @button = setattr(self.project "hoisted_page" None) ) vscroll[page_container](%expand %proportion[1]) %vbox { for (self._get_rows()) { TableOfContentsRow( project = self.project page = loopvar.page selection = self.selection.get(loopvar.page.id) indentation = loopvar.indentation %expand ) Divider[dividers]( padding = 0 height = 2 row = loopvar %expand ) } } }
class TableOfContents(TableOfContentsGui): <<TableOfContents>>
def _create_gui(self): TableOfContentsGui._create_gui(self) <<_create_gui>> @rltime("update toc") def _update_gui(self): TableOfContentsGui._update_gui(self)
def _has_hoisted_page(self): return self._get_hoisted_page() is not None
def _get_rows(self): self.rows = [] self.drop_points = [] if self._get_hoisted_page() is None: self._flatten_page(self.values["project"].get_root_page()) else: self._flatten_page(self._get_hoisted_page()) return self.rows def _flatten_page(self, page, indentation=0): is_collapsed = self.values["project"].is_collapsed(page.id) row = TocRow(page=page, indentation=indentation) self.rows.append(row) if is_collapsed or len(page.children) == 0: target_index = len(page.children) else: target_index = 0 self.drop_points.append(TableOfContentsDropPoint( divider_fn=(lambda index: lambda: self.dividers[index])(len(self.rows)-1), indentation=indentation+1, target_page=page, target_index=target_index )) if not is_collapsed: for index, child in enumerate(page.children): row = self._flatten_page(child, indentation+1) self.drop_points.append(TableOfContentsDropPoint( divider_fn=(lambda index: lambda: self.dividers[index])(len(self.rows)-1), indentation=indentation+1, target_page=page, target_index=index+1 )) return row
def _get_hoisted_page(self): if self.values["project"].hoisted_page is None: return None else: return self.values["project"].get_page(self.values["project"].hoisted_page)
TocRow = namedtuple("TocRow", ["page", "indentation"])
class TableOfContentsDropPoint(object): def __init__(self, divider_fn, indentation, target_page, target_index): self.divider_fn = divider_fn self.indentation = indentation self.target_page = target_page self.target_index = target_index @property def divider(self): return self.divider_fn() def x_distance_to(self, x): left_padding = TableOfContentsButton.SIZE+1+TableOfContentsRow.BORDER span_x_center = left_padding + TableOfContentsRow.INDENTATION_SIZE * (self.indentation + 1.5) return abs(span_x_center - x) def y_distance_to(self, y): return abs(self.divider.Position.y + self.divider.Size[1]/2 - y) def Show(self): self.divider.Show(sum([ TableOfContentsRow.BORDER, TableOfContentsButton.SIZE, 1, self.indentation*TableOfContentsRow.INDENTATION_SIZE, ])) def Hide(self): self.divider.Hide()
Inside the table of contents, pages can be dragged and dropped. The drag is initiated in the row widget and handled in the table of contents widget.
self.SetDropTarget(TableOfContentsDropTarget(self, self.values["project"]))
class TableOfContentsDropTarget(DropPointDropTarget): def __init__(self, toc, project): DropPointDropTarget.__init__(self, toc, "page") self.project = project def OnDataDropped(self, dropped_page, drop_point): self.project.get_page(dropped_page["page_id"]).move( target_page=drop_point.target_page, target_index=drop_point.target_index )
The DropPointDropTarget
(Drop point drop target) requires FindClosestDropPoint
to be defined on the target object. Here it is:
def FindClosestDropPoint(self, screen_pos): client_pos = (client_x, client_y) = self.page_container.ScreenToClient(screen_pos) if self.page_container.HitTest(client_pos) == wx.HT_WINDOW_INSIDE: y_distances = defaultdict(list) for drop_point in self.drop_points: y_distances[drop_point.y_distance_to(client_y)].append(drop_point) if y_distances: return min( y_distances[min(y_distances.keys())], key=lambda drop_point: drop_point.x_distance_to(client_x) )
The row widget renders the page title at the appropriate indentation. If the page has children, an expand/collapse widget is also rendered to the left of the title.
panel TableOfContentsRowGui %hbox { #project #page #selection #indentation BoxSpace(self._indentation_size()) TableOfContentsButton( project = self.project page = self.page %border[self.BORDER, LEFT] %expand %reserve_space_even_if_hidden ) TextProjectionEditor[text]( projection = self._create_projection() %border[self.BORDER, ALL] @ctrl_click = self.text.Select(self.project event.Position) @mouse_move = self._set_cursor(event) ) @click = self._on_click_old(event) @right_click = self._on_right_click_old(event) @drag = self._on_drag_old(event) @enter = self._on_enter(event) @leave = self._on_leave(event) }
class TableOfContentsRow(TableOfContentsRowGui): BORDER = 2 INDENTATION_SIZE = 16 <<TableOfContentsRow>>
def _indentation_size(self): return self.indentation*self.INDENTATION_SIZE
def _create_projection(self): if hasattr(self, "_projection"): self._projection.project = self.project self._projection.page = self.page self._projection.selection = self.selection else: self._projection = TocTitleProjection(self.project, self.page, self.selection) return self._projection
def _set_cursor(self, event): if event.GetModifiers() == wx.MOD_CONTROL: self.text.UpdateGui({"cursor": "beam"}) else: self.text.UpdateGui({"cursor": None})
def _on_click_old(self, event): self.project.open_pages([self.page.id], column_index=0)
def _on_right_click_old(self, event): menu = PageContextMenu(self.project, self.page) self.PopupMenu(menu) menu.Destroy()
def _on_drag_old(self, event): data = RliterateDataObject("page", { "page_id": self.page.id, }) drag_source = wx.DropSource(self) drag_source.SetData(data) result = drag_source.DoDragDrop(wx.Drag_DefaultMove)
def _on_enter(self, event): self.UpdateGui({"background": "#cccccc"}) def _on_leave(self, event): self.UpdateGui({"background": None})
class TocTitleProjection(BaseProjection): def __init__(self, project, page, selection): self.project = project self.page = page self.selection = selection def create_projection(self, editor): if self.project.is_open(self.page.id): token_type = TokenType.RLiterate.Strong else: token_type = TokenType.RLiterate if self.page.title: self.add( self.page.title, self.project.get_style(token_type), self.selection.value, self.selection ) else: self.add( "Enter title", self.project.get_style(TokenType.RLiterate.Empty), 0 if self.selection.present else self.selection.value, one_selection=self.selection.create(0) ) if self.selection.present: self._key_handler = TitleKeyHandler( editor, self.project, self._character_selection, self.page, self.selection )
The expand/collapse widget draws a native looking expand/collapse symbol. When clicked, the corresponding page it toggled.
panel TableOfContentsButtonGui %hbox { #project #page #visible = bool(self.page.children) #cursor = "hand" #min_size = self._get_min_size() @click = self.project.toggle_collapsed(self.page.id) @paint = self._on_paint(event) }
class TableOfContentsButton(TableOfContentsButtonGui): SIZE = 16 <<TableOfContentsButton>>
def _get_min_size(self): return (self.SIZE+1, -1)
def _on_paint(self, event): dc = wx.GCDC(wx.PaintDC(self)) dc.SetBrush(wx.BLACK_BRUSH) render = wx.RendererNative.Get() (w, h) = self.Size render.DrawTreeItemButton( self, dc, (0, (h-self.SIZE)/2, self.SIZE, self.SIZE), flags=0 if self.project.is_collapsed(self.page.id) else wx.CONTROL_EXPANDED )
class PageContextMenu(wx.Menu): def __init__(self, project, page): wx.Menu.__init__(self) self.project = project self.page = page self.child_ids = [page.id]+[child.id for child in page.children] self._create_menu() def _create_menu(self): self.Bind( wx.EVT_MENU, lambda event: self.project.open_pages([self.page.id], column_index=0), self.Append(wx.NewId(), "Open") ) self.Bind( wx.EVT_MENU, lambda event: self.project.open_pages([self.page.id]), self.Append(wx.NewId(), "Open append") ) self.Bind( wx.EVT_MENU, lambda event: self.project.open_pages(self.child_ids, column_index=0), self.Append(wx.NewId(), "Open with children") ) self.Bind( wx.EVT_MENU, lambda event: self.project.open_pages(self.child_ids), self.Append(wx.NewId(), "Open with children append") ) self.AppendSeparator() self.Bind( wx.EVT_MENU, lambda event: self.project.get_page(self.page.id).add_child(), self.Append(wx.NewId(), "Add child") ) self.AppendSeparator() self.Bind( wx.EVT_MENU, lambda event: setattr(self.project, "hoisted_page", self.page.id), self.Append(wx.NewId(), "Hoist") ) self.AppendSeparator() self.Bind( wx.EVT_MENU, lambda event: set_clipboard_text(self.page.id), self.Append(wx.NewId(), "Copy id") ) self.AppendSeparator() delete_item = self.Append(wx.NewId(), "Delete") delete_item.Enable(self.page.id != self.project.get_root_page().id) self.Bind( wx.EVT_MENU, lambda event: self.page.delete(), delete_item )
def set_clipboard_text(text): if wx.TheClipboard.Open(): try: wx.TheClipboard.SetData(wx.TextDataObject(text.encode("utf-8"))) finally: wx.TheClipboard.Close()
The workspace shows pages in the document. It is organized in columns, where each column can show multiple pages. The pages can also be edited from the workspace.
The workspace widget renders column widgets horizontally. It can be scrolled left and right if more columns are shown than can fit on the screen.
hscroll WorkspaceGui %hbox { #project #selection #background = self.project.theme.workspace_background BoxSpace(self.project.theme.page_padding) for (self._get_columns()) { Column[columns]( project = self.project index = loopvar.index page_ids = loopvar.page_ids selection = self.selection.get(loopvar.index) %expand ) } }
class Workspace(WorkspaceGui): <<Workspace>>
def _get_columns(self): return [ WorkspaceColumn(*x) for x in enumerate(self.project.columns) ]
WorkspaceColumn = namedtuple("WorkspaceColumn", ["index", "page_ids"])
def _create_gui(self): <<_create_gui>> WorkspaceGui._create_gui(self)
@rltime("update workspace") def _update_gui(self): WorkspaceGui._update_gui(self)
Inside a workspace, paragraphs can be dragged and dropped. The drag is handled in the paragraph widgets, but the drop is handled in the workspace widget.
self.SetDropTarget(WorkspaceDropTarget(self, self.project))
class WorkspaceDropTarget(DropPointDropTarget): def __init__(self, workspace, project): DropPointDropTarget.__init__(self, workspace, "paragraph") self.project = project def OnDataDropped(self, dropped_paragraph, drop_point): self.project.get_paragraph( dropped_paragraph["page_id"], dropped_paragraph["paragraph_id"] ).move( target_page=drop_point.target_page, target_index=drop_point.target_index )
The DropPointDropTarget requires FindClosestDropPoint to be defined on the target object. Here it is:
def FindClosestDropPoint(self, screen_pos): return find_first( self.columns, lambda column: column.FindClosestDropPoint(screen_pos) )
The column widget renders page container widgets vertically. It can be scrolled up and down if more pages are shown than can fit on the screen. It re-renders when SetPages
is called.
vscroll ColumnGui %vbox { #project #selection #page_ids #index #min_size = self._get_size() BoxSpace(self.project.theme.page_padding) for (self._get_rows()) { PageContainer[containers]( project = self.project page = loopvar.page selection = self.selection.get(loopvar.index).get(loopvar.page.id) %border[self.project.theme.page_padding, RIGHT|BOTTOM] %expand ) } @page_open_request = self.OpenPage(event) }
class Column(ColumnGui): <<Column>>
def _create_gui(self): ColumnGui._create_gui(self) self.Bind(EVT_HOVERED_TOKEN_CHANGED, self._on_hovered_token_changed) self.Bind(EVT_TOKEN_CLICK, self._on_token_click)
def _get_size(self): return ( self.project.theme.page_body_width+ 2*self.project.theme.container_border+ self.project.theme.page_padding+ self.project.theme.shadow_size, -1 )
def _get_rows(self): return [ ColumnRow( page=self.project.get_page(page_id), index=index ) for index, page_id in enumerate(self.page_ids) ]
ColumnRow = namedtuple("ColumnRow", ["page", "index"])
def _on_hovered_token_changed(self, event): if event.token is not None and event.token.token_type in [ TokenType.RLiterate.Link, TokenType.RLiterate.Reference, ]: event.widget.UpdateGui({"cursor": "hand"}) else: event.widget.UpdateGui({"cursor": None}) def _on_token_click(self, event): if event.token.token_type == TokenType.RLiterate.Reference: self.OpenPage(event.token.extra["page_id"]) elif event.token.token_type == TokenType.RLiterate.Link: webbrowser.open(event.token.extra["url"]) def OpenPage(self, page_id): self.project.open_pages( [page_id], column_index=self.index+1 )
def FindClosestDropPoint(self, screen_pos): return find_first( self.containers, lambda container: container.FindClosestDropPoint(screen_pos) )
The page container widget renders a page frame with a border, and inside the frame it renders a page widget.
panel PageContainerGui %vbox { #project #page #selection panel(%expand %proportion[1]) %hbox { panel(%expand %proportion[1] background="#ffffff") %hbox { PagePanel[page_panel]( project = self.project page = self.page selection = self.selection %expand %proportion[1] %border[self.project.theme.container_border, ALL] ) } panel(%expand) %vbox { BoxSpace(self.project.theme.shadow_size) panel( min_size = tuple([self.project.theme.shadow_size -1]) background = "#969696" %proportion[1] ) %hbox {} } } panel(%expand) %hbox { BoxSpace(self.project.theme.shadow_size) panel( min_size = tuple([-1 self.project.theme.shadow_size]) background = "#969696" %proportion[1] ) %hbox {} } @right_click = SimpleContextMenu.ShowRecursive(self) }
class PageContainer(PageContainerGui): <<PageContainer>>
def CreateContextMenu(self): menu = SimpleContextMenu("Page") menu.AppendItem("Change width", lambda: SettingsDialog( wx.GetTopLevelParent(self), self.project ).Show() ) return menu
def FindClosestDropPoint(self, screen_pos): return self.page_panel.FindClosestDropPoint(screen_pos)
The page widget renders a title widget, paragraph widgets, and an add button widget vertically.
panel PageGui %vbox { #project #page #selection #min_size = tuple([self.project.theme.page_body_width -1]) Title( project = self.project page = self.page selection = self.selection.get("title") %expand ) Divider[top_divider]( padding = self.divider_padding() height = 3 %expand ) for (self._get_paragraphs()) { loopvar.widget_cls( project = self.project page_id = self.page.id paragraph = loopvar.paragraph selection = self.selection.get("paragraph").get(loopvar.paragraph.id) %expand ) Divider[dividers]( padding = self.divider_padding() height = 3 %expand ) } panel(%expand) %hbox { panel(%proportion[1]) %hbox {} IconButton( icon = "add" @button = self.project.add_paragraph(self.page.id) ) } @right_click = SimpleContextMenu.ShowRecursive(self) }
class PagePanel(PageGui): <<PagePanel>>
def _get_paragraphs(self): self.drop_points = [] divider_fn = lambda: self.top_divider paragraphs = [] index = -1 for index, paragraph in enumerate(self.page.paragraphs): self.drop_points.append(PageDropPoint( divider_fn=divider_fn, target_page=self.page, target_index=index )) divider_fn = (lambda index: lambda: self.dividers[index])(index) paragraphs.append(ParagraphRow( { "text": Text, "quote": Quote, "list": List, "code": Code, "image": Image, "expanded_code": ExpandedCode, "factory": Factory, }[paragraph.type], paragraph )) self.drop_points.append(PageDropPoint( divider_fn=divider_fn, target_page=self.page, target_index=index+1 )) return paragraphs
ParagraphRow = namedtuple("ParagraphRow", ["widget_cls", "paragraph"])
def divider_padding(self): return (self.project.theme.paragraph_space-3)/2
All dividers must be in a container that starts at Y=0, otherwise its Position will not be correct relative to the parent.
Setting a minimus size on the page panel is necessary becuase size calculations does not seem to always be correct in a scrolling container.
class PageDropPoint(object): def __init__(self, divider_fn, target_page, target_index): self.divider_fn = divider_fn self.target_page = target_page self.target_index = target_index def y_distance_to(self, y): return abs(self.divider_fn().Position.y + self.divider_fn().Size[1]/2 - y) def Show(self): self.divider_fn().Show() def Hide(self): self.divider_fn().Hide()
def FindClosestDropPoint(self, screen_pos): client_pos = (client_x, client_y) = self.ScreenToClient(screen_pos) if self.HitTest(client_pos) == wx.HT_WINDOW_INSIDE: return min_or_none( self.drop_points, key=lambda drop_point: drop_point.y_distance_to(client_y) )
def min_or_none(items, key): if not items: return None return min(items, key=key)
The title widget is an editable that displays the page title in bigger text.
panel TitleGui %hbox { #project #page #selection TextProjectionEditor[text]( projection = TitleProjection(self.project self.page self.selection.get("title")) max_width = self.project.theme.page_body_width font = self._create_font() tooltip = self.page.full_title @double_click = self.text.Select(self.project event.Position) %proportion[1] ) @right_click = SimpleContextMenu.ShowRecursive(self) }
class Title(TitleGui): <<Title>>
def _create_font(self): return create_font(**self.project.theme.title_font)
class TitleProjection(BaseProjection): def __init__(self, project, page, selection): self.project = project self.page = page self.selection = selection def create_projection(self, editor): if self.page.title: self.add( self.page.title, self.project.get_style(TokenType.RLiterate), self.selection.value, self.selection ) else: self.add( "Enter title", self.project.get_style(TokenType.RLiterate.Empty), 0 if self.selection is None else self.selection.value, one_selection=self.selection.create(0) ) if self.selection.present: self._key_handler = TitleKeyHandler( editor, self.project, self._character_selection, self.page, self.selection )
class TitleKeyHandler(PlainTextKeyHandler): def __init__(self, editor, project, character_selection, page, selection): PlainTextKeyHandler.__init__(self, editor, project, character_selection, page.title, selection.value) self.project = project self.page = page self.selection = selection def save(self, text, index): with self.project.notify(): self.page.set_title(text) self.project.selection = self.selection.create(index)
All paragraphs are editables.
panel TextGui %hbox { #project #page_id #paragraph #selection TextView[text]( project = self.project paragraph = self.paragraph selection = self.selection indentation = 0 %proportion[1] ) @drag = self.DoDragDrop() @right_click = SimpleContextMenu.ShowRecursive(self) }
class Text(TextGui, ParagraphBaseMixin): def AddContextMenuItems(self, menu): menu.AppendItem( "To quote", lambda: self.paragraph.update({"type": "quote"}) )
A quote paragraph is the same as a text paragraph but the view is rendered indented.
panel QuoteGui %hbox { #project #page_id #paragraph #selection BoxSpace(20) TextView[text]( project = self.project paragraph = self.paragraph selection = self.selection indentation = 20 %proportion[1] ) @drag = self.DoDragDrop() @right_click = SimpleContextMenu.ShowRecursive(self) }
class Quote(QuoteGui, ParagraphBaseMixin): def AddContextMenuItems(self, menu): menu.AppendItem( "To text", lambda: self.paragraph.update({"type": "text"}) )
panel ListGui %vbox { #project #page_id #paragraph #selection for (self._get_rows()) { panel() %hbox { BoxSpace(loopvar.indentation) TextView( project = self.project prefix = loopvar.bullet_text paragraph = loopvar.item selection = loopvar.selection indentation = loopvar.indentation %proportion[1] ) } } @drag = self.DoDragDrop() @right_click = SimpleContextMenu.ShowRecursive(self) }
class List(ListGui, ParagraphBaseMixin): def _get_rows(self): self.rows = [] self._add_items(self.paragraph.children, self.paragraph.child_type, self.selection) return self.rows def _add_items(self, items, child_type, selection, indentation=0): for index, item in enumerate(items): child_selection = selection.get(index) self.rows.append(ListRow( item, self._get_bullet_text(child_type, index), 20*indentation, child_selection.get("body") )) self._add_items(item.children, item.child_type, child_selection, indentation+1) def _get_bullet_text(self, list_type, index): if list_type == "ordered": return "{}. ".format(index + 1) else: return u"\u2022 " def _create_font(self): return create_font(**self.project.theme.text_font)
ListRow = namedtuple("ListRow", ["item", "bullet_text", "indentation", "selection"])
panel CodeGui %vbox { #project #page_id #paragraph #selection #background = "#fdf6e3" for (self._header()) { panel(background="#f8f1df" %expand) %hbox { TextProjectionEditor[path]( projection = CodePathProjection(self.project self.paragraph self.selection) max_width = self._max_width(self._filetype()) font = self._create_font() cursor = "beam" @click = self.path.Select(self.project event.Position) @right_click = self._path_right_click(event) %border[self.PADDING, ALL] %proportion[1] ) for (self._filetype()) { panel( min_size=tuple([self.FILETYPE_WIDTH -1]) %border[self.PADDING, TOP|RIGHT|BOTTOM] %expand ) %hbox { TextProjectionEditor[raw_language]( projection = CodeRawLanguageProjection(self.project self.paragraph self.selection.get("raw_language")) max_width = self.FILETYPE_WIDTH font = self._create_font() cursor = "beam" @click = self.raw_language.Select(self.project event.Position) ) } } } } panel(%expand) %hbox { TokenView[code]( project = self.project tokens = self._highlight_variables(self.paragraph.tokens) max_width = self._max_width() font = self._create_font() @right_click = self._code_right_click(event) %border[self.PADDING, ALL] ) } for (self._post_process()) { panel(background="#f8f1df" %expand %border[self.PADDING, ALL]) %hbox { TextProjectionEditor[post_process]( projection = CodePostProcessingProjection(self.project self.paragraph self.selection.get("post_process")) max_width = self._max_width() font = self._create_font() cursor = "beam" @click = self.post_process.Select(self.project event.Position) ) } } @drag = self.DoDragDrop() @right_click = SimpleContextMenu.ShowRecursive(self) @click = setattr(self.project "selection" self.selection.create_this()) }
class Code(CodeGui, ParagraphBaseMixin): PADDING = 5 FILETYPE_WIDTH = 100 def _create_font(self): return create_font(**self.project.theme.code_font) def _max_width(self, adjust_for_filetype=False): if adjust_for_filetype: x = self.FILETYPE_WIDTH + self.PADDING else: x = 0 return self.project.theme.page_body_width-2*self.PADDING-x def _header(self): return self._path() or self._filetype() def _path(self): if not self.paragraph.path.is_empty or self.selection.present: return [None] else: return [] def _filetype(self): if self.paragraph.raw_language or self.selection.present: return [None] else: return [] def _post_process(self): if self.paragraph.post_process or self.selection.present: return [None] else: return [] def AddContextMenuItems(self, menu): menu.AppendItem( "Create expanded view", lambda: self.paragraph.insert_paragraph_after( paragraph_dict={ "type": "expanded_code", "code_id": self.paragraph.id, } ) ) <<Code>>
def _path_right_click(self, event): extra = self.path.GetExtraAt(event.Position) if extra is not None and extra.get("subpath") is not None: menu = SimpleContextMenu("Path") menu.AppendItem( "Rename '{}'".format(extra["subpath"].last), lambda: show_text_entry( self, title="Rename path", body="Rename '{}'".format(extra["subpath"].last), value=extra["subpath"].last, ok_fn=lambda value: self.project.rename_path( extra["subpath"], value ) ) ) menu.Popup(self) else: SimpleContextMenu.ShowRecursive(self)
def _highlight_variables(self, tokens): def foo(token): if self.project.highlighted_variable is not None and self.project.highlighted_variable == token.extra.get("variable"): return token.with_extra("highlight", True) return token return [foo(token) for token in tokens]
def _code_right_click(self, event): token = self.code.GetToken(event.Position) if token is not None and token.extra.get("variable") is not None: rename_value = self.project.lookup_variable(token.extra["variable"]) or token.extra["variable"] rename_message = "Rename '{}'".format(rename_value) menu = SimpleContextMenu("Variable") menu.AppendItem( rename_message, lambda: show_text_entry( self, title="Rename variable", body=rename_message, value=rename_value, ok_fn=lambda value: self.project.rename_variable( token.extra["variable"], value ) ) ) menu.AppendItem( "Copy id", lambda: set_clipboard_text(token.extra["variable"]) ) menu.AppendItem( "Highlight", lambda: setattr(self.project, "highlighted_variable", token.extra["variable"]) ) menu.AppendSeparator() menu.AppendItem("Usages:", lambda: None) def create_open_page_handler(page): return lambda: self.CallHandler("page_open_request", page.id) for page in self._find_variable_usages(token.extra["variable"]): menu.AppendItem( "{}".format(page.full_title), create_open_page_handler(page) ) menu.AppendSeparator() menu.AppendItem("Possible usages:", lambda: None) for page in self._find_variable_pages(rename_value): menu.AppendItem( "{}".format(page.full_title), create_open_page_handler(page) ) menu.Popup(self) else: SimpleContextMenu.ShowRecursive(self) def _find_variable_pages(self, name): for page in self.project.iter_pages(): if self._page_has_variable(page, name): yield page def _find_variable_usages(self, variable_id): for page in self.project.iter_pages(): if self._page_uses_variable(page, variable_id): yield page def _page_has_variable(self, page, name): pattern = re.compile(r"\b{}\b".format(re.escape(name))) for text_fragment in page.iter_text_fragments(): if text_fragment.type == "code": if pattern.search(text_fragment.text): return True for code_fragment in page.iter_code_fragments(): if code_fragment.type == "code": if pattern.search(code_fragment.text): return True def _page_uses_variable(self, page, variable_id): for text_fragment in page.iter_text_fragments(): if text_fragment.type == "variable" and text_fragment.id == variable_id: return True for code_fragment in page.iter_code_fragments(): if code_fragment.type == "variable" and code_fragment.id == variable_id: return True
class CodePathProjection(BaseProjection): def __init__(self, project, paragraph, selection): self.project = project self.paragraph = paragraph self.selection = selection def create_projection(self, editor): self.path = { "filepath": [], "chunkpath": [] } self._project_path( editor, "filepath", TokenType.RLiterate.Path ) if self.characters: self.add( " ", self.project.get_style(TokenType.RLiterate.Sep), ) self._project_path( editor, "chunkpath", TokenType.RLiterate.Chunk ) def _project_path(self, editor, name, token_type): path_selection = self.selection.get(name) items = getattr(self.paragraph, name) if len(items) > 0: for index, path in enumerate(items): self.path[name].append(path) part_selection = path_selection.get(index) if index > 0: self.add( "/", self.project.get_style(TokenType.RLiterate.Sep), ) if path: self.add( path, self.project.get_style(token_type), part_selection.value, part_selection, extra={ "subpath": Path( list(self.path["filepath"]), list(self.path["chunkpath"]) ) } ) else: self.add( "<>", self.project.get_style(TokenType.RLiterate.Empty), None if part_selection.value is None else 1, one_selection=part_selection.create(0) ) if part_selection.present: self._key_handler = CodePathElementKeyHandler( editor, self.project, self._character_selection, self.paragraph, name, index, path_selection ) elif self.selection.present: self.add( "Add {}".format(name), self.project.get_style(TokenType.RLiterate.Empty), None if path_selection.value is None else 0, one_selection=path_selection.create(0) ) if path_selection.present: self._key_handler = CodePathEmptyKeyHandler( editor, self.project, self._character_selection, self.paragraph, name, path_selection )
class CodePathElementKeyHandler(PlainTextKeyHandler): def __init__(self, editor, project, character_selection, paragraph, which_path, path_index, selection): PlainTextKeyHandler.__init__( self, editor, project, character_selection, getattr(paragraph, which_path)[path_index], selection.get(path_index).value ) self.project = project self.paragraph = paragraph self.which_path = which_path self.path_index = path_index self.selection = selection def handle_key(self, event): if event.GetKeyCode() == wx.WXK_BACK and not getattr(self.paragraph, self.which_path)[self.path_index]: with self.project.notify(): old = getattr(self.paragraph, self.which_path) new = old[:self.path_index]+old[self.path_index+1:] setattr(self.paragraph, self.which_path, new) if len(new) > 0: self.project.selection = self.selection.get(self.path_index-1).create(0) else: self.project.selection = self.selection.create(0) elif event.GetKeyCode() == 47: with self.project.notify(): old = getattr(self.paragraph, self.which_path) split = [ old[self.path_index][:self.index], old[self.path_index][self.index:], ] new = old[:self.path_index]+split+old[self.path_index+1:] setattr(self.paragraph, self.which_path, new) self.project.selection = self.selection.get(self.path_index+1).create(0) else: PlainTextKeyHandler.handle_key(self, event) def save(self, text, index): with self.project.notify(): setattr( self.paragraph, self.which_path, im_replace( getattr(self.paragraph, self.which_path), [self.path_index], text ) ) self.project.selection = self.selection.get(self.path_index).create(index)
class CodePathEmptyKeyHandler(PlainTextKeyHandler): def __init__(self, editor, project, character_selection, paragraph, which_path, selection): PlainTextKeyHandler.__init__(self, editor, project, character_selection, "", selection.value) self.project = project self.paragraph = paragraph self.which_path = which_path self.selection = selection def save(self, text, index): with self.project.notify(): setattr(self.paragraph, self.which_path, [text]) self.project.selection = self.selection.get(0).create(index)
class CodeRawLanguageProjection(BaseProjection): def __init__(self, project, paragraph, selection): self.project = project self.paragraph = paragraph self.selection = selection def create_projection(self, editor): if self.paragraph.raw_language: self.add( self.paragraph.raw_language, self.project.get_style(TokenType.RLiterate), self.selection.value, self.selection ) else: self.add( self.paragraph.language or "n/a", self.project.get_style(TokenType.RLiterate.Empty), self.selection.value, one_selection=self.selection.create(0) ) if self.selection.present: self._key_handler = CodeRawLanguageKeyHandler( editor, self.project, self._character_selection, self.paragraph, self.selection )
class CodeRawLanguageKeyHandler(PlainTextKeyHandler): def __init__(self, editor, project, character_selection, paragraph, selection): PlainTextKeyHandler.__init__(self, editor, project, character_selection, paragraph.raw_language, selection.value) self.project = project self.paragraph = paragraph self.selection = selection def save(self, text, index): with self.project.notify(): self.paragraph.raw_language = text self.project.selection = self.selection.create(index)
class CodePostProcessingProjection(BaseProjection): def __init__(self, project, paragraph, selection): self.project = project self.paragraph = paragraph self.selection = selection def create_projection(self, editor): self.add( "> ", self.project.get_style(TokenType.RLiterate), ) if len(self.paragraph.post_process) > 0: for index, part in enumerate(self.paragraph.post_process): part_selection = self.selection.get(index) if index > 0: self.add( " ", self.project.get_style(TokenType.RLiterate.Empty), ) if part: self.add( part, self.project.get_style(TokenType.RLiterate), part_selection.value, part_selection ) else: self.add( "\"\"", self.project.get_style(TokenType.RLiterate.Empty), None if part_selection.value is None else 1, one_selection=part_selection.create(0) ) if part_selection.present: self._key_handler = CodePostProcessElementKeyHandler( editor, self.project, self._character_selection, self.paragraph, index, self.selection ) else: self.add( "Enter post processing command", self.project.get_style(TokenType.RLiterate.Empty), self.selection.value, one_selection=self.selection.create(0) ) if self.selection.present: self._key_handler = CodePostProcessEmptyKeyHandler( editor, self.project, self._character_selection, self.paragraph, self.selection )
class CodePostProcessElementKeyHandler(PlainTextKeyHandler): def __init__(self, editor, project, character_selection, paragraph, post_process_index, selection): PlainTextKeyHandler.__init__( self, editor, project, character_selection, paragraph.post_process[post_process_index], selection.get(post_process_index).value ) self.project = project self.paragraph = paragraph self.post_process_index = post_process_index self.selection = selection def handle_key(self, event): if event.GetKeyCode() == wx.WXK_BACK and not self.paragraph.post_process[self.post_process_index]: with self.project.notify(): old = self.paragraph.post_process new = old[:self.post_process_index]+old[self.post_process_index+1:] self.paragraph.post_process = new if len(new) > 0: self.project.selection = self.selection.get(self.post_process_index-1).create(0) else: self.project.selection = self.selection.create(0) elif event.GetKeyCode() == 32: with self.project.notify(): old = self.paragraph.post_process split = [ old[self.post_process_index][:self.index], old[self.post_process_index][self.index:], ] new = old[:self.post_process_index]+split+old[self.post_process_index+1:] self.paragraph.post_process = new self.project.selection = self.selection.get(self.post_process_index+1).create(0) else: PlainTextKeyHandler.handle_key(self, event) def save(self, text, index): with self.project.notify(): self.paragraph.post_process = im_replace(self.paragraph.post_process, [self.post_process_index], text) self.project.selection = self.selection.get(self.post_process_index).create(index)
class CodePostProcessEmptyKeyHandler(PlainTextKeyHandler): def __init__(self, editor, project, character_selection, paragraph, selection): PlainTextKeyHandler.__init__(self, editor, project, character_selection, "", selection.value) self.project = project self.paragraph = paragraph self.selection = selection def save(self, text, index): with self.project.notify(): self.paragraph.post_process = [text] self.project.selection = self.selection.get(0).create(index)
Data is defined in Image.
panel ImageGui %vbox { #project #page_id #paragraph #selection Bitmap( bitmap = self._get_bitmap() selection = self.selection.get("image") %align_center ) TextView( project = self.project paragraph = self.paragraph selection = self.selection.get("text") indentation = 60 %align_center ) @drag = self.DoDragDrop() @right_click = SimpleContextMenu.ShowRecursive(self) }
class Image(ImageGui, ParagraphBaseMixin): def _get_bitmap(self): return base64_to_bitmap( self.paragraph.image_base64, self.project.theme.page_body_width ) def _get_paste(self): image_data = wx.BitmapDataObject() if wx.TheClipboard.Open(): success = wx.TheClipboard.GetData(image_data) wx.TheClipboard.Close() if success: return bitmap_to_base64(image_data.GetBitmap()) def AddContextMenuItems(self, menu): menu.AppendItem( "Paste image", lambda: self.paragraph.update({"image_base64": self._get_paste()}) )
def base64_to_bitmap(data, max_width): try: image = fit_image(wx.ImageFromStream( StringIO.StringIO(base64.b64decode(data)), wx.BITMAP_TYPE_ANY ), max_width) return image.ConvertToBitmap() except: return wx.ArtProvider.GetBitmap( wx.ART_MISSING_IMAGE, wx.ART_BUTTON, (64, 64) )
def bitmap_to_base64(bitmap): output = StringIO.StringIO() image = wx.ImageFromBitmap(bitmap) image.SaveStream(output, wx.BITMAP_TYPE_PNG) return base64.b64encode(output.getvalue())
def fit_image(image, width): if image.Width <= width: return image factor = float(width) / image.Width return image.Scale( int(image.Width*factor), int(image.Height*factor), wx.IMAGE_QUALITY_HIGH )
panel ExpandedCodeGui %vbox { #project #page_id #paragraph #selection #background = "#f0f0f0" TokenView( %expand project = self.project tokens = self.paragraph.tokens max_width = self.project.theme.page_body_width font = self._create_font() ) @drag = self.DoDragDrop() @right_click = SimpleContextMenu.ShowRecursive(self) }
class ExpandedCode(ExpandedCodeGui, ParagraphBaseMixin): def _create_font(self): return create_font(**self.project.theme.code_font)
panel FactoryGui %vbox { #project #page_id #paragraph #selection #background = "#f0f0f0" BoxSpace(self.project.theme.paragraph_space) Label(%align_center label="Factory") BoxSpace(self.project.theme.paragraph_space) panel(%align_center) %hbox { Button(%border[2,ALL] @button=self._add_text() label="Text") Button(%border[2,ALL] @button=self._add_quote() label="Quote") Button(%border[2,ALL] @button=self._add_list() label="List") Button(%border[2,ALL] @button=self._add_code() label="Code") Button(%border[2,ALL] @button=self._add_image() label="Image") } BoxSpace(self.project.theme.paragraph_space) @drag = self.DoDragDrop() @right_click = SimpleContextMenu.ShowRecursive(self) }
class Factory(FactoryGui, ParagraphBaseMixin): def _add_text(self): self.paragraph.update({ "type": "text", "fragments": [{"type": "text", "text": "Enter text here..."}], }) def _add_quote(self): self.paragraph.update({ "type": "quote", "fragments": [{"type": "text", "text": "Enter quote here..."}], }) def _add_list(self): self.paragraph.update({ "type": "list", "child_type": "unordered", "children": [{ "child_type": None, "children": [], "fragments": [{"type": "text", "text": "Enter list item here..."}], }], }) def _add_code(self): self.paragraph.update({ "type": "code", "filepath": [], "chunkpath": [], "fragments": [{"type": "code", "text": "Enter code here..."}], }) def _add_image(self): self.paragraph.update({ "type": "image", "fragments": [{"type": "text", "text": "Enter image text here..."}], })
class ParagraphBaseMixin(object): def DoDragDrop(self): data = RliterateDataObject("paragraph", { "page_id": self.page_id, "paragraph_id": self.paragraph.id, }) drag_source = wx.DropSource(self) drag_source.SetData(data) result = drag_source.DoDragDrop(wx.Drag_DefaultMove) <<ParagraphBaseMixin>>
def ShowContextMenu(self): SimpleContextMenu.ShowRecursive(self) def CreateContextMenu(self): menu = SimpleContextMenu("Paragraph") menu.AppendItem( "New paragraph before", lambda: self.paragraph.insert_paragraph_before() ) menu.AppendItem( "New paragraph after", lambda: self.paragraph.insert_paragraph_after() ) menu.AppendItem( "Duplicate", lambda: self.paragraph.duplicate() ) if hasattr(self.paragraph, "text_version"): menu.AppendItem( "Edit in gvim", lambda: setattr( self.paragraph, "text_version", edit_in_gvim( self.paragraph.text_version, self.paragraph.filename ) ) ) menu.AppendSeparator() self.AddContextMenuItems(menu) menu.AppendSeparator() menu.AppendItem( "Delete", lambda: self.paragraph.delete() ) return menu def AddContextMenuItems(self, menu): pass
def edit_in_gvim(text, filename): with tempfile.NamedTemporaryFile(suffix="-rliterate-external-"+filename) as f: f.write(text) f.flush() p = subprocess.Popen(["gvim", "--nofork", f.name]) while p.poll() is None: wx.Yield() time.sleep(0.1) f.seek(0) return f.read()
panel TextViewGui %hbox { #project #paragraph #selection #indentation #prefix TextProjectionEditor[text]( projection = TextFragmentsProjection(self.project self.paragraph self.selection self.prefix) max_width = self._get_max_width() line_height = self.LINE_HEIGHT skip_extra_space = True font = self._create_font() @double_click = self.text.Select(self.project event.Position) @mouse_move = self._on_mouse_move(event) @click = self._on_click() %proportion[1] ) }
class TextView(TextViewGui): LINE_HEIGHT = 1.2 DEFAULTS = { "prefix": "", } token = None <<TextView>>
def _get_max_width(self): return self.project.theme.page_body_width - self.indentation
def _create_font(self): return create_font(**self.project.theme.text_font)
def _on_mouse_move(self, event): char = self.text.text.GetClosestCharacter(event.Position) if char is not None and "index" in char.extra: token = self.paragraph.fragments[char.extra["index"]].token post_hovered_token_changed(self, token) self.token = token
def _on_click(self): if self.token is not None: post_token_click(self, self.token)
class TextFragmentsProjection(BaseProjection): def __init__(self, project, paragraph, selection, prefix): self.project = project self.paragraph = paragraph self.selection = selection self.prefix = prefix def create_projection(self, editor): if self.prefix: self.add(self.prefix, self.project.get_style(TokenType.RLiterate)) fragments = self.paragraph.fragments for fragment_index, fragment in enumerate(fragments): fragment_selection = self.selection.get(fragment_index) if fragment_index == 0 or fragment_index == len(fragments): flag = False else: flag = True { "strong": self._project_strong, "emphasis": self._project_emphasis, "code": self._project_code, "variable": self._project_variable, "reference": self._project_reference, "link": self._project_link, }.get(fragment.type, self._project_text)(editor, fragment, fragment_index, fragment_selection) def _add_markup(self, text): if self.selection.present: self.add(text, self.project.get_style(TokenType.RLiterate.Empty)) def _set_key_handler(self, editor, fragment, attr, selection): if selection.present: self._key_handler = FragmentKeyHandler( editor, fragment, self.project, self._character_selection, attr, selection ) <<TextFragmentsProjection>>
def _project_strong(self, editor, fragment, index, selection): self._add_markup("**") self.add( fragment.text, self.project.get_style(TokenType.RLiterate.Strong), selection.value, selection, extra={"index": index} ) self._set_key_handler(editor, fragment, "text", selection) self._add_markup("**")
def _project_emphasis(self, editor, fragment, index, selection): self._add_markup("*") self.add( fragment.text, self.project.get_style(TokenType.RLiterate.Emphasis), selection.value, selection, extra={"index": index} ) self._set_key_handler(editor, fragment, "text", selection) self._add_markup("*")
def _project_code(self, editor, fragment, index, selection): self._add_markup("`") self.add( fragment.text, self.project.get_style(TokenType.RLiterate.Code), selection.value, selection, extra={"index": index} ) self._set_key_handler(editor, fragment, "text", selection) self._add_markup("`")
def _project_variable(self, editor, fragment, index, selection): self._add_markup("``") self.add( fragment.name, self.project.get_style(TokenType.RLiterate.Variable), selection.value, selection, extra={"index": index} ) self._set_key_handler(editor, fragment, "text", selection) self._add_markup("``")
def _project_reference(self, editor, fragment, index, selection): self._add_markup("[[") self.add( fragment.title, self.project.get_style(TokenType.RLiterate.Reference), selection.value, selection, extra={"index": index} ) self._set_key_handler(editor, fragment, "text", selection) self._add_markup("]]")
def _project_link(self, editor, fragment, index, selection): if self.selection.present: self._add_markup("[") text_selection = selection.get("text") self.add( fragment.text, self.project.get_style(TokenType.RLiterate.Link), text_selection.value, text_selection, extra={"index": index} ) self._set_key_handler(editor, fragment, "text", text_selection) if self.selection.present: url_selection = selection.get("url") self._add_markup("]") self._add_markup("(") self.add( fragment.url, self.project.get_style(TokenType.RLiterate.Link), url_selection.value, url_selection, extra={"index": index} ) self._set_key_handler(editor, fragment, "url", url_selection) self._add_markup(")")
def _project_text(self, editor, fragment, index, selection): self.add( fragment.text, self.project.get_style(TokenType.RLiterate.Text), selection.value, selection, extra={"index": index} ) self._set_key_handler(editor, fragment, "text", selection)
class FragmentKeyHandler(PlainTextKeyHandler): def __init__(self, editor, fragment, project, character_selection, attr, selection): PlainTextKeyHandler.__init__(self, editor, project, character_selection, getattr(fragment, attr), selection.value) self.project = project self.fragment = fragment self.attr = attr self.selection = selection def save(self, text, index): with self.project.notify(): setattr(self.fragment, self.attr, text) self.project.selection = self.selection.create(index)
TokenClick, EVT_TOKEN_CLICK = wx.lib.newevent.NewCommandEvent()
def post_token_click(widget, token): wx.PostEvent(widget, TokenClick(0, widget=widget, token=token))
HoveredTokenChanged, EVT_HOVERED_TOKEN_CHANGED = wx.lib.newevent.NewCommandEvent()
def post_hovered_token_changed(widget, token): wx.PostEvent(widget, HoveredTokenChanged(0, widget=widget, token=token))
def find_first(items, action): for item in items: result = action(item) if result is not None: return result return None
panel HBorder %vbox { #project #background = self.project.theme.border_color #min_size = tuple([-1 1]) }
panel VBorder %vbox { #project #background = self.project.theme.border_color #min_size = tuple([1 -1]) }
class SettingsDialog(wx.Dialog): def __init__(self, parent, project): wx.Dialog.__init__(self, parent) self.project = project self._init_gui() def _init_gui(self): spin = wx.SpinCtrl( self, value="{}".format(self.project.theme.page_body_width), min=100, max=10000 ) self.Bind( wx.EVT_SPINCTRL, lambda event: setattr( self.project.theme, "page_body_width", event.Value ) )
class RliterateDataObject(wx.CustomDataObject): def __init__(self, kind, json=None): wx.CustomDataObject.__init__(self, "rliterate/{}".format(kind)) if json is not None: self.set_json(json) def set_json(self, data): self.SetData(json.dumps(data)) def get_json(self): return json.loads(self.GetData())
A drop target that can work with windows that supports FindClosestDropPoint
.
class DropPointDropTarget(wx.DropTarget): def __init__(self, window, kind): wx.DropTarget.__init__(self) self.window = window self.last_drop_point = None self.rliterate_data = RliterateDataObject(kind) self.DataObject = self.rliterate_data def OnDragOver(self, x, y, defResult): self._hide_last_drop_point() drop_point = self._find_closest_drop_point(x, y) if drop_point is not None and defResult == wx.DragMove: drop_point.Show() self.last_drop_point = drop_point return wx.DragMove return wx.DragNone def OnData(self, x, y, defResult): self._hide_last_drop_point() drop_point = self._find_closest_drop_point(x, y) if drop_point is not None and self.GetData(): wx.CallAfter(self.OnDataDropped, self.rliterate_data.get_json(), drop_point) return defResult def OnLeave(self): self._hide_last_drop_point() def _find_closest_drop_point(self, x, y): return self.window.FindClosestDropPoint( self.window.ClientToScreen((x, y)) ) def _hide_last_drop_point(self): if self.last_drop_point is not None: self.last_drop_point.Hide() self.last_drop_point = None
If wx.CallAfter
is not used when calling OnDataDropped
, the application will not close propery. Not sure why, but this works for now.
panel DividerGui %vbox { #padding #height #left_space #active BoxSpace(self.padding) panel(min_size=tuple([-1 self.height]) %expand) %hbox { BoxSpace(self.left_space) panel( background = "#ff6400" visible = self.active %expand %proportion[1] ) %hbox {} } BoxSpace(self.padding) @right_click = SimpleContextMenu.ShowRecursive(self) }
class Divider(DividerGui): DEFAULTS = { "padding": 0, "height": 1, "left_space": 0, "active": False, } def Show(self, left_space=0): self.UpdateGui({"left_space": left_space, "active": True}) def Hide(self): self.UpdateGui({"active": False})
The text projection editor is an editor for something that can be projected as text. It is optimized for making as few calls to a DC as possible since those are expensive.
panel TextProjectionGui %vbox { #characters #line_height #max_width #break_at_word #font @paint = self._on_paint(event) @timer = self._on_timer(event) }
class Character(namedtuple("Character", "text style marker extra")): @classmethod def create(cls, text, style, marker=None, extra=None): return cls(text, style, marker, extra)
class Box(object): def __init__(self, char): self.char = char self.rect = wx.Rect(0, 0, 0, 0)
class TextProjection(TextProjectionGui): DEFAULTS = { "characters": [], "line_height": 1, "max_width": None, "break_at_word": True, "font": None, } <<TextProjection>>
def _create_gui(self): TextProjectionGui._create_gui(self) self.timer = wx.Timer(self) self._show_beams = True self._update_gui() def _update_gui(self): TextProjectionGui._update_gui(self) if self.has_changes: self._show_beams = True self._layout() if self._markers: self.timer.Start(400) else: self.timer.Stop()
def _on_timer(self, event): self._show_beams = not self._show_beams self.UpdateGui()
def _layout(self): self._partition_characters() self._measure_character_size() self._calculate_lines() self._partition_strings() self._set_min_size()
def _partition_characters(self): self._boxes = [] self._boxes_by_style = defaultdict(list) self._markers = [] for character in self.values["characters"]: box = Box(character) self._boxes.append(box) self._boxes_by_style[character.style].append(box) if character.marker is not None: self._markers.append(box)
def _measure_character_size(self): dc = wx.MemoryDC() dc.SetFont(self.GetFont()) dc.SelectObject(wx.EmptyBitmap(1, 1)) self._line_height_pixels = int(round(dc.GetTextExtent("M")[1]*self.values["line_height"])) for style, boxes in self._boxes_by_style.iteritems(): style.apply_to_wx_dc(dc, self.GetFont()) for box in boxes: box.rect.Size = dc.GetTextExtent(box.char.text)
def _calculate_lines(self): self._lines = [] current_line = [] x, y = 0, 0 index = 0 previous_box, break_index, break_len = None, None, None while index < len(self._boxes): box = self._boxes[index] if previous_box and previous_box.char.text == " " and box.char.text != " ": break_index, break_len = index, len(current_line) if (box.char.text == "\n" or (x > 0 and self.values["max_width"] is not None and x+box.rect.Width > self.values["max_width"])): x = 0 y += self._line_height_pixels if box.char.text != "\n" and break_index is not None and self.values["break_at_word"]: index = break_index box = self._boxes[index] current_line = current_line[:break_len] self._lines.append(current_line) current_line = [] previous_box, break_index, break_len = None, None, None box.rect.Position = (x, y) x += box.rect.Width if box.char.text != "\n": current_line.append(box) previous_box = box index += 1 if current_line: self._lines.append(current_line)
def _partition_strings(self): self._strings_by_style = defaultdict(lambda: ([], [])) def push(): if text: strings, poss = self._strings_by_style[style] strings.append("".join(text)) poss.append(position) for line in self._lines: style = None position = None text = [] for box in line: if style is None or style != box.char.style: push() style = box.char.style position = box.rect.Position text = [] text.append(box.char.text) push()
def _set_min_size(self): max_x = 10 max_y = self._line_height_pixels for line in self._lines: for box in line: max_x = max(max_x, box.rect.X+box.rect.Width) max_y = max(max_y, box.rect.Y+box.rect.Height) self.SetMinSize((max_x, max_y))
def _on_paint(self, event): dc = wx.PaintDC(self) for style, strings_positions in self._strings_by_style.iteritems(): style.apply_to_wx_dc(dc, self.GetFont()) dc.DrawTextList(*strings_positions) if self._show_beams: THICKNESS = 2 CURVE_SIZE = 3 dc.SetPen(wx.Pen(wx.Colour(255, 50, 0, 180), width=THICKNESS, style=wx.PENSTYLE_SOLID)) for box in self._markers: if box.char.marker == "beam_left_flag": dc.DrawLines([ box.rect.TopLeft + (THICKNESS/2+CURVE_SIZE, 1 ), box.rect.TopLeft + (THICKNESS/2+0, 1+CURVE_SIZE), box.rect.BottomLeft + (THICKNESS/2+0, -CURVE_SIZE), box.rect.BottomLeft + (THICKNESS/2+CURVE_SIZE, 0), ]) elif box.char.marker == "beam_left": dc.DrawLines([ box.rect.TopLeft + (THICKNESS/2, 0), box.rect.BottomLeft + (THICKNESS/2, 0), ]) elif box.char.marker == "beam_right": dc.DrawLines([ box.rect.TopRight - (THICKNESS/2, 0), box.rect.BottomRight - (THICKNESS/2, 0), ]) elif box.char.marker == "beam_right_flag": dc.DrawLines([ box.rect.TopRight + (-THICKNESS/2-CURVE_SIZE, 1 ), box.rect.TopRight + (-THICKNESS/2+0, 1+CURVE_SIZE), box.rect.BottomRight + (-THICKNESS/2+0, -CURVE_SIZE), box.rect.BottomRight + (-THICKNESS/2-CURVE_SIZE, 0), ])
def GetCharacterAt(self, position): for box in self._boxes: if box.rect.Contains(position): return box.char
def GetCharacterAtIndex(self, index): if index < 0 or index >= len(self._boxes): return None return self._boxes[index].char
def GetClosestCharacter(self, position): box = self._get_closest_box(position) if box is None: return None else: return box.char
def GetFont(self): font = self.values["font"] if font is None: return TextProjectionGui.GetFont(self) else: return font
def GetClosestCharacterWithSide(self, position): box = self._get_closest_box(position) if box is None: return (None, None) elif position.x < (box.rect.X + box.rect.Width/2): return (box.char, wx.LEFT) else: return (box.char, wx.RIGHT)
def _get_closest_box(self, position): if len(self._boxes) == 0: return None characters_by_y_distance = defaultdict(list) for box in self._boxes: if box.rect.Contains(position): characters_by_y_distance[0] = [box] break characters_by_y_distance[ abs(box.rect.Y + int(box.rect.Height / 2) - position.y) ].append(box) return min( characters_by_y_distance[min(characters_by_y_distance.keys())], key=lambda box: abs(box.rect.X + int(box.rect.Width / 2) - position.x) )
panel TextProjectionEditorGui %hbox { #projection #line_height #max_width #break_at_word #font #tooltip TextProjection[text]( characters = self.projection.get_characters(self) line_height = self.line_height max_width = self.max_width break_at_word = self.break_at_word font = self.font tooltip = self.tooltip focus = self.projection.expects_input @char = self.projection.handle_key(event) ) }
class TextProjectionEditor(TextProjectionEditorGui): DEFAULTS = { "line_height": 1, "max_width": None, "break_at_word": True, "font": None, "tooltip": None, } <<TextProjectionEditor2>>
def Select(self, project, pos): selection = self.GetClosestSelection(pos) if selection: project.selection = selection
def GetClosestSelection(self, pos): character, side = self.text.GetClosestCharacterWithSide(pos) if character is None: pass elif side == wx.LEFT and "left_selection" in character.extra: return character.extra["left_selection"] elif side == wx.RIGHT and "right_selection" in character.extra: return character.extra["right_selection"]
def GetExtraAt(self, pos): character = self.text.GetClosestCharacter(pos) if character is not None: return character.extra
def FindClosestSelection(self, character_selection, direction): if character_selection is None: return char_index, side = character_selection return self._get_first_selection(char_index, side, direction) def _get_first_selection(self, index, side, direction): first_selection = None for selection in self._yield_selections(index, side, direction): if selection is not None: if first_selection is None: first_selection = selection elif selection != first_selection: return selection def _yield_selections(self, index, side, direction): first = True while True: char = self.text.GetCharacterAtIndex(index) if char is None: break if direction == "left": if first: if side == "right": yield char.extra.get("right_selection", None) yield char.extra.get("left_selection", None) else: yield char.extra.get("right_selection", None) yield char.extra.get("left_selection", None) index -= 1 else: if first: if side == "left": yield char.extra.get("left_selection", None) yield char.extra.get("right_selection", None) else: yield char.extra.get("left_selection", None) yield char.extra.get("right_selection", None) index += 1 first = False
class BaseProjection(object): old_memo = {} new_memo = {} @property def expects_input(self): return self._key_handler is not None def handle_key(self, event): return self._key_handler.handle_key(event) def get_characters(self, editor): self._key_handler = None self._character_selection = None self.characters = [] self.new_memo = {} self.create_projection(editor) self.old_memo = self.new_memo return self.characters @rltime("text projection add") def add(self, text, style, selection=None, path=None, one_selection=None, flag=False, extra={}): key = (text, style, selection, path, one_selection, flag, extra) memo_key, memo_value = self.old_memo.get(len(self.characters), (None, None)) if memo_key != key: characters = [] character_selection = None for index, char in enumerate(text): if index == 0 and selection == 0: marker = "beam_left" if flag: marker = "beam_left_flag" character_selection = (len(self.characters)+len(characters), "left") elif index == len(text) - 1 and selection == index + 1: marker = "beam_right" if flag: marker = "beam_right_flag" character_selection = (len(self.characters)+len(characters), "right") elif index == selection: marker = "beam_left" character_selection = (len(self.characters)+len(characters), "left") else: marker = None extra = dict(extra) if path is not None: extra["left_selection"] = path.create(index) extra["right_selection"] = path.create(index+1) elif one_selection is not None: extra["left_selection"] = one_selection extra["right_selection"] = one_selection characters.append(Character.create( char, style, marker, extra=extra )) memo_value = (characters, character_selection) self.new_memo[len(self.characters)] = (key, memo_value) self.characters.extend(memo_value[0]) if memo_value[1] is not None: self._character_selection = memo_value[1]
class NavigationKeyHandler(object): def __init__(self, editor, project, character_selection): self.editor = editor self.project = project self.character_selection = character_selection def handle_key(self, event): selection = None if event.GetKeyCode() == wx.WXK_LEFT: selection = self.editor.FindClosestSelection(self.character_selection, "left") if event.GetKeyCode() == wx.WXK_RIGHT: selection = self.editor.FindClosestSelection(self.character_selection, "right") if selection is not None: self.project.selection = selection
class PlainTextKeyHandler(NavigationKeyHandler): def __init__(self, editor, project, character_selection, text, index): NavigationKeyHandler.__init__(self, editor, project, character_selection) self.editor = editor self.text = text self.index = index def handle_key(self, event): if event.GetUnicodeKey() >= 32: self.save( self.text[:self.index]+unichr(event.GetUnicodeKey())+self.text[self.index:], self.index+1 ) elif event.GetKeyCode() == wx.WXK_BACK and self.index > 0: self.save( self.text[:self.index-1]+self.text[self.index:], self.index-1 ) elif event.GetKeyCode() == wx.WXK_DELETE and self.index < len(self.text): self.save( self.text[:self.index]+self.text[self.index+1:], self.index ) else: NavigationKeyHandler.handle_key(self, event)
The token view widget is a temporary wrapper around the text projection widget.
class TokenView(TextProjection): def __init__(self, parent, values): TextProjection.__init__( self, parent, { "characters": self._generate_characters(values["project"], values["tokens"]), "line_height": values.get("line_height", 1), "max_width": values.get("max_width", 100), "break_at_word": values.get("skip_extra_space", False), "font": values.get("font"), } ) self.last_tokens = [] def UpdateGui(self, values, layout=True): values["characters"] = self._generate_characters(values["project"], values["tokens"]) TextProjection.UpdateGui(self, values, layout) def _generate_characters(self, project, tokens): characters = [] for token in tokens: style = project.get_style(token.token_type) if token.extra.get("highlight"): style = style.highlight() for subtoken in token.split(): for char in subtoken.text: characters.append(Character.create( char, style, extra=subtoken )) return characters <<TokenView>>
The Token
class:
class Token(object): SPLIT_PATTERNS = [ re.compile(r"\n"), re.compile(r"\s+"), re.compile(r"\S+"), ] def __init__(self, text, token_type=TokenType.RLiterate, index=0, **extra): self.text = text self.token_type = token_type self.extra = extra self.index = index def __eq__(self, other): return ( isinstance(other, Token) and self.text == other.text and self.token_type == other.token_type and self.extra == other.extra and self.index == other.index ) def __ne__(self, other): return not (self == other) def with_extra(self, key, value): self.extra[key] = value return self def is_newline(self): return self.text == "\n" def is_space(self): return re.match(r"^\s+$", self.text) def split(self): index = self.index subtokens = [] text = self.text while text: for split_pattern in self.SPLIT_PATTERNS: match = split_pattern.match(text) if match: subtokens.append(self._replace_text(match.group(0), index)) text = text[match.end(0):] index += len(match.group(0)) return subtokens def _replace_text(self, text, index): return Token( text=text, token_type=self.token_type, index=index, **self.extra )
def GetToken(self, position): character = self.GetCharacterAt(position) if character is not None: return character.extra
The default scrolling window gives extra space. See https://stackoverflow.com/a/22817659. This custom control solves this problem.
class CompactScrolledWindow(wx.ScrolledWindow): MIN_WIDTH = 200 MIN_HEIGHT = 200 def __init__(self, parent, style=0, size=wx.DefaultSize, step=100): w, h = size size = (max(w, self.MIN_WIDTH), max(h, self.MIN_HEIGHT)) wx.ScrolledWindow.__init__(self, parent, style=style, size=size) self.Size = size if style == wx.HSCROLL: self.SetScrollRate(1, 0) self._calc_scroll_pos = self._calc_scroll_pos_hscroll elif style == wx.VSCROLL: self.SetScrollRate(0, 1) self._calc_scroll_pos = self._calc_scroll_pos_vscroll else: self.SetScrollRate(1, 1) self._calc_scroll_pos = self._calc_scroll_pos_vscroll self.step = step self.Bind(wx.EVT_MOUSEWHEEL, self._on_mousewheel) def _on_mousewheel(self, event): x, y = self.GetViewStart() delta = event.GetWheelRotation() / event.GetWheelDelta() self.Scroll(*self._calc_scroll_pos(x, y, delta)) def _calc_scroll_pos_hscroll(self, x, y, delta): return (x+delta*self.step, y) def _calc_scroll_pos_vscroll(self, x, y, delta): return (x, y-delta*self.step) <<CompactScrolledWindow>>
The minimum size is set to prevent the following error:
(rliterate.py:23983): Gtk-CRITICAL **: gtk_box_gadget_distribute: assertion 'size >= 0' failed in GtkScrollbar
def ScrollToBeginning(self): self.Scroll(0, 0) def ScrollToEnd(self): self.Scroll(*(self.GetSizer().Size - self.Size))
def create_font(monospace=False, size=10, bold=False): return wx.Font( size, wx.FONTFAMILY_TELETYPE if monospace else wx.FONTFAMILY_DEFAULT, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_BOLD if bold else wx.FONTWEIGHT_NORMAL, False )
def show_text_entry(parent, **kwargs): dialog = wx.TextEntryDialog( parent, message=kwargs.get("body", ""), caption=kwargs.get("title", ""), defaultValue=kwargs.get("value", "") ) if dialog.ShowModal() == wx.ID_OK: kwargs.get("ok_fn", lambda value: None)(dialog.Value) dialog.Destroy()
class SimpleContextMenu(wx.Menu): def __init__(self, name): wx.Menu.__init__(self) self._name = name def AppendItem(self, text, fn): self.Bind( wx.EVT_MENU, lambda event: fn(), self.Append(wx.NewId(), text) ) def AddToMenu(self, menu): menu.AppendSubMenu(self, self._name) def Popup(self, widget): self._append_parents(widget) widget.PopupMenu(self) self.Destroy() def _append_parents(self, widget): parent = widget.Parent sub_menus = [] while parent is not None: if hasattr(parent, "CreateContextMenu"): sub_menus.append(parent.CreateContextMenu()) parent = parent.Parent if sub_menus: self.AppendSeparator() for x in sub_menus: x.AddToMenu(self) @staticmethod def ShowRecursive(widget): while widget is not None: if hasattr(widget, "CreateContextMenu"): widget.CreateContextMenu().Popup(widget) return widget = widget.Parent
All GUI widgets are (or should be) written in a custom language. That is compiled to Python code for a specific GUI framework. In this case it is wxPython.
As soon as something changes in the project, many widgets are re-rendered. Instead of passing information of what changed, the widgets are just notified that something changed. It is up to the widgets to "figure out" what has changed and find an optimal way to redraw itself.
GuiParser { widget = | containerType:x name:y boxType:z ws '{' body*:as ws '}' ws !. -> [x y [z as]] body = | derived | variable | handler | child variable = | ws '#' name:x -> ["variable" x ] derived = | ws '#' name:x ws '=' expr:y -> ["derived" x y] box = | boxType:x ws '{' child*:xs ws '}' -> [x xs] boxType = | ws '%hbox' !nameChar -> "hbox" | ws '%vbox' !nameChar -> "vbox" child = | ws 'BoxSpace' ws '(' expr:x ws ')' -> ["boxspace" x] | containerType:x binding:y ws '(' (parameter|handler|sizerprop)*:zs ws ')' box:ws -> ["child" x y zs [ws]] | !reserved expr:x binding:y ws '(' (parameter|handler|sizerprop)*:zs ws ')' -> ["child" x y zs []] | for ws '(' expr:x ws ')' ws '{' child*:zs ws '}' -> ["loop" x ~zs] binding = | '[' name:x ']' -> ["binding" x] | -> [] parameter = | name:x ws '=' expr:y -> ["parameter" x y] handler = | ws '@' name:x ws '=' expr:y -> ["handler" x y] sizerprop = | ws '%border[' expr:x ws ',' borderSide:y ws ']' -> ["sizerBorder" x y] | ws '%proportion[' expr:x ws ']' -> ["sizerProportion" x] | ws '%expand' !nameChar -> ["sizerExpand"] | ws '%reserve_space_even_if_hidden' !nameChar -> ["sizerReserve"] | ws '%align_center' !nameChar -> ["sizerAlignCenter"] | ws '%align_center_vertical' !nameChar -> ["sizerAlignCenterVertical"] borderSide = | side:x (ws '|' side)*:xs -> [x ~xs] expr = | expr1:x (ws '.' expr1)*:xs -> ["get" x ~xs] | ws '"' (!'"' .)*:xs '"' -> ["string" join(xs)] | ws ("-" | -> ""):x '0'-'9':y ('0'-'9')*:ys -> ["int" int(join([x y ~ys]))] | ws '[' expr*:xs ws ']' -> ["list" ~xs] expr1 = | expr2:x ws '(' expr*:xs ws ')' -> ["call" x ~xs] | expr2 expr2 = | name:x -> ["name" x] reserved = containerType | for for = ws 'for' !nameChar side = ws side2:x !nameChar -> x side2 = 'ALL' | 'LEFT' | 'RIGHT' | 'TOP' | 'BOTTOM' containerType = ws containerType2:x !nameChar -> x containerType2 = 'frame' | 'dialog' | 'panel' | 'scroll' | 'vscroll' | 'hscroll' name = ws nameStart:x nameChar*:xs -> join([x ~xs]) nameStart = alpha | '_' nameChar = alphanum | '_' alpha = 'a'-'z' | 'A'-'Z' alphanum = alpha | '0'-'9' ws = (' ' | '\n')* }
WxGuiCodeGenerator { ast = <<WxCodeGenerator/ast>> <<WxCodeGenerator/rules>> border = | "ALL" -> { "sizer[\"flag\"] |= wx.ALL\n" } | "LEFT" -> { "sizer[\"flag\"] |= wx.LEFT\n" } | "RIGHT" -> { "sizer[\"flag\"] |= wx.RIGHT\n" } | "TOP" -> { "sizer[\"flag\"] |= wx.TOP\n" } | "BOTTOM" -> { "sizer[\"flag\"] |= wx.BOTTOM\n" } boxType = | "hbox" -> "wx.HORIZONTAL" | "vbox" -> "wx.VERTICAL" astGetList = | ast:x astGetList:y -> { x "." y } | ast astList = astEmptyList | astNonEmptyList astEmptyList = !. -> {} astNonEmptyList = | ast:x astNonEmptyList:y -> { x ", " y } | ast:x binding = | ["binding" .:x] -> { "if parent.inside_loop:\n" > "parent.add_loop_var(" repr(x) ", widget.widget)\n" < "else:\n" > "self." x " = widget.widget\n" < } | . -> {} }
| [topLevel:x .:name ast:y] -> { "class " name "(" x "):\n" > "\ndef _get_derived(self):\n" > "return {\n" > #derived < "}\n" < "\ndef _create_gui(self):\n" > "self._root_panel = GuiFrameworkPanel(self, {})\n" "self.Sizer = wx.BoxSizer(wx.HORIZONTAL)\n" "self.Sizer.Add(self._root_panel, flag=wx.EXPAND, proportion=1)\n" "self._root_widget = GuiFrameworkWidgetInfo(self._root_panel, self)\n" "self._root_panel.SetFocus()\n" "self._child_root(self._root_widget, first=True)\n" < "\ndef _update_gui(self):\n" > "self._root_panel.SetFocus()\n" "self._child_root(self._root_widget)\n" < "\ndef _child_root(self, parent, loopvar=None, first=False):\n" > "parent.reset()\n" "handlers = []\n" #child "if first:\n" > "parent.listen(handlers)\n" < < #methods y < }
When a frame re-renders, the widget currently having focus might be removed. That leaves no widget focused. If no widget is focused, keyboard shortcuts don't work. (This one took me a few hours to figure out.) Therefore the root panel is always given focus. If child widgets re-rendered later wants focus they will take it.
We put all components in a panel, and let the panel be the only child of the top level window. Otherwise tab cycling will not work properly. Reference?
| [widget:x .:name ast:y] -> { "class " name "(" x "):\n" > "\ndef _get_derived(self):\n" > "return {\n" > #derived < "}\n" < "\ndef _create_gui(self):\n" > "self._root_widget = GuiFrameworkWidgetInfo(self)\n" "self._child_root(self._root_widget, first=True)\n" < "\ndef _update_gui(self):\n" > "self._child_root(self._root_widget)\n" < "\ndef _child_root(self, parent, loopvar=None, first=False):\n" > "parent.reset()\n" "handlers = []\n" #child "if first:\n" > "parent.listen(handlers)\n" < < #methods y < }
| ["child" (widget|ast):x binding:y [ast*:zs] [ast*:ws]] -> { %%child @child { "self." %child "(parent, loopvar)\n" } @methods { "\ndef " %child "(self, parent, loopvar):\n" > "handlers = []\n" "properties = {}\n" "sizer = {\"flag\": 0, \"border\": 0, \"proportion\": 0}\n" #child < zs @child { "widget = parent.add(" x ", properties, handlers, sizer)\n" y "parent = widget\n" "parent.reset()\n" } #methods ws } }
| ["loop" ast:x ast*:zs] -> { @child { "parent.loop_start()\n" "try:\n" > "for loopvar in " x ":\n" > "pass\n" } zs @child { << "finally:\n" > "parent.loop_end(self)\n" < } }
| [boxType:x [ast*:ys]] -> { %%sizer @child { "parent.sizer = wx.BoxSizer(" x ")\n" } ys }
| ["body" [ast*:xs] ast:y [ast*:zs]] -> { xs y zs } | ["boxspace" ast:x] -> { @child { "parent.add_space(" x ")\n" } } | ["parameter" .:x ast:y] -> { @child { "properties[" repr(x) "] = " y "\n" } } | ["handler" .:x ast:y] -> { @child { "handlers.append((" repr(x) ", lambda event: " y "))\n" } } | ["get" astGetList:x] -> { x } | ["call" ast:x astList:y] -> { x "(" y ")" } | ["name" .:x] -> { x } | ["variable" .:x] -> { "\n@property\n" "def " x "(self):\n" > "return self.values[\"" x "\"]\n" < } | ["derived" .:x ast:y] -> { @derived { repr(x) ": " y ",\n" } } | ["string" .:x] -> { repr(x) } | ["int" .:x] -> { repr(x) } | ["list" astList:x] -> { "[" x "]" } | ["sizerBorder" ast:x [border*:y]] -> { @child { "sizer[\"border\"] = " x "\n" y } } | ["sizerProportion" ast:x] -> { @child { "sizer[\"proportion\"] = " x "\n" } } | ["sizerExpand"] -> { @child { "sizer[\"flag\"] |= wx.EXPAND\n" } } | ["sizerReserve"] -> { @child { "sizer[\"flag\"] |= wx.RESERVE_SPACE_EVEN_IF_HIDDEN\n" } } | ["sizerAlignCenter"] -> { @child { "sizer[\"flag\"] |= wx.ALIGN_CENTER\n" } } | ["sizerAlignCenterVertical"] -> { @child { "sizer[\"flag\"] |= wx.ALIGN_CENTER_VERTICAL\n" } }
widget = | "panel" -> "GuiFrameworkPanel" | "scroll" -> "GuiFrameworkScroll" | "vscroll" -> "GuiFrameworkVScroll" | "hscroll" -> "GuiFrameworkHScroll" topLevel = | "frame" -> "GuiFrameworkFrame" | "dialog" -> "GuiFrameworkDialog"
class GuiFrameworkBaseMixin(object): DEFAULTS = {} def __init__(self, values={}): self.values = {} self.values.update(self.DEFAULTS) self.values.update(values) self.values.update(self._get_derived()) self.changed = None self._handlers = {} self.down_pos = None self._default_cursor = self.GetCursor() self.Bind(wx.EVT_LEFT_DOWN, self._on_left_down) self.Bind(wx.EVT_MOTION, self._on_motion) self.Bind(wx.EVT_LEFT_UP, self._on_left_up) self.Bind(wx.EVT_LEFT_DCLICK, self._on_left_dclick) self.Bind(wx.EVT_RIGHT_UP, self._on_right_up) self.Bind(wx.EVT_ENTER_WINDOW, self._on_enter) self.Bind(wx.EVT_LEAVE_WINDOW, self._on_leave) self._create_gui() self._update_builtin(self.values.keys()) def _on_enter(self, event): self._call_handler("enter", event, propagate=True) event.Skip() def _on_leave(self, event): self._call_handler("leave", event, propagate=True) event.Skip() def listen(self, event, handler): if event in self._handlers: raise Exception("only one handler per event allowed") self._handlers[event] = handler if event == "char": self.Bind(wx.EVT_CHAR, lambda event: self._call_handler("char", event)) elif event == "paint": self.Bind(wx.EVT_PAINT, lambda event: self._call_handler("paint", event)) elif event == "timer": self.Bind(wx.EVT_TIMER, lambda event: self._call_handler("timer", event)) elif event == "close": self.Bind(wx.EVT_CLOSE, lambda event: self._call_handler("close", event)) @property def has_changes(self): return self.changed is None or len(self.changed) > 0 def UpdateGui(self, values={}, layout=True): self.changed = [] self._update(values) self._update(self._get_derived()) if layout: with flicker_free_drawing(self): self._update_gui() self._update_builtin(self.changed) self.Layout() self.Refresh() else: self._update_gui() self._update_builtin(self.changed) def _update(self, values): for key, value in values.items(): if key not in self.values or self.values[key] != value: self.values[key] = value self.changed.append(key) def did_change(self, name): if self.changed is None: return name in self.values else: return name in self.changed def _get_derived(self): return {} def _create_gui(self): pass def _update_gui(self): pass def _update_builtin(self, names): for name in names: if name == "tooltip": value = self.values.get("tooltip", None) if value is None: self.UnsetToolTip() else: self.SetToolTipString(value) if name == "cursor": self.SetCursor({ "hand": wx.StockCursor(wx.CURSOR_HAND), "beam": wx.StockCursor(wx.CURSOR_IBEAM), None: self._default_cursor, }.get(self.values["cursor"])) if name == "min_size": self.SetMinSize(self.values["min_size"]) if name == "label": self.SetLabel(self.values["label"]) if name == "visible": self.Show(self.values["visible"]) if name == "background": self.SetBackgroundColour(self.values["background"]) if name == "enabled": self.Enable(self.values["enabled"]) if self.values.get("focus", False) and not self.HasFocus(): self.SetFocus() def _on_left_down(self, event): self.down_pos = event.Position event.Skip() def _on_motion(self, event): if self.down_pos is None: self._call_handler("mouse_move", event, propagate=True) if self._should_drag(event.Position): self.down_pos = None self._call_handler("drag", event, propagate=True) def _should_drag(self, pos): if self.down_pos is not None: diff = self.down_pos - pos if abs(diff.x) > 2: return True if abs(diff.y) > 2: return True return False def _on_left_up(self, event): if self.down_pos is not None: if event.GetModifiers() == wx.MOD_CONTROL: self._call_handler("ctrl_click", event, propagate=True) else: self._call_handler("click", event, propagate=True) self.down_pos = None def _on_left_dclick(self, event): self._call_handler("double_click", event, propagate=True) def _on_right_up(self, event): self._call_handler("right_click", event, propagate=True) def _call_handler(self, name, event, propagate=False): if name in self._handlers: self._handlers[name](event) elif propagate and isinstance(self.Parent, GuiFrameworkBaseMixin): self.Parent._call_handler(name, event, propagate=propagate) def CallHandler(self, name, event): self._call_handler(name, event, propagate=True) def ShowModal(self, name, values): dialog = name(self, values) dialog.ShowModal() dialog.Destroy()
class GuiFrameworkWidgetInfo(object): def __init__(self, widget, handler_widget=None): self.widget = widget if handler_widget is None: self.handler_widget = widget else: self.handler_widget = handler_widget self.children = [] self.reset() def reset(self): self.sizer_index = 0 self.child_index = 0 self.inside_loop = False self.vars = defaultdict(list) def loop_start(self): self.inside_loop = True if self.child_index >= len(self.children): self.children.append([]) self.old_children = self.children self.next_index = self.child_index + 1 self.children = self.children[self.child_index] self.child_index = 0 def add_loop_var(self, name, value): self.vars[name].append(value) def loop_end(self, parent): while self.child_index < len(self.children): self.children.pop(-1).widget.Destroy() self.children = self.old_children self.child_index = self.next_index self.inside_loop = False for name, values in self.vars.items(): setattr(parent, name, values) @property def sizer(self): return self.widget.Sizer @sizer.setter def sizer(self, value): if self.widget.Sizer is None: self.widget.Sizer = value def add(self, widget_cls, properties, handlers, sizer): if self.child_index >= len(self.children) or (self.inside_loop and type(self.children[self.child_index].widget) != widget_cls): widget = widget_cls(self.widget, properties) self.sizer.Insert(self.sizer_index, widget, **sizer) widget_info = GuiFrameworkWidgetInfo(widget) widget_info.listen(handlers) self.children.insert(self.child_index, widget_info) else: widget_info = self.children[self.child_index] widget_info.widget.UpdateGui(properties, layout=False) self.child_index += 1 self.sizer_index += 1 return widget_info def add_space(self, space): if self.child_index >= len(self.children): x = self.sizer.Insert(self.sizer_index, self._get_space_size(space)) self.children.insert(self.child_index, x) else: self.children[self.child_index].SetMinSize(self._get_space_size(space)) self.sizer_index += 1 self.child_index += 1 def _get_space_size(self, size): if self.sizer.Orientation == wx.HORIZONTAL: return (size, 1) else: return (1, size) def listen(self, event_handlers): for event_handler in event_handlers: self.handler_widget.listen(*event_handler)
class GuiFrameworkTopBaseMixin(GuiFrameworkBaseMixin): def _update_builtin(self, names): GuiFrameworkBaseMixin._update_builtin(self, names) if self.did_change("title"): self.SetTitle(self.values["title"]) def set_keyboard_shortcuts(self, shortcuts): def create_handler(condition_fn, action_fn): def handler(event): if condition_fn(): action_fn() return handler entries = [] for shortcut in shortcuts: flags = wx.ACCEL_NORMAL if shortcut.get("ctrl", False): flags |= wx.ACCEL_CTRL key = shortcut.get("key", "") if len(key) == 1: keycode = ord(key) else: keycode = { "esc": wx.WXK_ESCAPE, }[key] command = wx.NewId() fn = create_handler( shortcut.get("condition_fn", lambda: True), shortcut.get("action_fn", lambda: None) ) self.Bind(wx.EVT_MENU, fn, id=command) entries.append(wx.AcceleratorEntry(flags, keycode, command)) self.SetAcceleratorTable(wx.AcceleratorTable(entries))
class GuiFrameworkFrame(wx.Frame, GuiFrameworkTopBaseMixin): def __init__(self, parent, values): wx.Frame.__init__(self, parent) GuiFrameworkTopBaseMixin.__init__(self, values)
class GuiFrameworkDialog(wx.Dialog, GuiFrameworkTopBaseMixin): def __init__(self, parent, values): wx.Dialog.__init__(self, parent) GuiFrameworkTopBaseMixin.__init__(self, values)
class GuiFrameworkPanel(wx.Panel, GuiFrameworkBaseMixin): def __init__(self, parent, values): wx.Panel.__init__(self, parent) GuiFrameworkBaseMixin.__init__(self, values)
class GuiFrameworkScroll(CompactScrolledWindow, GuiFrameworkBaseMixin): def __init__(self, parent, values): CompactScrolledWindow.__init__(self, parent) GuiFrameworkBaseMixin.__init__(self, values)
class GuiFrameworkVScroll(CompactScrolledWindow, GuiFrameworkBaseMixin): def __init__(self, parent, values): CompactScrolledWindow.__init__(self, parent, wx.VERTICAL) GuiFrameworkBaseMixin.__init__(self, values)
class GuiFrameworkHScroll(CompactScrolledWindow, GuiFrameworkBaseMixin): def __init__(self, parent, values): CompactScrolledWindow.__init__(self, parent, wx.HORIZONTAL) GuiFrameworkBaseMixin.__init__(self, values)
class Button(wx.Button, GuiFrameworkBaseMixin): def __init__(self, parent, values): wx.Button.__init__(self, parent) GuiFrameworkBaseMixin.__init__(self, values) self.Bind(wx.EVT_BUTTON, lambda event: self._call_handler("button", event, propagate=True))
class Gauge(wx.Gauge, GuiFrameworkBaseMixin): def __init__(self, parent, values): wx.Gauge.__init__(self, parent) GuiFrameworkBaseMixin.__init__(self, values)
class Label(wx.StaticText, GuiFrameworkBaseMixin): def __init__(self, parent, values): wx.StaticText.__init__(self, parent) GuiFrameworkBaseMixin.__init__(self, values)
class Bitmap(wx.StaticBitmap, GuiFrameworkBaseMixin): def __init__(self, parent, values): wx.StaticBitmap.__init__(self, parent) GuiFrameworkBaseMixin.__init__(self, values) def _update_builtin(self, names): GuiFrameworkBaseMixin._update_builtin(self, names) if self.did_change("bitmap"): self.SetBitmap(self.values["bitmap"])
class IconButton(wx.BitmapButton, GuiFrameworkBaseMixin): def __init__(self, parent, values): wx.BitmapButton.__init__( self, parent, bitmap=wx.ArtProvider.GetBitmap( { "add": wx.ART_ADD_BOOKMARK, "back": wx.ART_GO_BACK, "forward": wx.ART_GO_FORWARD, "undo": wx.ART_UNDO, "redo": wx.ART_REDO, "quit": wx.ART_QUIT, "save": wx.ART_FILE_SAVE, }.get(values.get("icon"), wx.ART_QUESTION), wx.ART_BUTTON, (24, 24) ), style=wx.NO_BORDER ) GuiFrameworkBaseMixin.__init__(self, values) self.Bind(wx.EVT_BUTTON, lambda event: self._call_handler("button", event, propagate=True))
This code seems to be needed on Windows only. If many layout operations are done, the GUI otherwise becomes slow and flickers.
@contextlib.contextmanager def flicker_free_drawing(widget): if "wxMSW" in wx.PlatformInfo: widget.Freeze() yield widget.Thaw() else: yield
import sys import pprint <<support>> <<classes>> join = "".join if __name__ == "__main__": parser = GuiParser() wxcodegenerator = WxGuiCodeGenerator() try: ast = None ast = parser.run("widget", sys.stdin.read()) code = wxcodegenerator.run("ast", ast) sys.stdout.write(code) except _MatchError as e: sys.stderr.write(pprint.pformat(ast)) sys.stderr.write("\n") sys.stderr.write(e.describe()) sys.exit(1)
# Placeholder just to generate support library in post processing step
panel Example %vbox { #x #y Button(label=str(["top" self.x]) @click=new_number()) for (list(range(self.x))) { Button(label=str([self.x loopvar])) } for (list(range(self.y))) { Button(label=str([self.y loopvar])) } Button(label="bottom" @click=reset()) }
class Example(GuiFrameworkPanel): def _get_derived(self): return { } def _create_gui(self): self._root_widget = GuiFrameworkWidgetInfo(self) self._child_root(self._root_widget, first=True) def _update_gui(self): self._child_root(self._root_widget) def _child_root(self, parent, loopvar=None, first=False): parent.reset() handlers = [] parent.sizer = wx.BoxSizer(wx.VERTICAL) self._child1(parent, loopvar) parent.loop_start() try: for loopvar in list(range(self.x)): pass self._child2(parent, loopvar) finally: parent.loop_end(self) parent.loop_start() try: for loopvar in list(range(self.y)): pass self._child3(parent, loopvar) finally: parent.loop_end(self) self._child4(parent, loopvar) if first: parent.listen(handlers) def _child1(self, parent, loopvar): handlers = [] properties = {} sizer = {"flag": 0, "border": 0, "proportion": 0} properties['label'] = str(['top', self.x]) handlers.append(('click', lambda event: new_number())) widget = parent.add(Button, properties, handlers, sizer) parent = widget parent.reset() def _child2(self, parent, loopvar): handlers = [] properties = {} sizer = {"flag": 0, "border": 0, "proportion": 0} properties['label'] = str([self.x, loopvar]) widget = parent.add(Button, properties, handlers, sizer) parent = widget parent.reset() def _child3(self, parent, loopvar): handlers = [] properties = {} sizer = {"flag": 0, "border": 0, "proportion": 0} properties['label'] = str([self.y, loopvar]) widget = parent.add(Button, properties, handlers, sizer) parent = widget parent.reset() def _child4(self, parent, loopvar): handlers = [] properties = {} sizer = {"flag": 0, "border": 0, "proportion": 0} properties['label'] = 'bottom' handlers.append(('click', lambda event: reset())) widget = parent.add(Button, properties, handlers, sizer) parent = widget parent.reset() @property def x(self): return self.values["x"] @property def y(self): return self.values["y"]
import random import wx from rliterate import * <<classes>> def new_number(): x = random.randint(0, 8) y = random.randint(0, 8) panel.UpdateGui(x=x, y=y) frame.Layout() def reset(): panel.UpdateGui(x=0, y=0) frame.Layout() app = wx.App() frame = wx.Frame(None) frame.Sizer = wx.BoxSizer(wx.HORIZONTAL) panel = Example(frame, x=2, y=2) frame.Sizer.Add(panel, flag=wx.EXPAND, proportion=1) frame.Show() app.MainLoop()
from pygments.lexer import RegexLexer from pygments.token import * class RLGuiLexer(RegexLexer): name = 'RLGui' aliases = ['rlgui'] filenames = ['*.rlgui'] tokens = { 'root': [ (r'"', String, "string"), (r'[=]', Name.Builtin), (r'#\w+', Keyword.Declaration), (r'@\w+', Name.Exception), (r'%\w+', Comment), (r'.', Text), ], 'string': [ (r'[^"\\]+', String), (r'"', String, "#pop"), ], }
from setuptools import setup setup( name='rlgui_lexer', version='0.1', packages=['rlgui_lexer'], entry_points={ 'pygments.lexers': ['rlgui_lexer=rlgui_lexer:RLGuiLexer'], }, zip_safe=False, )
#!/bin/sh set -e pip install --upgrade --user .
class CodeExpander(object): def __init__(self, document): self.document = document self._parts = OrderedDict() self._ids = {} self._collect_parts(self.document.get_root_page()) def _collect_parts(self, page): for paragraph in page.paragraphs: if paragraph.type == "code": key = ( tuple(paragraph.path.filepath), tuple(paragraph.path.chunkpath), ) if key not in self._parts: self._parts[key] = [] self._parts[key].append(paragraph) self._ids[paragraph.id] = paragraph for child in page.children: self._collect_parts(child) def generate_files(self): for key in self._parts.keys(): filepath = self._get_filepath(key) if filepath: chain = self._expand(self._parts[key]) with open(filepath, "w") as f: f.write("".join(char.value for char in chain)) def expand_id(self, paragraph_id, post_process=True): if paragraph_id in self._ids: return ( self._ids[paragraph_id], self._expand([self._ids[paragraph_id]], post_process=post_process) ) else: chain = CharChain() chain.append("Could not find {}".format(paragraph_id)) return (None, chain) def _expand(self, paragraphs, post_process=True): chain = CharChain() self._render(chain, paragraphs, [], post_process=post_process) chain.align_tabstops() return chain def _render(self, chain, paragraphs, tabstops, post_process, prefix="", blank_lines_before=0): for index, paragraph in enumerate(paragraphs): if index > 0: self._add_text_to_chain("\n"*blank_lines_before, chain, prefix, tabstops) if paragraph.post_process and post_process: try: value = subprocess.Popen( paragraph.post_process, stdin=subprocess.PIPE, stdout=subprocess.PIPE ).communicate( "".join( char.value for char in self.expand_id(paragraph.id, post_process=False)[1] ) )[0] except Exception as e: value = str(e) sys.stderr.write("Command {} failed: {}".format(paragraph.post_process, value)) self._add_text_to_chain(value, chain, prefix, tabstops) else: for fragment in paragraph.fragments: if fragment.type == "chunk": self._render( chain, self._parts[( tuple(paragraph.path.filepath), tuple(paragraph.path.chunkpath)+tuple(fragment.path) )], tabstops, post_process, prefix=prefix+fragment.prefix, blank_lines_before=fragment.blank_lines_before ) elif fragment.type == "variable": self._add_text_to_chain(fragment.name, chain, prefix, tabstops) elif fragment.type == "code": self._add_text_to_chain(fragment.text, chain, prefix, tabstops) elif fragment.type == "tabstop": tabstops.append(fragment.index) while tabstops: chain.mark_tabstop(tabstops.pop()) if chain.tail is not None and chain.tail.value != "\n": chain.append("\n") def _add_text_to_chain(self, text, chain, prefix, tabstops): for char in text: if chain.tail is None or (chain.tail.value == "\n" and char != "\n"): chain.append(prefix) while tabstops: chain.mark_tabstop(tabstops.pop()) chain.append(char) def _get_filepath(self, key): if len(key[0]) > 0 and len(key[1]) == 0: return os.path.join(*key[0]) else: return None
class HTMLBuilder(object): def __init__(self, document, **options): self.document = document self.parts = [] self.generate_toc = options.get("generate_toc", True) self.toc_max_depth = options.get("toc_max_depth", 3) def build(self): self.page(self.document.get_root_page()) return "".join(self.parts) def toc(self, root_page, levels_left): if levels_left > 0 and root_page.children: with self.tag("ul", newlines=False): for page in root_page.children: with self.tag("li"): with self.tag("a", args={"href": "#{}".format(page.id)}): self.escaped(page.title) self.toc(page, levels_left - 1) def page(self, page, level=1): with self.tag(self.header_tag(level)): with self.tag("a", newlines=False, args={"name": page.id}): pass self.escaped(page.title) for paragraph in page.paragraphs: { "text": self.paragraph_text, "quote": self.paragraph_quote, "list": self.paragraph_list, "code": self.paragraph_code, "image": self.paragraph_image, "expanded_code": self.paragraph_expanded_code, }.get(paragraph.type, self.paragraph_unknown)(paragraph) if level == 1 and self.generate_toc: self.toc(page, self.toc_max_depth) for child in page.children: self.page(child, level+1) def header_tag(self, level): if level > 6: return "b" else: return "h{}".format(level) def paragraph_text(self, text): with self.tag("p"): self.tokens(text.tokens) def paragraph_quote(self, text): with self.tag("blockquote"): self.tokens(text.tokens) def paragraph_list(self, paragraph): self.list(paragraph) def list(self, a_list): if a_list.children: with self.tag({"ordered": "ol"}.get(a_list.child_type, "ul")): for item in a_list.children: with self.tag("li"): self.tokens(item.tokens) self.list(item) def tokens(self, tokens): for token in tokens: { TokenType.RLiterate.Emphasis: self.token_emphasis, TokenType.RLiterate.Strong: self.token_strong, TokenType.RLiterate.Code: self.token_code, TokenType.RLiterate.Link: self.token_link, TokenType.RLiterate.Reference: self.token_reference, TokenType.RLiterate.Variable: self.token_variable, }.get(token.token_type, self.token_default)(token) def token_emphasis(self, token): with self.tag("em", newlines=False): self.escaped(token.text) def token_strong(self, token): with self.tag("strong", newlines=False): self.escaped(token.text) def token_code(self, token): with self.tag("code", newlines=False): self.escaped(token.text) def token_link(self, token): with self.tag("a", args={"href": token.extra["url"]}, newlines=False): self.escaped(token.text) def token_reference(self, token): with self.tag("a", args={"href": "#{}".format(token.extra["page_id"])}, newlines=False): with self.tag("em", newlines=False): self.escaped(token.text) def token_variable(self, token): with self.tag("code", newlines=False): self.escaped(token.text) def token_default(self, token): self.escaped(token.text) def paragraph_code(self, code): with self.tag("div", args={"class": "rliterate-code"}): if not code.path.is_empty: with self.tag("div", args={"class": "rliterate-code-header"}): with self.tag("ol", args={"class": "rliterate-code-path"}): for path in code.path.filepath: with self.tag("li", newlines=False): self.escaped(path) for path in code.path.chunkpath: with self.tag("li", newlines=False): with self.tag("span", args={"class": pygments.token.STANDARD_TYPES.get(TokenType.Comment.Preproc, "")}): self.escaped(path) with self.tag("div", args={"class": "rliterate-code-body"}): with self.tag("pre", newlines=False): for token in code.tokens: with self.tag( "span", newlines=False, args={"class": pygments.token.STANDARD_TYPES.get(token.token_type, "")} ): self.escaped(token.text) def paragraph_expanded_code(self, expanded_code): with self.tag("div", args={"class": "rliterate-code"}): with self.tag("div", args={"class": "rliterate-code-body"}): with self.tag("pre", newlines=False): for token in expanded_code.tokens: with self.tag( "span", newlines=False, args={"class": pygments.token.STANDARD_TYPES.get(token.token_type, "")} ): self.escaped(token.text) def paragraph_image(self, paragraph): with self.tag("div", args={"class": "rliterate-image"}): with self.tag("img", args={ "src": "data:image/png;base64,{}".format(paragraph.image_base64) }): pass with self.tag("div", args={"class": "rliterate-image-text"}): with self.tag("p"): self.tokens(paragraph.tokens) def paragraph_unknown(self, paragraph): with self.tag("p"): self.escaped("Unknown paragraph...") @contextlib.contextmanager def tag(self, tag, newlines=True, args={}): args_string = "" if args: args_string = " " + " ".join("{}=\"{}\"".format(k, v) for k, v in args.items()) self.raw("<{}{}>".format(tag, args_string)) yield self.raw("</{}>".format(tag)) if newlines: self.raw("\n\n") def raw(self, text): self.parts.append(text) def escaped(self, text): self.parts.append(xml.sax.saxutils.escape(text))
This generates a file that is suitable for textual diffing.
class DiffBuilder(object): def __init__(self, document): self.document = document def build(self): self.parts = [] self._foo() return "".join(self.parts) def _foo(self): self.pages = [] self._collect_pages(self.document.get_root_page()) self._render_pages() def _collect_pages(self, page): self.pages.append(page) for child in page.children: self._collect_pages(child) def _render_pages(self): for page in sorted(self.pages, key=lambda page: page.id): self._write(page.id) self._write(": ") self._write(page.title) for child in page.children: self._write("\n") self._write(" ") self._write(child.id) self._write(": ") self._write(child.title) self._write("\n\n") for paragraph in page.paragraphs: { "text": self._render_text, "quote": self._render_quote, "list": self._render_list, "code": self._render_code, }.get(paragraph.type, self._render_unknown)(paragraph) def _render_text(self, text): self._wrapped_text(text.text_version) def _render_quote(self, paragraph): self._wrapped_text(paragraph.text_version, indent=4) def _render_list(self, paragraph): self._write(paragraph.text_version) def _wrapped_text(self, text, indent=0): current_line = [] for part in text.replace("\n", " ").split(" "): if len(" ".join(current_line)) > 60-indent: self._write(" "*indent+" ".join(current_line)) self._write("\n") current_line = [] if part.strip(): current_line.append(part.strip()) if current_line: self._write(" "*indent+" ".join(current_line)) self._write("\n") self._write("\n") def _render_code(self, code): self._write(code.path.text_version+":\n\n") for line in code.text_version.splitlines(): self._write(" "+line+"\n") self._write("\n") def _render_unknown(self, paragraph): self._write("Unknown type = "+paragraph.type+"\n\n") def _write(self, text): self.parts.append(text)
class Observable(object): def __init__(self): self._notify_count = 0 self._listeners = [] def listen(self, fn): self._listeners.append(fn) def unlisten(self, fn): self._listeners.remove(fn) @contextlib.contextmanager def notify(self): self._notify_count += 1 try: yield finally: self._notify_count -= 1 self._notify() def notify_forwarder(self): return self._notify def _notify(self): if self._notify_count == 0: for fn in self._listeners: fn()
The JsonSettings
class provides functionality to read and write settings in JSON format.
set
.class JsonSettings(Observable): def __init__(self, settings_dict): Observable.__init__(self) self._settings_dict = settings_dict <<JsonSettings>>
@classmethod def from_file(cls, path): if os.path.exists(path): settings_dict = load_json_from_file(path) else: settings_dict = {} settings = cls(settings_dict) settings.listen(lambda: write_json_to_file( path, settings_dict ) ) return settings
def get(self, path, default=None): keys = path.split(".") return copy.deepcopy( self._dict_at(keys[:-1]).get(keys[-1], default) ) def set(self, path, value): keys = path.split(".") with self.notify(): self._dict_at(keys[:-1], create=True)[keys[-1]] = copy.deepcopy(value) def _dict_at(self, keys, create=False): sub_dict = self._settings_dict while keys: key = keys.pop(0) if key not in sub_dict and create: sub_dict[key] = {} sub_dict = sub_dict.get(key, {}) return sub_dict
@staticmethod def property(path, default=None): return property( fget=lambda self: self.get(path, default), fset=lambda self, value: self.set(path, value) )
def load_json_from_file(path): with open(path, "r") as f: return json.load(f)
def write_json_to_file(path, data): with safely_write_file(path) as f: json.dump( data, f, sort_keys=True, indent=0, separators=(',', ':') )
This functions tries to write safely to a file. The file will either be completely written or not modified at all. It is achieved by first writing to a temporary file and then performing a rename.
@contextlib.contextmanager def safely_write_file(path): with tempfile.NamedTemporaryFile( dir=os.path.dirname(path), prefix=os.path.basename(path) + ".tmp", delete=False ) as tmp: yield tmp os.rename(tmp.name, path)
The History
class maintains a list of values. The current value can be changed with the back
and forward
methods. This is used to implement undo/redo behavior.
class History(object): def __init__(self, initial_value, size=10): self._history = [("", initial_value)] self._history_index = 0 self._new_history_entry = None self._size = size @property def value(self): return self._history[self._history_index][1] @contextlib.contextmanager def new_value(self, name="", modify_fn=copy.deepcopy): if self._new_history_entry is None: self._new_history_entry = (name, modify_fn(self.value)) yield self._new_history_entry[1] self._history = self._history[:self._history_index+1] self._history.append(self._new_history_entry) self._history = self._history[-self._size:] self._history_index = len(self._history) - 1 self._new_history_entry = None else: if modify_fn != copy.deepcopy: self._new_history_entry = ( self._new_history_entry[0], modify_fn(self._new_history_entry[1]) ) yield self._new_history_entry[1] def can_back(self): return self._history_index > 0 def back_name(self): if self.can_back(): return self._history[self._history_index][0] def back(self): if self.can_back(): self._history_index -= 1 def can_forward(self): return self._history_index < (len(self._history) - 1) def forward_name(self): return self._history[self._history_index+1][0] def forward(self): if self.can_forward(): self._history_index += 1
RLTIMES = {} def rltime_reset(): def wrap(fn): def fn_with_timing(*args, **kwargs): global RLTIMES RLTIMES = {} value = fn(*args, **kwargs) print("---") for text, ms in sorted( RLTIMES.items(), key=lambda x: sum(x[1]), reverse=True ): print("{: <25} {: <6} {}ms".format(text, len(ms), sum(ms))) return value if "--profile" in sys.argv: return fn_with_timing else: return fn return wrap def rltime(text): def wrap(fn): def fn_with_timing(*args, **kwargs): t1 = time.time() value = fn(*args, **kwargs) t2 = time.time() global RLTIMES if text not in RLTIMES: RLTIMES[text] = [] RLTIMES[text].append(1000*(t2-t1)) return value if "--profile" in sys.argv: return fn_with_timing else: return fn return wrap
if sys.argv[2:] == ["--html"]: sys.stdout.write(HTMLBuilder(Document.from_file(sys.argv[1])).build()) elif sys.argv[2:] == ["--diff"]: sys.stdout.write(DiffBuilder(Document.from_file(sys.argv[1])).build()) else: app = wx.App() project = Project(sys.argv[1]) main_frame = MainFrame(None, {"project": project}) main_frame.Show() project.listen(lambda: main_frame.UpdateGui({"project": project})) app.MainLoop()
py.test is used for testing.
@pytest.fixture def tmpfile(tmpdir): return str(tmpdir.join("test.rliterate"))
.PHONY: test test: py.test -vv python rliterate.py rliterate.rliterate --diff > /dev/null python rliterate.py rliterate.rliterate --html > /dev/null
.PHONY: watch-test watch-test: inotifywait -m -e close_write *rliterate.py 2>&1 | while read x; do tput reset && make test; done
from collections import defaultdict, namedtuple, OrderedDict import contextlib import json import os import re import subprocess import sys import tempfile import time import uuid import webbrowser import xml.sax.saxutils import StringIO import base64 import copy import Queue import threading import pygments.lexers import pygments.token from pygments.token import Token as TokenType import wx import wx.richtext import wx.lib.newevent
import pytest from rliterate import *
This chapter documents some insights I've had during the development of RLiterate.
DoDragDrop must be called from within an event handler. (Not custom event handler, but actually like mouse down?)
Font must be assigned before setting a label, otherwise size calculations will be wrong? Must investigate further.
sizer.AddSpacer adding space in both directions
This is a small test program to experiment with scrollbars.
class Frame(wx.Frame): def __init__(self): wx.Frame.__init__(self, None) sizer = wx.BoxSizer(wx.VERTICAL) self.container = self._create_scroll_window() sizer.Add(wx.Button(self, label="other")) sizer.Add(self.container) self.SetSizerAndFit(sizer) self.Bind(wx.EVT_SIZE, lambda event: self._print_info("resize")) self._print_info("init") def _create_scroll_window(self): container = wx.ScrolledWindow( self, style=wx.HSCROLL, size=(200, 200) ) container.SetScrollRate(1, 0) sizer = wx.BoxSizer(wx.HORIZONTAL) for index in range(30): sizer.Add( wx.Button(container, label="Button {}".format(index)) ) container.SetSizer(sizer) return container def _print_info(self, header): print("{}:".format(header)) print(" Frame size = {}".format(self.Size)) print(" Sizer size = {}".format(self.container.GetSizer().Size)) print("----")
Findings:
SetSizer
the container scrolls to beginning when the window is activated.SetSizerAndFit
the container behaves more like expected.SetSizerAndFit
also seems to be needed for the controls to lay out properly.import wx <<classes>> app = wx.App() frame = Frame() frame.Show() app.MainLoop()
self.timer = wx.Timer(self) self.Bind(wx.EVT_TIMER, self.onTimer) self.timer.Start(1000) def onTimer(self, evt): print 'Focused window:', wx.Window.FindFocus()
# When to call # if self.project.columns and (self.column_count_changed or self.columns[index].did_change("page_ids")): # wx.CallAfter(self.ScrollToEnd)
CallAfter
is needed becasue the layout is not "ready" at this point, and sizer reports size (0, 0), so ScrollToEnd
does not work.
On removing widgets Removing a widget from its wxPython parent:
Widgets will remove themselves from the sizer when they are destroyed, so yes, it is redundant. Also, widgets will remove themselves from the parent's child list, so callingRemoveChild
is also redundant. IOW, the call towindow.Destroy()
will be sufficient to remove the widget and its resources, and to do the necessary clean-ups in the parent and the sizer.
Literate programming treats any target programming language as an assembly language. RLiterate is not written in Python. It is written in natural language and uses Python as an assembly language.
If I can not clearly explain a part of RLiterate, I probably don't understand it well enough myslef. I might have written code that does sort of what I want, but I don't fully understand it. The act of writing literate programs forces me to understand everything better.
RLiterate started out as a prototype to learn what the program wanted to be. It proved to be useful, and it sparked new ideas of what it might want to be.
At some point it might not be useful to continue work on RLiterate. It might be more appropriate to do a complete rewrite with the learnings found from the first prototype. Instead of continuing building on the first prototype until it becomes a monster, make a new, better, more beautiful version. And continue doing so for version 2, 3, ... and so on.
Random notes of what I might want to work on in the future.
os.rename
does not work if file exists (JSON serialization mechanisms)GetWheelRotation
and GetWheelDelta
"
and '
is replaced when writing code in textboxdefResult
is never wx.DragMove
[[wxdoc:Sizer]]
?<a name=...
for all pages and paragraphscopy.deepcopy
needed? It should be an implementation detail.wx.PopupWindow
be used?GetClosestCharacter
does not report correct Y if height differsself.name
. Correct?enter
/leave
events don't always fire correctlyevent.Skip()
? Only if no handler called?Generated from 5264d77c430991ac026758a8d43ba59697e0aa2e.
What is Rickard working on and thinking about right now?
Every month I write a newsletter about just that. You will get updates about my current projects and thoughts about programming, and also get a chance to hit reply and interact with me. Subscribe to it below.