diff options
Diffstat (limited to 'btns2.py')
-rwxr-xr-x | btns2.py | 1012 |
1 files changed, 1012 insertions, 0 deletions
diff --git a/btns2.py b/btns2.py new file mode 100755 index 0000000..a461f11 --- /dev/null +++ b/btns2.py @@ -0,0 +1,1012 @@ +#!/usr/bin/env python3 + +"""A simple Qt-based virtual chalkboard program. + +Draw with left-click, erase with right-click drag. Hold down the middle mouse button and drag to +pick a different colour (white, blue, red, green). + +Keyboard shortcuts: + C: Erase the current board + N: Add a new board and switch to it +->: Next board +<-: Previous board + S: Save the current session to disk + U: Save a picture of the current board to the disk (c.f. the `--path` argument) + L: Switch to "laser" mode (bright red draw, disappears after a few seconds) + +The usual setup is to mount a folder from some webserver on the local machine (e.g. with SSHFS) and +use the `U` key to periodically save screenshots of the current board to that folder; this way, +students can page between boards, access them in breakout rooms, and you float between breakout +rooms and still use the board. An HTML file is generated in the screenshot folder which is suitable +for displaying the images in a browser. + +For example, I would mount the folder "/var/www/html/cb" on my webserver running +"https://unsuspicious.services" to "./cb" on the local machine using SSHFS, then invoke BTNS using: + + ./btns2.py -p ./cb -s "https://unsuspicious.services/cb" <filename> + +(the filename is auto-generated if not given). The `-p` argument says to store screenshots in +"./cb" and the `-s` argument takes some text to display in the upper right corner to remind +students where to access it. +""" + +import argparse +import base64 +import datetime as dt +import io +import math +import os +import shutil +import sys +import threading +import time +import traceback as tb +import xml.etree.ElementTree as et + +from PIL import ImageDraw, Image, ImageChops, ImageQt + +from PyQt5.QtWidgets import * +from PyQt5.QtGui import * +from PyQt5.QtCore import * + +## Default/only pen colours available +DEFAULT_COLOURS = ( + QColor(255, 255, 255), ## White + QColor(0x64, 0xA5, 0xFF), ## Blue + QColor(0xFF, 0x64, 0x64), ## Red + QColor(0x83, 0xF7, 0x83), ## Green +) + +BXW = "{https://unsuspicious.services/btns}" + +HTML_TEMPLATE = """<!DOCTYPE html> + +<html> + <head> + <meta charset="utf-8"/> + <title>BTNS v2</title> + </head> + + <body> + <!-- Don't be so nosy, get back to work! --> + <div style="width: 80%; margin: 0 auto"> + <h1>Chalkboards</h1> +{items} + </div> + </body> +</html> +""" + +HTML_ITEM_TEMPLATE = """ <h2>{name}</h2> + <img width="100%" src="{src}"/>""" + +## A small piece of H*goromo chalk +CURSOR = Image.open(io.BytesIO(base64.b64decode("""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"""))) + +## A small chalkboard eraser +ERASER = ImageQt.ImageQt(Image.open(io.BytesIO(base64.b64decode("""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""")))) + +def make_cursor(colour=(255, 255, 255)): + """Construct a new cursor by multiplying `CURSOR` with the given colour.""" + other = Image.new("RGBA", (32, 32), colour) + image = ImageQt.ImageQt(ImageChops.multiply(other, CURSOR)) + + return QCursor(QPixmap.fromImage(image), 2, 29) + +class LoadError(Exception): + pass + +def xml_indent(elem, indent=4, level=0): + """Pretty-indent the XML tree.""" + i = "\n" + level*(indent*' ') + if elem: + if not elem.text or not elem.text.strip(): + elem.text = i + (' '*indent) + if not elem.tail or not elem.tail.strip(): + elem.tail = i + for elem in elem: + xml_indent(elem, indent=indent, level=level+1) + if not elem.tail or not elem.tail.strip(): + elem.tail = i + else: + if level and (not elem.tail or not elem.tail.strip()): + elem.tail = i + +class IncrementalSplineMask: + """Class for constructing (carindal) cubic splines incrementally, i.e. adding a control point, + rendering, adding a control point, rendering, etc. + + :param resolution: Parameter controlling the number of points to compute between each control + point, e.g. 0.1 for 10, 0.5 for 2. + :param tension: Controls the "smoothness" of the resulting path; this will depend on how + frequently points are added/rendered. + :param width: Width of the spline; used for image export and detecting when multiple splines + intersect. If you are rendering the spline elsewhere, that width should match + this one. + """ + @staticmethod + def _spline_points(p_0, m_0, p_1, m_1, resolution): + points = [p_0] + t = resolution + while t < 1: + h_00 = 2*(t**3) - 3*(t**2) + 1 + h_10 = t**3 - 2*(t**2) + t + h_01 = -2*(t**3) + 3*(t**2) + h_11 = t**3 - t**2 + points.append(tuple((int(z+0.5) for z in (h_00*c_0 + h_10*s_0 + h_01*c_1 + h_11*s_1 for c_0, s_0, c_1, s_1 in zip(p_0, m_0, p_1, m_1))))) + + t += resolution + points.append(p_1) + + return points + + @staticmethod + def _bbox(points): + minx = None + miny = None + maxx = None + maxy = None + for x, y in points: + if minx is None or x < minx: + minx = x + if miny is None or y < miny: + miny = y + if maxx is None or x > maxx: + maxx = x + if maxy is None or y > maxy : + maxy = y + + return math.floor(minx), math.floor(miny), math.ceil(maxx), math.ceil(maxy) + + @staticmethod + def _overlap(x0, y0, x1, y1, x2, y2, x3, y3): + """Compute the overlap between the two rectangles. + + We assume x0 <= x1, y0 <= y1, and likewise for the other points. + """ + if None in (x0, y0, x1, y1, x2, y2, x3, y3) or x1 < x2 or x3 < x0 or y1 < y2 or y3 < y0: + ## No overlap, return 4 for convenience + return None, None, None, None + + minx = max(x0, x2) + maxx = min(x1, x3) + miny = max(y0, y2) + maxy = min(y1, y3) + + return minx, miny, maxx, maxy + + @staticmethod + def composite(background, *splines, size=None): + """Return a composite image of the given splines. + + :param size: The desired size; if not given, it is computed from the bounding boxes of the + splines. + """ + maxx = max((i.maxx for i in splines if i.maxx is not None)) + maxy = max((i.maxy for i in splines if i.maxy is not None)) + + if size is not None: + maxx = max(maxx, size[0]) + maxy = max(maxy, size[1]) + + composite = Image.new("RGBA", (maxx+1, maxy+1), color=background) + draw = ImageDraw.Draw(composite) + + for spline in splines: + if spline.maxx is None: + continue + draw.bitmap((spline.minx, spline.miny), spline.current, spline.colour) + + return composite + + def _update_bounds(self, temp_coords): + ## It is simpler to include the temporary coordinates in the calculations, rather than try + ## to save some memory by maintaining different sizes for the current and permanent images + minx, miny, maxx, maxy = self._bbox(self.coords + temp_coords) + + if (minx, miny, maxx, maxy) == (self.minx, self.miny, self.maxx, self.maxy): + return + + ## Allocate and copy the PREVIOUS image data; when this is called, that's all we care about + new = Image.new("1", (maxx-minx+1, maxy-miny+1)) + + bix, biy, bax, bay = self._overlap(minx, miny, maxx, maxy, self.minx, self.miny, self.maxx, self.maxy) + + if bix is not None: + prev = self.previous.crop((bix-self.minx, biy-self.miny, bax-self.minx, bay-self.miny)) + new.paste(self.previous, (bix-minx, biy-miny)) + + self.minx, self.miny, self.maxx, self.maxy = minx, miny, maxx, maxy + self.previous = new + self.current = None + + def _fix_coords(self, coords): + return [(x-self.minx, y-self.miny) for x, y in coords] + + def add_points(self, *points): + """Add points to the spline. + + Updates the spline's mask image and returns two lists of points: `new` and `rest`. `rest` + contains the points corresponding to the previous spline control points, EXCEPT for the + last segment: this segment changed with the addition of the current control point(s). `new` + contains all of the added/changed points; `rest + new` is the complete spline. + """ + rest = [] + new_points = [] + + prev = None + if self.points: + prev = self.points[-1] + + for n, this_pt in enumerate(points, 1): + self.points.append(this_pt) + + if self.last_pt is None: + ## No points + self.last_pt = this_pt + new_points.append(this_pt) + + continue + + m_0 = self.last_last_m + if self.last_last_pt is None: + ## Two points + m_1 = tuple(((a-b)*self.tension for a, b in zip(this_pt, self.last_pt))) + else: + m_1 = tuple(((a-c)*self.tension for a, c in zip(this_pt, self.last_last_pt))) + + ## We can't compute this one properly yet + m_2 = tuple(((a-b)*self.tension for a, b in zip(this_pt, self.last_pt))) + + p_0 = self.last_last_pt + p_1 = self.last_pt + p_2 = this_pt + + if m_0 is not None: + ## This segment will not change: we have all the data needed to draw it + this_points = self._spline_points(p_0, m_0, p_1, m_1, self.resolution) + new_points.extend(this_points if not new_points else this_points[1:]) + + if n == len(points): + ## This segment will change if another point is added, since we have only one side. + ## Since we have no other points to add in this call, we need to compute the + ## temporary values so they can be drawn until another point is added + rest = self._spline_points(p_1, m_1, p_2, m_2, self.resolution) + + ## Swap out variables + self.last_last_pt = self.last_pt + self.last_pt = this_pt + self.last_last_m = m_1 + + ## Update our coordinates and mask image + self.coords.extend(new_points if not self.coords else new_points[1:]) + self._update_bounds(rest) + + ## Now draw the actual things + previous_draw = ImageDraw.Draw(self.previous) + if new_points: + previous_draw.line(self._fix_coords(new_points), fill=1, width=self.width) + + self.current = self.previous.copy() + current_draw = ImageDraw.Draw(self.current) + current_draw.line(self._fix_coords(rest), fill=1, width=self.width) + + return new_points, rest + + def overlaps(self, other): + """Check if two splines overlap. + + We do this in a simple way: first check bounding boxes, then compute the logical AND of + the mask images of the two splines and check if there are any non-zero pixels. + """ + ## This is slightly tricky: first, we compute the overlap images + bix, biy, bax, bay = self._overlap(self.minx, self.miny, self.maxx, self.maxy, other.minx, other.miny, other.maxx, other.maxy) + if bix is None: + return None + + ## There is overlap; create two images that contain our and their overlap + ours = self.current.crop((bix-self.minx, biy-self.miny, bax-self.minx+1, bay-self.miny+1)) + theirs = other.current.crop((bix-other.minx, biy-other.miny, bax-other.minx+1, bay-other.miny+1)) + + ## Compute the overlap of the result + a = ImageChops.logical_and(ours, theirs) + a = a.getbbox() + + return a is not None + + def __init__(self, resolution=0.1, tension=0.1, width=1, colour=(255, 0, 0)): + self.resolution = resolution + self.tension = tension + self.width = width + self.colour = colour + + self.last_pt = None + self.last_last_pt = None + self.last_last_m = None + + self.points = [] + + self.minx = None + self.miny = None + self.maxx = None + self.maxy = None + + ## Current image, including the last segment, which will change on the next control point + self.current = None + ## Previous image, storing all parts of the spline that will not change + self.previous = None + + self.coords = [] + +def qcolor_to_hex(colour): + return ("#%02x%02x%02x%02x" % colour.getRgb()).upper() + +def hex_to_qcolor(s): + r = int(s[1:3], 16) + g = int(s[3:5], 16) + b = int(s[5:7], 16) + if len(s) > 7: + a = int(s[7:9], 16) + + return QColor(r, g, b, a) + +class SerializablePen(QPen): + def serialize(self): + e = et.Element(BXW+"pen") + e.set("width", str(self.width())) + e.set("cap", str(self.capStyle())) + e.set("join", str(self.joinStyle())) + e.set("style", str(self.style())) + e.set("colour", str(qcolor_to_hex(self.color()))) + + return e + + @classmethod + def deserialize(cls, e): + if e.tag != BXW+"pen": + raise LoadError("Invalid pen tag!") + + width = res_or_none(int, e.get("width")) + join = res_or_none(int, e.get("join")) + cap = res_or_none(int, e.get("cap")) + colour = e.get("colour") + style = res_or_none(int, e.get("style")) + + if None in (width, join, cap, colour, style): + raise LoadError("Invalid pen properties!") + + try: + colour = hex_to_qcolor(colour) + except: + raise LoadError("Invalid pen colour!") + + return cls(colour, width, style=style, cap=cap, join=join) + +def res_or_none(func, s): + try: + return func(s) + except: + return None + +class Path(IncrementalSplineMask): + def serialize(self): + e = et.Element(BXW+"path") + e.set("id", str(self.id)) + e.set("resolution", str(self.resolution)) + e.set("tension", str(self.tension)) + + pen = self.pen.serialize() + e.insert(0, pen) + + for n, p in enumerate(self.points, 1): + se = et.Element(BXW+"point") + se.set("x", str(p[0])) + se.set("y", str(p[1])) + e.insert(n, se) + + return e + + @classmethod + def deserialize(cls, e): + if e.tag != BXW+"path": + raise LoadError("Invalid path tag!") + + id_ = res_or_none(int, e.get("id")) + if id_ is None: + raise LoadError("Invalid path id!") + + resolution = res_or_none(float, e.get("resolution")) + if resolution is None: + raise LoadError("Invalid path resolution!") + + tension = res_or_none(float, e.get("resolution")) + if tension is None: + raise LoadError("Invalid path tension!") + + points = [] + pen = None + for point in e: + if pen is None: + pen = SerializablePen.deserialize(point) + continue + + if point.tag != BXW+"point": + raise LoadError("Invalid point tag!") + + x = res_or_none(int, point.get("x")) + y = res_or_none(int, point.get("y")) + if x is None or y is None: + raise LoadError("Invalid point coordinates!") + + points.append((x, y)) + + if pen is None: + raise LoadError("Path must have a pen!") + elif not points: + raise LoadError("Path must not be empty!") + + path = cls(id=id_, pen=pen, resolution=resolution, tension=tension) + path.add_points(*points) + + return path + + def __init__(self, id, pen, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.id = id + self.pen = pen + +class Board(QGraphicsScene): + def start_path(self, pen, x, y, erase=False, laser=False): + if not laser: + self.changed = True + else: + self.laser_timer.stop() + self.path = Path(id=self.current_id, pen=pen) + self.current_id += 1 + self.erase = erase + self.laser = laser + self.path_items = [] + + def move_to(self, x, y): + good, rest = self.path.add_points((x, y)) + if not self.erase: + if not self.laser: + self.changed = True + self.laser_timer.stop() + if len(good)+len(rest) > 1: + for _ in range(self.last_path_items): + self.removeItem(self.path_items.pop(-1)) + + new = good+rest + self.last_path_items = len(rest)-1 + for i in range(len(new)-1): + self.path_items.append(self.addLine(*new[i], *new[i+1], self.path.pen)) + else: + for n, (path, group) in enumerate(self.paths): + if path.overlaps(self.path): + self.changed = True + self.removeItem(group) + self.paths.pop(n) + break + + def finish_path(self): + if self.path is None: + return + if not self.laser: + self.changed = True + if self.path.coords is not None and self.path.coords and not self.erase: + group = self.createItemGroup(self.path_items) + if not self.laser: + self.paths.append((self.path, group)) + else: + self.laser_paths.append((self.path, group)) + self.laser_timer.start(1500) + self.transitive.append(group) + self.path = None + self.path_items = [] + self.last_path_items = 0 + + def _add_path(self, path): + items = [] + for i in range(len(path.coords)-1): + items.append(self.addLine(*path.coords[i], *path.coords[i+1], path.pen)) + group = self.createItemGroup(items) + self.paths.append((path, group)) + + def _remove_laser(self): + for _, group in self.laser_paths: + self.removeItem(group) + self.laser_paths = [] + + def serialize(self): + e = et.Element(BXW+"board") + e.set("id", str(self.id)) + e.set("current-id", str(self.current_id)) + e.set("background", qcolor_to_hex(self.backgroundBrush().color())) + e.set("text-colour", qcolor_to_hex(self.text_colour)) + + for n, (path, _) in enumerate(self.paths): + se = path.serialize() + e.insert(n, se) + + return e + + @classmethod + def deserialize(cls, e, *args, **kwargs): + if e.tag != BXW+"board": + raise LoadError("Invalid board tag", e.tag) + + id_ = res_or_none(int, e.get("id")) + if id_ is None: + raise LoadError("Invalid board id!") + + current_id = res_or_none(int, e.get("current-id")) + if current_id is None: + raise LoadError("Invalid board current id!") + + background = res_or_none(hex_to_qcolor, e.get("background")) + if background is None: + raise LoadError("Invalid board background!") + + text_colour = res_or_none(hex_to_qcolor, e.get("text-colour")) + if text_colour is None: + raise LoadError("Invalid board text colour!") + + self = cls(id_, background, text_colour, *args, current_id=current_id, **kwargs) + + for path in e: + path = Path.deserialize(path) + self._add_path(path) + + return self + + def show_menu(self, pens, x, y): + """Show the colour menu.""" + menu_radius = 150 + delta = 360 / len(pens) + angle = 90 - delta/2 + for pen in pens: + e = self.addEllipse(x-menu_radius, y-menu_radius, 2*menu_radius, 2*menu_radius, pen=pen, brush=pen.brush()) + e.setStartAngle(int(angle*16)) + e.setSpanAngle(int(delta*16 + 1)) + self.menu[e] = pen + angle += delta + + def finish_menu(self, x, y): + """Hide the colour menu.""" + item = self.itemAt(x, y, QTransform()) + pen = None + if item in self.menu: + pen = self.menu[item] + + for item in self.menu: + self.removeItem(item) + + self.menu = {} + + return pen + + def clear(self): + """Clear the board.""" + self.finish_path() + + for path, group in self.paths: + self.removeItem(group) + + self.paths = [] + + def set_size(self, width, height): + self.setSceneRect(0, 0, width, height) + self.parent_width = width + self.parent_height = height + self._fix_message_pos() + + def _fix_message_pos(self): + br = self.message.boundingRect() + self.message.setPos(QPoint(self.parent_width//2, self.parent_height-5) - br.center() - QPoint(0, int(br.height())//2)) + + def show_message(self, text, length=1500): + """Show a message in the bottom centre for the given time (in ms).""" + self.message.setPlainText(text) + self._fix_message_pos() + self.message.show() + self.message_timer.start(length) + + def to_image(self): + """Save the board to a QImage.""" + image = QImage(int(self.width()), int(self.height()), QImage.Format_ARGB32) + painter = QPainter(image) + painter.setRenderHints(QPainter.HighQualityAntialiasing); + + ## Hide everything (mainly text, menus) that shouldn't be visible in the saved image + for t in self.transitive: + t.hide() + self.render(painter) + + for t in self.transitive: + t.show() + + return image + + def _hide_message(self): + self.message.hide() + + def __init__(self, id, background, text_colour, *args, current_id=0, show_number=True, site_text=None, **kwargs): + super().__init__(*args, **kwargs) + + self.setBackgroundBrush(background) + self.id = id + self.current_id = current_id + self.paths = [] + self.path = None + self.path_items = [] + self.last_path_items = 0 + self.erase = False + self.laser = False + self.laser_timer = QTimer() + self.laser_timer.setSingleShot(True) + self.laser_timer.timeout.connect(self._remove_laser) + self.laser_paths = [] + self.changed = False + + self.text_colour = text_colour + self.text_font = QFont() + self.text_font.setPointSize(20) + self.parent_width = None + self.parent_height = None + + ## This list contains everything that needs to be disabled for screenshots + self.transitive = [] + + if show_number: + ## Show the board number permanently, TODO + self.number = self.addText("-1/-1", self.text_font) + self.transitive.append(self.number) + + if site_text is not None: + ## Show some text, usually pointing to somewhere students can view it + self.site = self.addText(site_text, self.text_font) + self.site.setPos(5, 5) + self.site.show() + self.site.setDefaultTextColor(self.text_colour) + self.transitive.append(self.site) + + self.message = self.addText("No message yet!", self.text_font) + self.message.setDefaultTextColor(self.text_colour) + self.message.hide() + self.transitive.append(self.message) + + self.message_timer = QTimer() + self.message_timer.timeout.connect(self._hide_message) + self.message_timer.setSingleShot(True) + + self.menu = {} + +class Room(QGraphicsView): + def release_pen(self, x, y): + self.board.finish_path() + + def release_erase(self, x, y): + self.board.finish_path() + self.setCursor(self.cursor) + + def set_pen(self, pen): + self.pen = pen + self.cursor = make_cursor(pen.brush().color().getRgb()[0:3]) + self.laser = False + self.setCursor(self.cursor) + + def release_menu(self, x, y): + pen = self.board.finish_menu(x, y) + if pen is not None: + self.set_pen(pen) + + def down_pen(self, x, y): + self.board.start_path(self.pen if not self.laser else self.laser_pen, x, y, erase=(self.down == 2), laser=self.laser) + + def down_erase(self, x, y): + self.down_pen(x, y) + self.setCursor(self.eraser_cursor) + + def down_menu(self, x, y): + self.board.show_menu(self.pens, x, y) + + def move_pen(self, x, y): + now = time.monotonic() + ## Restrict the update speed or it will look jagged + if self.last_time is not None and now-self.last_time < 1/24: + if not (None in (self.last_used_x, self.last_used_y)) and (x-self.last_used_x)**2 + (y-self.last_used_y)**2 < 50: + return + self.last_time = now + self.last_used_x = x + self.last_used_y = y + self.board.move_to(x, y) + + move_erase = move_pen + + def clear(self): + self.board.clear() + self.board.show_message("Cleared!") + + def keyPressEvent(self, ev): + if ev.key() == Qt.Key_C: + self.clear() + elif ev.key() == Qt.Key_N: + self.append_board() + elif ev.key() == Qt.Key_Right: + self.next_board() + elif ev.key() == Qt.Key_Left: + self.previous_board() + elif ev.key() == Qt.Key_S: + self.save() + self.board.show_message("Saved!") + elif ev.key() == Qt.Key_U and self.site_path is not None: + self.to_site(self.site_path) + elif ev.key() == Qt.Key_L: + self.toggle_laser() + + def toggle_laser(self): + if self.laser: + self.set_pen(self.pen) + else: + self.laser = True + self.unsetCursor() + + def mouseMoveEvent(self, ev): + x, y = ev.x(), ev.y() + + b1 = bool(ev.buttons() & Qt.LeftButton) + b2 = bool(ev.buttons() & Qt.RightButton) + b3 = bool(ev.buttons() & Qt.MiddleButton) + + button = max(1*b1, 2*b2, 3*b3) + buttons = (0, b1, b2, b3) + + if self.down is not None and not buttons[self.down]: + if self.down == 1: + self.release_pen(x, y) + elif self.down == 2: + self.release_erase(x, y) + else: + self.release_menu(x, y) + + self.down = None + elif self.down is None and button: + self.down = button + if self.down == 1: + self.down_pen(x, y) + elif self.down == 2: + self.down_erase(x, y) + else: + self.down_menu(x, y) + elif self.down is not None and buttons[self.down]: + if x == self.last_x and y == self.last_y: + return + + if self.down == 1: + self.move_pen(x, y) + elif self.down == 2: + self.move_erase(x, y) + + self.last_x = x + self.last_y = y + + def mousePressEvent(self, ev): + self.mouseMoveEvent(ev) + + def mouseReleaseEvent(self, ev): + self.mouseMoveEvent(ev) + + def resizeEvent(self, ev): + self.set_size(ev.size().width(), ev.size().height()) + + def set_size(self, width, height): + if self.board is None: + return + self.board.set_size(width, height) + self.setSceneRect(0, 0, width, height) + + def serialize(self): + e = et.Element(BXW+"room") + e.set("active-board", str(self.board.id)) + e.set("created", self.created.isoformat()) + e.set("current-id", str(self.current_id)) + + for n, board in enumerate(self.boards): + se = board.serialize() + e.insert(n, se) + + return e + + @classmethod + def deserialize(cls, e, *args, **kwargs): + if e.tag != BXW+"room": + raise LoadError("Invalid room tag!") + + created = res_or_none(dt.datetime.fromisoformat, e.get("created")) + active_board = res_or_none(int, e.get("active-board")) + current_id = res_or_none(int, e.get("current-id")) + if None in (created, active_board, current_id): + raise LoadError("Invalid room!") + + room = cls(*args, current_id=current_id, created=created, **kwargs) + + boards = [] + for board in e: + board = Board.deserialize(board, show_number=self.show_number, site_text=self.site_text) + boards.append(board) + + room.boards = boards + room.set_active_board_by_id(active_board) + + return room + + def set_active_board(self, idx, save=True): + idx = idx % len(self.boards) + if idx == self.board_idx: + return + do_save = save and self.board is not None and self.board.changed + self.board = self.boards[idx] + self.board.changed = False + self.board_idx = idx + self.setScene(self.board) + self.set_size(self.width(), self.height()) + self.board.show_message("Board %d/%d" % (self.board_idx+1, len(self.boards))) + if do_save: + self.save() + + def set_active_board_by_id(self, id): + for n, board in enumerate(self.boards): + if board.id == id: + self.set_active_board(n) + else: + return False + + return True + + def append_board(self, activate=True): + board = Board(self.current_id, background=self.default_background, text_colour=self.pens[0].brush().color(), show_number=self.show_number, site_text=self.site_text) + self.current_id += 1 + + self.boards.append(board) + if activate: + self.set_active_board(len(self.boards)-1, save=self.autosave) + + def next_board(self): + self.set_active_board(self.board_idx+1, save=self.autosave) + + def previous_board(self): + self.set_active_board(self.board_idx-1, save=self.autosave) + + def save(self, filename=None): + if filename is None: + filename = self.filename + et.register_namespace("", BXW.strip("{}")) + root = self.serialize() + xml_indent(root) + tree = et.ElementTree(element=root) + tree.write(filename, encoding="unicode") + + @classmethod + def load(cls, filename, *args, **kwargs): + tree = et.parse(filename) + root = tree.getroot() + + return cls.deserialize(root, *args, **kwargs) + + def to_site(self, path): + images = [] + for n, board in enumerate(self.boards, 1): + images.append((n, board.to_image())) + + class TheThread(QThread): + actual_start = pyqtSignal() + def run(this): + with self.to_site_lock: + this.actual_start.emit() + items = [] + for id, image in images: + name = "Board %d" % id + src = "%s_%03d.png" % (self.created_name, id) + items.append(HTML_ITEM_TEMPLATE.format(name=name, src=src)) + image.save(os.path.join(path, src)) + os.chmod(os.path.join(path, src), 0o744) + html = HTML_TEMPLATE.format(items='\n'.join(items)) + with open(os.path.join(path, "index.html"), 'w') as f: + f.write(html) + os.chmod(os.path.join(path, "index.html"), 0o744) + + def message1(): + self.board.show_message("Uploading...") + + def message2(): + self.board.show_message("Uploaded!") + self.to_site_threads.pop(0) + + thread = TheThread() + self.to_site_threads.append(thread) + thread.actual_start.connect(message1) + thread.finished.connect(message2) + thread.start() + + def __init__(self, *args, filename=None, default_background=QColor(0x2D, 0x2D, 0x2D), width=4, current_id=0, created=None, pens=None, site_path=None, site_text=None, show_number=True, **kwargs): + self.board = None + self.board_idx = -1 + self.boards = [] + + super().__init__(self.board, *args, **kwargs) + + if pens is None: + pens = [SerializablePen(colour, width, join=Qt.RoundJoin, cap=Qt.RoundCap) for colour in DEFAULT_COLOURS] + + self.default_background = default_background + self.current_id = current_id + + self.setAlignment(Qt.AlignTop | Qt.AlignLeft) + + self.down = None + self.last_time = None + + self.pens = pens + self.pen = self.pens[0] + self.laser_pen = QPen(QColor(255, 0, 0), width) + self.laser = False + + self.setWindowTitle("BTNS v2") + + self.last_x = None + self.last_y = None + self.last_used_x = None + self.last_used_y = None + self.cursor = None + self.eraser_cursor = QCursor(QPixmap.fromImage(ERASER), 4, 30) + + self.setRenderHints(QPainter.HighQualityAntialiasing) + self.setMouseTracking(True) + self.setStyleSheet("border: 0px") + + self.autosave = True + self.to_site_lock = threading.Lock() + self.to_site_threads = [] + + self.site_path = site_path + self.site_text = site_text + self.show_number = show_number + + if created is None: + self.created = dt.datetime.now() + self.append_board() + else: + self.created = created + + + self.created_name = self.created.isoformat().replace(':', '_').replace('.', '_') + + if filename is None: + self.filename = self.created_name + ".xml" + else: + self.filename = filename + + self.set_pen(self.pen) + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Ben's Terrible Notetaking Software, v2", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__ + ) + parser.add_argument("file", nargs="?", default=None, help="Room file to load") + parser.add_argument("-n", "--show-numbers", action="store_true", default=False, help="Show board numbers") + parser.add_argument("-s", "--site", action="store", default="https://unsuspicious.services/cb", help="Text to show in the bottom corner") + parser.add_argument("-p", "--path", action="store", default="cb", help="Folder to store screenshots in (for uploading)") + args = parser.parse_args() + + app = QApplication(sys.argv) + if args.file and os.path.isfile(args.file): + shutil.copyfile(args.file, args.file+".bak") + w = Room.load(args.file, show_number=args.show_numbers, site_path=args.path, site_text=args.site) + else: + w = Room(filename=args.file, show_number=args.show_numbers, site_path=args.path, site_text=args.site) + w.show() + sys.exit(app.exec()) |