From b731ab69fad4112b126b1f7586e433eb2f21fbcc Mon Sep 17 00:00:00 2001 From: Blake DeMarcy Date: Tue, 11 Apr 2017 15:31:01 -0500 Subject: [PATCH] primitive, awful text formatting --- clients/network_client.py | 4 +- clients/urwid/main.py | 137 ++++++++++++++++++++++++++++++++++---- server.py | 5 +- src/formatting.py | 123 +++++++++++++++++++++++++++++++--- 4 files changed, 241 insertions(+), 28 deletions(-) diff --git a/clients/network_client.py b/clients/network_client.py index 6da6796..8becb5f 100644 --- a/clients/network_client.py +++ b/clients/network_client.py @@ -408,7 +408,7 @@ class BBJ(object): return response["data"], response["usermap"] - def thread_load(self, thread_id): + def thread_load(self, thread_id, format=None): """ Returns a tuple where [0] is a thread object and [1] is a usermap object. @@ -419,5 +419,5 @@ class BBJ(object): print(usermap[author_id]["user_name"]) print(message["body"]) """ - response = self("thread_load", thread_id=thread_id) + response = self("thread_load", format=format, thread_id=thread_id) return response["data"], response["usermap"] diff --git a/clients/urwid/main.py b/clients/urwid/main.py index eac416d..f1db278 100644 --- a/clients/urwid/main.py +++ b/clients/urwid/main.py @@ -62,6 +62,11 @@ welcome = """>>> Welcome to Bulletin Butter & Jelly! ------------------@ @_________________________________________________________@ """ +colornames = [ + "none", "red", "yellow", "green", "blue", + "cyan", "magenta" +] + colors = [ "\033[1;31m", "\033[1;33m", "\033[1;33m", "\033[1;32m", "\033[1;34m", "\033[1;35m" @@ -90,10 +95,13 @@ class App(object): ("default", "default", "default"), ("bar", "light magenta", "default"), ("button", "light red", "default"), + ("quote", "light green,underline", "default"), ("opt_prompt", "black", "light gray"), ("opt_header", "light cyan", "default"), ("hover", "light cyan", "default"), ("dim", "dark gray", "default"), + ("bold", "default,bold", "default"), + ("underline", "default,underline", "default"), # map the bbj api color values for display ("0", "default", "default"), @@ -258,7 +266,7 @@ class App(object): return [ head, urwid.Divider(), - MessageBody(message["body"]), + urwid.Columns([(self.prefs["max_text_width"], MessageBody(message["body"]))]), urwid.Divider(), urwid.AttrMap(urwid.Divider("-"), "dim") ] @@ -333,7 +341,7 @@ class App(object): if self.mode == "index": self.last_pos = self.loop.widget.body.base_widget.get_focus()[1] self.mode = "thread" - thread, usermap = network.thread_load(thread_id) + thread, usermap = network.thread_load(thread_id, format="sequential") self.usermap.update(usermap) self.thread = thread self.walker.clear() @@ -480,6 +488,11 @@ class App(object): widget.set_text(rendered) + def edit_width(self, editor, content): + self.prefs["max_text_width"] = \ + int(content) if content else 0 + bbjrc("update", **self.prefs) + def options_menu(self): """ @@ -488,14 +501,13 @@ class App(object): """ editor_buttons = [] edit_mode = [] - user_colors = [] if network.user_auth: account_message = "Logged in as %s." % network.user_name - colors = ["None", "Red", "Yellow", "Green", "Blue", "Cyan", "Magenta"] - for index, color in enumerate(colors): + user_colors = [] + for index, color in enumerate(colornames): urwid.RadioButton( - user_colors, color, + user_colors, color.title(), network.user["color"] == index, self.set_color, index) @@ -513,11 +525,11 @@ class App(object): account_stuff = [urwid.Button("Login/Register", on_press=self.relog)] time_box = urwid.Text(self.timestring(time(), "time")) - time_edit = urwid.Edit(edit_text=self.prefs["time"]) + time_edit = Prompt(edit_text=self.prefs["time"]) urwid.connect_signal(time_edit, "change", self.live_time_render, (time_box, "time")) date_box = urwid.Text(self.timestring(time(), "date")) - date_edit = urwid.Edit(edit_text=self.prefs["date"]) + date_edit = Prompt(edit_text=self.prefs["date"]) urwid.connect_signal(date_edit, "change", self.live_time_render, (date_box, "date")) time_stuff = [ @@ -528,7 +540,10 @@ class App(object): date_box, urwid.AttrMap(date_edit, "opt_prompt"), ] - editor_display = urwid.Edit(edit_text=self.prefs["editor"]) + width_edit = urwid.IntEdit(default=self.prefs["max_text_width"]) + urwid.connect_signal(width_edit, "change", self.edit_width) + + editor_display = Prompt(edit_text=self.prefs["editor"]) urwid.connect_signal(editor_display, "change", self.set_new_editor, editor_buttons) for editor in editors: urwid.RadioButton( @@ -564,6 +579,9 @@ class App(object): urwid.Divider(), *time_stuff, urwid.Divider(), + urwid.Text(("button", "Max message width:")), + urwid.AttrMap(width_edit, "opt_prompt"), + urwid.Divider(), urwid.Text(("button", "Text editor:")), urwid.Text("You can type in your own command or use one of these presets."), urwid.Divider(), @@ -587,7 +605,7 @@ class App(object): align="center", valign="middle", width=30, - height=(self.loop.screen_size[1] - 10) + height=("relative", 75) ) @@ -681,8 +699,8 @@ class App(object): self.loop.widget, align="center", valign="middle", - width=self.loop.screen_size[0] - 2, - height=(self.loop.screen_size[1] - 4)) + width=("relative", 90), + height=("relative", 80)) elif self.mode == "thread": self.window_split=True @@ -701,10 +719,87 @@ class App(object): class MessageBody(urwid.Text): - pass + def __init__(self, text_objects): + result = [] + last_directive = None + for paragraph in text_objects: + for directive, body in paragraph: + + if directive in colornames: + color = str(colornames.index(directive)) + result.append((color, body)) + + elif directive in ["underline", "bold"]: + result.append((directive, body)) + + elif directive == "linequote": + if directive != last_directive and result[-1][-1][-1] != "\n": + result.append(("default", "\n")) + result.append(("3", "%s\n" % body.strip())) + + elif directive == "quote": + result.append(("quote", ">>%s" % body)) + + elif directive == "rainbow": + color = 1 + for char in body: + if color == 7: + color = 1 + result.append((str(color), char)) + color += 1 + + else: + result.append(("default", body)) + last_directive = directive + + result.append("\n\n") + result.pop() + super(MessageBody, self).__init__(result) -class FootPrompt(urwid.Edit): +class Prompt(urwid.Edit): + """ + Supports basic bashmacs keybinds. Key casing is + ignored and ctrl/alt are treated the same. Only + character-wise (not word-wise) movements are + implemented. + """ + def keypress(self, size, key): + if not super(Prompt, self).keypress(size, key): + return + elif key[0:4] not in ["meta", "ctrl"]: + return key + + column = self.get_cursor_coords((app.loop.screen_size[0],))[0] + text = self.get_edit_text() + key = key[-1].lower() + + if key == "u": + self.set_edit_pos(0) + self.set_edit_text(text[column:]) + + elif key == "k": + self.set_edit_text(text[:column]) + + elif key == "f": + self.keypress(size, "right") + + elif key == "b": + self.keypress(size, "left") + + elif key == "a": + self.set_edit_pos(0) + + elif key == "e": + self.set_edit_pos(len(text)) + + elif key == "d": + self.set_edit_text(text[0:column] + text[column+1:]) + + return key + + +class FootPrompt(Prompt): def __init__(self, callback, *callback_args): super(FootPrompt, self).__init__() self.callback = callback @@ -728,6 +823,10 @@ class ExternalEditor(urwid.Terminal): self.endpoint = endpoint self.params = params env = os.environ + # barring this, programs will happily spit out unicode chars which + # urwid+python3 seem to choke on. This seems to be a bug on urwid's + # behalf. Users who take issue to programs trying to supress unicode + # should use the options menu to switch to Overthrow mode. env.update({"LANG": "POSIX"}) command = ["bash", "-c", "{} {}; echo Press any key to kill this window...".format( app.prefs["editor"], self.path)] @@ -757,6 +856,16 @@ class ExternalEditor(urwid.Terminal): app.switch_editor() + def __del__(self): + """ + Make damn sure we scoop up after ourselves here... + """ + try: + os.remove(self.path) + except FileNotFoundError: + pass + + class OptionsMenu(urwid.LineBox): def keypress(self, size, key): if key == "esc": diff --git a/server.py b/server.py index 69d295a..4311227 100644 --- a/server.py +++ b/server.py @@ -1,5 +1,5 @@ from src.exceptions import BBJException, BBJParameterError, BBJUserError -from src import db, schema +from src import db, schema, formatting from functools import wraps from uuid import uuid1 import traceback @@ -244,6 +244,9 @@ class API(object): thread = db.thread_get(database, args["thread_id"]) cherrypy.thread_data.usermap = \ create_usermap(database, thread["messages"]) + if args.get("format") == "sequential": + formatting.apply_formatting(thread["messages"], + formatting.sequential_expressions) return thread diff --git a/src/formatting.py b/src/formatting.py index 07250e8..d0a64a3 100644 --- a/src/formatting.py +++ b/src/formatting.py @@ -3,34 +3,135 @@ This module is not complete and none of its functions are currently used elsewhere. Subject to major refactoring. """ -from markdown import markdown -from html import escape +test = """ +This is a small paragraph +thats divided between a +few rows. + +this opens a few linequotes. +>this is a few +>rows of +>sequential line breaks +and this is what follows right after +""" + +# from markdown import markdown +# from html import escape import re -#0, 1 2 3 4 5 6 colors = [ +#0, 1 2 3 4 5 6 "red", "yellow", "green", "blue", "cyan", "magenta" ] markup = [ - "bold", "italic", "underline", "strike" + "bold", "italic", "underline", "linequote", "quote", "rainbow" ] -tokens = re.compile(r"\[(%s): (.+?)]" % "|".join(colors + markup), - flags=re.DOTALL) +# tokens being [red: this will be red] and [bold: this will be bold] +# tokens = re.compile(r"\[(%s): (.+?)]" % "|".join(colors + markup), flags=re.DOTALL) +# quotes being references to other post_ids, like >>34 or >>0 for OP quotes = re.compile(">>([0-9]+)") -linequotes = re.compile("^(>.+)$", - flags=re.MULTILINE) + +# linequotes being chan-style greentext, +# >like this +linequotes = re.compile("^(>.+)$", flags=re.MULTILINE) + + +def parse_segments(text, sanitize_linequotes=True): + """ + Parse linequotes, quotes, and paragraphs into their appropriate + representations. Paragraphs are represented as separate strings + in the returned list, and quote-types are compiled to their + [bracketed] representations. + """ + result = list() + for paragraph in [p.strip() for p in re.split("\n{2,}", text)]: + pg = str() + for segment in [s.strip() for s in paragraph.split("\n")]: + if not segment: + continue + segment = quotes.sub(lambda m: "[quote: %s]" % m.group(1), segment) + if segment.startswith(">"): + if sanitize_linequotes: + inner = segment.replace("]", "\\]") + else: + inner = segment + segment = "[linequote: %s]" % inner + # pg = pg[0:-1] + pg += segment + else: + pg += segment + " " + result.append(pg.strip()) + return result + + +def sequential_expressions(string): + """ + Takes a string, sexpifies it, and returns a list of lists + who contain tuples. Each list of tuples represents a paragraph. + Within each paragraph, [0] is either None or a markup directive, + and [1] is the body of text to which it applies. This representation + is very easy to handle for a client. It semi-supports nesting: + eg, the expression [red: this [blue: is [green: mixed]]] will + return [("red", "this "), ("blue", "is "), ("green", "mixed")], + but this cannot effectively express an input like + [bold: [red: bolded colors.]], in which case the innermost + expression will take precedence. For the input: + "[bold: [red: this] is some shit [green: it cant handle]]" + you get: + [('red', 'this'), ('bold', ' is some shit '), ('green', 'it cant handle')] + """ + # abandon all hope ye who enter here + directives = colors + markup + result = list() + for paragraph in parse_segments(string): + stack = [[None, str()]] + skip_iters = [] + nest = [None] + escaped = False + for index, char in enumerate(paragraph): + if skip_iters: + skip_iters.pop() + continue + + if not escaped and char == "[": + directive = paragraph[index+1:paragraph.find(": ", index+1)] + open_p = directive in directives + else: open_p = False + clsd_p = not escaped and nest[-1] != None and char == "]" + + # dont splice other directives into linequotes: that is far + # too confusing for the client to determine where to put line + # breaks + if open_p and nest[-1] != "linequote": + stack.append([directive, str()]) + nest.append(directive) + [skip_iters.append(x) for x in range(len(directive)+2)] + + elif clsd_p: + nest.pop() + stack.append([nest[-1], str()]) + + else: + escaped = char == "\\" + if not (escaped and paragraph[index+1] in "[]"): + stack[-1][1] += char + # filter out unused stacks, eg ["red", ""] + result.append([(directive, body) for directive, body in stack if body]) + return result def apply_formatting(msg_obj, formatter): """ Receives a messages object from a thread and returns it with - all the message bodies passed through FORMATTER. + all the message bodies passed through FORMATTER. Not all + formatting functions have to return a string. Refer to the + documentation for each formatter. """ - for x in range(len(msg_obj)): - msg_obj[x]["body"] = formatter(msg_obj[x]["body"]) + for x, obj in enumerate(msg_obj): + msg_obj[x]["body"] = formatter(obj["body"]) return msg_obj