btns2 - Simple Qt virtual chalkboard used for online tutorials.

git clone https://benconnors.ca/git-repos/btns2

Log | Files | Refs

btns2.py (39306B) - raw


      1 #!/usr/bin/env python3
      2 
      3 """A simple Qt-based virtual chalkboard program.
      4 
      5 Draw with left-click, erase with right-click drag. Hold down the middle mouse button and drag to 
      6 pick a different colour (white, blue, red, green).
      7 
      8 Keyboard shortcuts:
      9  C: Erase the current board
     10  N: Add a new board and switch to it
     11 ->: Next board
     12 <-: Previous board
     13  S: Save the current session to disk
     14  U: Save a picture of the current board to the disk (c.f. the `--path` argument)
     15  L: Switch to "laser" mode (bright red draw, disappears after a few seconds)
     16 
     17 The usual setup is to mount a folder from some webserver on the local machine (e.g. with SSHFS) and
     18 use the `U` key to periodically save screenshots of the current board to that folder; this way,
     19 students can page between boards, access them in breakout rooms, and you float between breakout 
     20 rooms and still use the board. An HTML file is generated in the screenshot folder which is suitable
     21 for displaying the images in a browser.
     22 
     23 For example, I would mount the folder "/var/www/html/cb" on my webserver running 
     24 "https://unsuspicious.services" to "./cb" on the local machine using SSHFS, then invoke BTNS using:
     25 
     26     ./btns2.py -p ./cb -s "https://unsuspicious.services/cb" <filename>
     27 
     28 (the filename is auto-generated if not given). The `-p` argument says to store screenshots in 
     29 "./cb" and the `-s` argument takes some text to display in the upper right corner to remind
     30 students where to access it.
     31 """
     32 
     33 import argparse
     34 import base64
     35 import datetime as dt
     36 import io
     37 import math
     38 import os
     39 import shutil
     40 import sys
     41 import threading
     42 import time
     43 import traceback as tb
     44 import xml.etree.ElementTree as et
     45 
     46 from PIL import ImageDraw, Image, ImageChops, ImageQt
     47 
     48 from PyQt5.QtWidgets import *
     49 from PyQt5.QtGui import *
     50 from PyQt5.QtCore import *
     51 
     52 ## Default/only pen colours available
     53 DEFAULT_COLOURS = (
     54     QColor(255, 255, 255),    ## White
     55     QColor(0x64, 0xA5, 0xFF), ## Blue
     56     QColor(0xFF, 0x64, 0x64), ## Red
     57     QColor(0x83, 0xF7, 0x83), ## Green
     58 )
     59 
     60 BXW = "{https://unsuspicious.services/btns}"
     61 
     62 HTML_TEMPLATE = """<!DOCTYPE html>
     63 
     64 <html>
     65     <head>
     66         <meta charset="utf-8"/>
     67         <title>BTNS v2</title>
     68     </head>
     69 
     70     <body>
     71         <!-- Don't be so nosy, get back to work! -->
     72         <div style="width: 80%; margin: 0 auto">
     73             <h1>Chalkboards</h1>
     74 {items}
     75         </div>
     76     </body>
     77 </html>
     78 """
     79 
     80 HTML_ITEM_TEMPLATE = """            <h2>{name}</h2>
     81             <img width="100%" src="{src}"/>"""
     82 
     83 ## A small piece of H*goromo chalk
     84 CURSOR = Image.open(io.BytesIO(base64.b64decode("""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""")))
     85 
     86 ## A small chalkboard eraser
     87 ERASER = ImageQt.ImageQt(Image.open(io.BytesIO(base64.b64decode("""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"""))))
     88 
     89 def make_cursor(colour=(255, 255, 255)):
     90     """Construct a new cursor by multiplying `CURSOR` with the given colour."""
     91     other = Image.new("RGBA", (32, 32), colour)
     92     image = ImageQt.ImageQt(ImageChops.multiply(other, CURSOR))
     93 
     94     return QCursor(QPixmap.fromImage(image), 2, 29)
     95 
     96 class LoadError(Exception):
     97     pass
     98 
     99 def xml_indent(elem, indent=4, level=0):
    100     """Pretty-indent the XML tree."""
    101     i = "\n" + level*(indent*' ')
    102     if elem:
    103         if not elem.text or not elem.text.strip():
    104             elem.text = i + (' '*indent)
    105         if not elem.tail or not elem.tail.strip():
    106             elem.tail = i
    107         for elem in elem:
    108             xml_indent(elem, indent=indent, level=level+1)
    109         if not elem.tail or not elem.tail.strip():
    110             elem.tail = i
    111     else:
    112         if level and (not elem.tail or not elem.tail.strip()):
    113             elem.tail = i
    114 
    115 class IncrementalSplineMask:
    116     """Class for constructing (carindal) cubic splines incrementally, i.e. adding a control point, 
    117     rendering, adding a control point, rendering, etc.
    118 
    119     :param resolution: Parameter controlling the number of points to compute between each control
    120                        point, e.g. 0.1 for 10, 0.5 for 2.
    121     :param tension: Controls the "smoothness" of the resulting path; this will depend on how 
    122                     frequently points are added/rendered.
    123     :param width: Width of the spline; used for image export and detecting when multiple splines 
    124                   intersect. If you are rendering the spline elsewhere, that width should match 
    125                   this one.
    126     """
    127     @staticmethod
    128     def _spline_points(p_0, m_0, p_1, m_1, resolution):
    129         points = [p_0]
    130         t = resolution
    131         while t < 1:
    132             h_00 = 2*(t**3) - 3*(t**2) + 1
    133             h_10 = t**3 - 2*(t**2) + t
    134             h_01 = -2*(t**3) + 3*(t**2)
    135             h_11 = t**3 - t**2
    136             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)))))
    137 
    138             t += resolution
    139         points.append(p_1)
    140 
    141         return points
    142 
    143     @staticmethod
    144     def _bbox(points):
    145         minx = None
    146         miny = None
    147         maxx = None
    148         maxy = None
    149         for x, y in points:
    150             if minx is None or x < minx:
    151                 minx = x
    152             if miny is None or y < miny:
    153                 miny = y
    154             if maxx is None or x > maxx:
    155                 maxx = x
    156             if maxy is None or y > maxy :
    157                 maxy = y
    158 
    159         return math.floor(minx), math.floor(miny), math.ceil(maxx), math.ceil(maxy)
    160 
    161     @staticmethod
    162     def _overlap(x0, y0, x1, y1, x2, y2, x3, y3):
    163         """Compute the overlap between the two rectangles.
    164 
    165         We assume x0 <= x1, y0 <= y1, and likewise for the other points.
    166         """
    167         if None in (x0, y0, x1, y1, x2, y2, x3, y3) or x1 < x2 or x3 < x0 or y1 < y2 or y3 < y0:
    168             ## No overlap, return 4 for convenience
    169             return None, None, None, None
    170 
    171         minx = max(x0, x2)
    172         maxx = min(x1, x3)
    173         miny = max(y0, y2)
    174         maxy = min(y1, y3)
    175 
    176         return minx, miny, maxx, maxy
    177 
    178     @staticmethod
    179     def composite(background, *splines, size=None):
    180         """Return a composite image of the given splines.
    181 
    182         :param size: The desired size; if not given, it is computed from the bounding boxes of the
    183                      splines.
    184         """
    185         maxx = max((i.maxx for i in splines if i.maxx is not None))
    186         maxy = max((i.maxy for i in splines if i.maxy is not None))
    187 
    188         if size is not None:
    189             maxx = max(maxx, size[0])
    190             maxy = max(maxy, size[1])
    191 
    192         composite = Image.new("RGBA", (maxx+1, maxy+1), color=background)
    193         draw = ImageDraw.Draw(composite)
    194 
    195         for spline in splines:
    196             if spline.maxx is None:
    197                 continue
    198             draw.bitmap((spline.minx, spline.miny), spline.current, spline.colour)
    199 
    200         return composite
    201 
    202     def _update_bounds(self, temp_coords):
    203         ## It is simpler to include the temporary coordinates in the calculations, rather than try
    204         ## to save some memory by maintaining different sizes for the current and permanent images
    205         minx, miny, maxx, maxy = self._bbox(self.coords + temp_coords)
    206 
    207         if (minx, miny, maxx, maxy) == (self.minx, self.miny, self.maxx, self.maxy):
    208             return
    209 
    210         ## Allocate and copy the PREVIOUS image data; when this is called, that's all we care about
    211         new = Image.new("1", (maxx-minx+1, maxy-miny+1))
    212 
    213         bix, biy, bax, bay = self._overlap(minx, miny, maxx, maxy, self.minx, self.miny, self.maxx, self.maxy)
    214         
    215         if bix is not None:
    216             prev = self.previous.crop((bix-self.minx, biy-self.miny, bax-self.minx, bay-self.miny))
    217             new.paste(self.previous, (bix-minx, biy-miny))
    218 
    219         self.minx, self.miny, self.maxx, self.maxy = minx, miny, maxx, maxy
    220         self.previous = new
    221         self.current = None
    222 
    223     def _fix_coords(self, coords):
    224         return [(x-self.minx, y-self.miny) for x, y in coords]
    225 
    226     def add_points(self, *points):
    227         """Add points to the spline.
    228 
    229         Updates the spline's mask image and returns two lists of points: `new` and `rest`. `rest`
    230         contains the points corresponding to the previous spline control points, EXCEPT for the 
    231         last segment: this segment changed with the addition of the current control point(s). `new`
    232         contains all of the added/changed points; `rest + new` is the complete spline.
    233         """
    234         rest = []
    235         new_points = []
    236 
    237         prev = None
    238         if self.points:
    239             prev = self.points[-1]
    240 
    241         for n, this_pt in enumerate(points, 1):
    242             self.points.append(this_pt)
    243 
    244             if self.last_pt is None:
    245                 ## No points
    246                 self.last_pt = this_pt
    247                 new_points.append(this_pt)
    248 
    249                 continue
    250 
    251             m_0 = self.last_last_m
    252             if self.last_last_pt is None:
    253                 ## Two points
    254                 m_1 = tuple(((a-b)*self.tension for a, b in zip(this_pt, self.last_pt)))
    255             else:
    256                 m_1 = tuple(((a-c)*self.tension for a, c in zip(this_pt, self.last_last_pt)))
    257 
    258             ## We can't compute this one properly yet
    259             m_2 = tuple(((a-b)*self.tension for a, b in zip(this_pt, self.last_pt)))
    260 
    261             p_0 = self.last_last_pt
    262             p_1 = self.last_pt
    263             p_2 = this_pt
    264 
    265             if m_0 is not None:
    266                 ## This segment will not change: we have all the data needed to draw it
    267                 this_points = self._spline_points(p_0, m_0, p_1, m_1, self.resolution)
    268                 new_points.extend(this_points if not new_points else this_points[1:])
    269 
    270             if n == len(points):
    271                 ## This segment will change if another point is added, since we have only one side.
    272                 ## Since we have no other points to add in this call, we need to compute the 
    273                 ## temporary values so they can be drawn until another point is added
    274                 rest = self._spline_points(p_1, m_1, p_2, m_2, self.resolution)
    275 
    276             ## Swap out variables
    277             self.last_last_pt = self.last_pt
    278             self.last_pt = this_pt
    279             self.last_last_m = m_1
    280 
    281         ## Update our coordinates and mask image
    282         self.coords.extend(new_points if not self.coords else new_points[1:])
    283         self._update_bounds(rest)
    284 
    285         ## Now draw the actual things
    286         previous_draw = ImageDraw.Draw(self.previous)
    287         if new_points:
    288             previous_draw.line(self._fix_coords(new_points), fill=1, width=self.width)
    289 
    290         self.current = self.previous.copy()
    291         current_draw = ImageDraw.Draw(self.current)
    292         current_draw.line(self._fix_coords(rest), fill=1, width=self.width)
    293 
    294         return new_points, rest
    295 
    296     def overlaps(self, other):
    297         """Check if two splines overlap.
    298 
    299         We do this in a simple way: first check bounding boxes, then compute the logical AND of 
    300         the mask images of the two splines and check if there are any non-zero pixels.
    301         """
    302         ## This is slightly tricky: first, we compute the overlap images
    303         bix, biy, bax, bay = self._overlap(self.minx, self.miny, self.maxx, self.maxy, other.minx, other.miny, other.maxx, other.maxy)
    304         if bix is None:
    305             return None
    306 
    307         ## There is overlap; create two images that contain our and their overlap
    308         ours = self.current.crop((bix-self.minx, biy-self.miny, bax-self.minx+1, bay-self.miny+1))
    309         theirs = other.current.crop((bix-other.minx, biy-other.miny, bax-other.minx+1, bay-other.miny+1))
    310         
    311         ## Compute the overlap of the result
    312         a = ImageChops.logical_and(ours, theirs)
    313         a = a.getbbox()
    314 
    315         return a is not None
    316 
    317     def __init__(self, resolution=0.1, tension=0.1, width=1, colour=(255, 0, 0)):
    318         self.resolution = resolution
    319         self.tension = tension
    320         self.width = width
    321         self.colour = colour
    322 
    323         self.last_pt = None
    324         self.last_last_pt = None
    325         self.last_last_m = None
    326 
    327         self.points = []
    328 
    329         self.minx = None
    330         self.miny = None
    331         self.maxx = None
    332         self.maxy = None
    333 
    334         ## Current image, including the last segment, which will change on the next control point
    335         self.current = None
    336         ## Previous image, storing all parts of the spline that will not change
    337         self.previous = None
    338 
    339         self.coords = []
    340 
    341 def qcolor_to_hex(colour):
    342     return ("#%02x%02x%02x%02x" % colour.getRgb()).upper()
    343 
    344 def hex_to_qcolor(s):
    345     r = int(s[1:3], 16)
    346     g = int(s[3:5], 16)
    347     b = int(s[5:7], 16)
    348     if len(s) > 7:
    349         a = int(s[7:9], 16)
    350 
    351     return QColor(r, g, b, a)
    352 
    353 class SerializablePen(QPen):
    354     def serialize(self):
    355         e = et.Element(BXW+"pen")
    356         e.set("width", str(self.width()))
    357         e.set("cap", str(self.capStyle()))
    358         e.set("join", str(self.joinStyle()))
    359         e.set("style", str(self.style()))
    360         e.set("colour", str(qcolor_to_hex(self.color())))
    361 
    362         return e
    363 
    364     @classmethod
    365     def deserialize(cls, e):
    366         if e.tag != BXW+"pen":
    367             raise LoadError("Invalid pen tag!")
    368 
    369         width = res_or_none(int, e.get("width"))
    370         join = res_or_none(int, e.get("join"))
    371         cap = res_or_none(int, e.get("cap"))
    372         colour = e.get("colour")
    373         style = res_or_none(int, e.get("style"))
    374 
    375         if None in (width, join, cap, colour, style):
    376             raise LoadError("Invalid pen properties!")
    377 
    378         try:
    379             colour = hex_to_qcolor(colour)
    380         except:
    381             raise LoadError("Invalid pen colour!")
    382 
    383         return cls(colour, width, style=style, cap=cap, join=join)
    384 
    385 def res_or_none(func, s):
    386     try:
    387         return func(s)
    388     except:
    389         return None
    390 
    391 class Path(IncrementalSplineMask):
    392     def serialize(self):
    393         e = et.Element(BXW+"path")
    394         e.set("id", str(self.id))
    395         e.set("resolution", str(self.resolution))
    396         e.set("tension", str(self.tension))
    397 
    398         pen = self.pen.serialize()
    399         e.insert(0, pen)
    400 
    401         for n, p in enumerate(self.points, 1):
    402             se = et.Element(BXW+"point")
    403             se.set("x", str(p[0]))
    404             se.set("y", str(p[1]))
    405             e.insert(n, se)
    406 
    407         return e
    408 
    409     @classmethod
    410     def deserialize(cls, e):
    411         if e.tag != BXW+"path":
    412             raise LoadError("Invalid path tag!")
    413 
    414         id_ = res_or_none(int, e.get("id"))
    415         if id_ is None:
    416             raise LoadError("Invalid path id!")
    417 
    418         resolution = res_or_none(float, e.get("resolution"))
    419         if resolution is None:
    420             raise LoadError("Invalid path resolution!")
    421 
    422         tension = res_or_none(float, e.get("resolution"))
    423         if tension is None:
    424             raise LoadError("Invalid path tension!")
    425 
    426         points = []
    427         pen = None
    428         for point in e:
    429             if pen is None:
    430                 pen = SerializablePen.deserialize(point)
    431                 continue
    432 
    433             if point.tag != BXW+"point":
    434                 raise LoadError("Invalid point tag!")
    435 
    436             x = res_or_none(int, point.get("x"))
    437             y = res_or_none(int, point.get("y"))
    438             if x is None or y is None:
    439                 raise LoadError("Invalid point coordinates!")
    440 
    441             points.append((x, y))
    442 
    443         if pen is None:
    444             raise LoadError("Path must have a pen!")
    445         elif not points:
    446             raise LoadError("Path must not be empty!")
    447 
    448         path = cls(id=id_, pen=pen, resolution=resolution, tension=tension)
    449         path.add_points(*points)
    450 
    451         return path
    452 
    453     def __init__(self, id, pen, *args, **kwargs):
    454         super().__init__(*args, **kwargs)
    455 
    456         self.id = id
    457         self.pen = pen
    458 
    459 class Board(QGraphicsScene):
    460     def start_path(self, pen, x, y, erase=False, laser=False):
    461         if not laser:
    462             self.changed = True
    463         else:
    464             self.laser_timer.stop()
    465         self.path = Path(id=self.current_id, pen=pen)
    466         self.current_id += 1
    467         self.erase = erase
    468         self.laser = laser
    469         self.path_items = []
    470 
    471     def move_to(self, x, y):
    472         good, rest = self.path.add_points((x, y))
    473         if not self.erase:
    474             if not self.laser:
    475                 self.changed = True
    476                 self.laser_timer.stop()
    477             if len(good)+len(rest) > 1:
    478                 for _ in range(self.last_path_items):
    479                     self.removeItem(self.path_items.pop(-1))
    480 
    481                 new = good+rest
    482                 self.last_path_items = len(rest)-1
    483                 for i in range(len(new)-1):
    484                     self.path_items.append(self.addLine(*new[i], *new[i+1], self.path.pen))
    485         else:
    486             for n, (path, group) in enumerate(self.paths):
    487                 if path.overlaps(self.path):
    488                     self.changed = True
    489                     self.removeItem(group)
    490                     self.paths.pop(n)
    491                     break
    492 
    493     def finish_path(self):
    494         if self.path is None:
    495             return
    496         if not self.laser:
    497             self.changed = True
    498         if self.path.coords is not None and self.path.coords and not self.erase:
    499             group = self.createItemGroup(self.path_items)
    500             if not self.laser:
    501                 self.paths.append((self.path, group))
    502             else:
    503                 self.laser_paths.append((self.path, group))
    504                 self.laser_timer.start(1500)
    505                 self.transitive.append(group)
    506         self.path = None
    507         self.path_items = []
    508         self.last_path_items = 0
    509 
    510     def _add_path(self, path):
    511         items = []
    512         for i in range(len(path.coords)-1):
    513             items.append(self.addLine(*path.coords[i], *path.coords[i+1], path.pen))
    514         group = self.createItemGroup(items)
    515         self.paths.append((path, group))
    516 
    517     def _remove_laser(self):
    518         for _, group in self.laser_paths:
    519             self.removeItem(group)
    520         self.laser_paths = []
    521 
    522     def serialize(self):
    523         e = et.Element(BXW+"board")
    524         e.set("id", str(self.id))
    525         e.set("current-id", str(self.current_id))
    526         e.set("background", qcolor_to_hex(self.backgroundBrush().color()))
    527         e.set("text-colour", qcolor_to_hex(self.text_colour))
    528 
    529         for n, (path, _) in enumerate(self.paths):
    530             se = path.serialize()
    531             e.insert(n, se)
    532 
    533         return e
    534 
    535     @classmethod
    536     def deserialize(cls, e, *args, **kwargs):
    537         if e.tag != BXW+"board":
    538             raise LoadError("Invalid board tag", e.tag)
    539 
    540         id_ = res_or_none(int, e.get("id"))
    541         if id_ is None:
    542             raise LoadError("Invalid board id!")
    543 
    544         current_id = res_or_none(int, e.get("current-id"))
    545         if current_id is None:
    546             raise LoadError("Invalid board current id!")
    547 
    548         background = res_or_none(hex_to_qcolor, e.get("background"))
    549         if background is None:
    550             raise LoadError("Invalid board background!")
    551 
    552         text_colour = res_or_none(hex_to_qcolor, e.get("text-colour"))
    553         if text_colour is None:
    554             raise LoadError("Invalid board text colour!")
    555 
    556         self = cls(id_, background, text_colour, *args, current_id=current_id, **kwargs)
    557 
    558         for path in e:
    559             path = Path.deserialize(path)
    560             self._add_path(path)
    561 
    562         return self
    563 
    564     def show_menu(self, pens, x, y):
    565         """Show the colour menu."""
    566         menu_radius = 150
    567         delta = 360 / len(pens)
    568         angle = 90 - delta/2
    569         for pen in pens:
    570             e = self.addEllipse(x-menu_radius, y-menu_radius, 2*menu_radius, 2*menu_radius, pen=pen, brush=pen.brush())
    571             e.setStartAngle(int(angle*16))
    572             e.setSpanAngle(int(delta*16 + 1))
    573             self.menu[e] = pen
    574             angle += delta
    575 
    576     def finish_menu(self, x, y):
    577         """Hide the colour menu."""
    578         item = self.itemAt(x, y, QTransform())
    579         pen = None
    580         if item in self.menu:
    581             pen = self.menu[item]
    582 
    583         for item in self.menu:
    584             self.removeItem(item)
    585 
    586         self.menu = {}
    587 
    588         return pen
    589 
    590     def clear(self):
    591         """Clear the board."""
    592         self.finish_path()
    593 
    594         for path, group in self.paths:
    595             self.removeItem(group)
    596 
    597         self.paths = []
    598 
    599     def set_size(self, width, height):
    600         self.setSceneRect(0, 0, width, height)
    601         self.parent_width = width
    602         self.parent_height = height
    603         self._fix_message_pos()
    604 
    605     def _fix_message_pos(self):
    606         br = self.message.boundingRect()
    607         self.message.setPos(QPoint(self.parent_width//2, self.parent_height-5) - br.center() - QPoint(0, int(br.height())//2))
    608 
    609     def show_message(self, text, length=1500):
    610         """Show a message in the bottom centre for the given time (in ms)."""
    611         self.message.setPlainText(text)
    612         self._fix_message_pos()
    613         self.message.show()
    614         self.message_timer.start(length)
    615 
    616     def to_image(self):
    617         """Save the board to a QImage."""
    618         image = QImage(int(self.width()), int(self.height()), QImage.Format_ARGB32)
    619         painter = QPainter(image)
    620         painter.setRenderHints(QPainter.HighQualityAntialiasing);
    621 
    622         ## Hide everything (mainly text, menus) that shouldn't be visible in the saved image
    623         for t in self.transitive:
    624             t.hide()
    625         self.render(painter)
    626 
    627         for t in self.transitive:
    628             t.show()
    629 
    630         return image
    631 
    632     def _hide_message(self):
    633         self.message.hide()
    634 
    635     def __init__(self, id, background, text_colour, *args, current_id=0, show_number=True, site_text=None, **kwargs):
    636         super().__init__(*args, **kwargs)
    637 
    638         self.setBackgroundBrush(background)
    639         self.id = id
    640         self.current_id = current_id
    641         self.paths = []
    642         self.path = None
    643         self.path_items = []
    644         self.last_path_items = 0
    645         self.erase = False
    646         self.laser = False
    647         self.laser_timer = QTimer()
    648         self.laser_timer.setSingleShot(True)
    649         self.laser_timer.timeout.connect(self._remove_laser)
    650         self.laser_paths = []
    651         self.changed = False
    652 
    653         self.text_colour = text_colour
    654         self.text_font = QFont()
    655         self.text_font.setPointSize(20)
    656         self.parent_width = None
    657         self.parent_height = None
    658 
    659         ## This list contains everything that needs to be disabled for screenshots
    660         self.transitive = []
    661 
    662         if show_number:
    663             ## Show the board number permanently, TODO
    664             self.number = self.addText("-1/-1", self.text_font)
    665             self.transitive.append(self.number)
    666 
    667         if site_text is not None:
    668             ## Show some text, usually pointing to somewhere students can view it
    669             self.site = self.addText(site_text, self.text_font)
    670             self.site.setPos(5, 5)
    671             self.site.show()
    672             self.site.setDefaultTextColor(self.text_colour)
    673             self.transitive.append(self.site)
    674 
    675         self.message = self.addText("No message yet!", self.text_font)
    676         self.message.setDefaultTextColor(self.text_colour)
    677         self.message.hide()
    678         self.transitive.append(self.message)
    679 
    680         self.message_timer = QTimer()
    681         self.message_timer.timeout.connect(self._hide_message)
    682         self.message_timer.setSingleShot(True)
    683         
    684         self.menu = {}
    685 
    686 class Room(QGraphicsView):
    687     def release_pen(self, x, y):
    688         self.board.finish_path()
    689 
    690     def release_erase(self, x, y):
    691         self.board.finish_path()
    692         self.setCursor(self.cursor)
    693 
    694     def set_pen(self, pen):
    695         self.pen = pen
    696         self.cursor = make_cursor(pen.brush().color().getRgb()[0:3])
    697         self.laser = False
    698         self.setCursor(self.cursor)
    699 
    700     def release_menu(self, x, y):
    701         pen = self.board.finish_menu(x, y)
    702         if pen is not None:
    703             self.set_pen(pen)
    704 
    705     def down_pen(self, x, y):
    706         self.board.start_path(self.pen if not self.laser else self.laser_pen, x, y, erase=(self.down == 2), laser=self.laser)
    707 
    708     def down_erase(self, x, y):
    709         self.down_pen(x, y)
    710         self.setCursor(self.eraser_cursor)
    711 
    712     def down_menu(self, x, y):
    713         self.board.show_menu(self.pens, x, y)
    714 
    715     def move_pen(self, x, y):
    716         now = time.monotonic()
    717         ## Restrict the update speed or it will look jagged
    718         if self.last_time is not None and now-self.last_time < 1/24:
    719             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:
    720                 return
    721         self.last_time = now
    722         self.last_used_x = x
    723         self.last_used_y = y
    724         self.board.move_to(x, y)
    725 
    726     move_erase = move_pen
    727 
    728     def clear(self):
    729         self.board.clear()
    730         self.board.show_message("Cleared!")
    731 
    732     def keyPressEvent(self, ev):
    733         if ev.key() == Qt.Key_C:
    734             self.clear()
    735         elif ev.key() == Qt.Key_N:
    736             self.append_board()
    737         elif ev.key() == Qt.Key_Right:
    738             self.next_board()
    739         elif ev.key() == Qt.Key_Left:
    740             self.previous_board()
    741         elif ev.key() == Qt.Key_S:
    742             self.save()
    743             self.board.show_message("Saved!")
    744         elif ev.key() == Qt.Key_U and self.site_path is not None:
    745             self.to_site(self.site_path)
    746         elif ev.key() == Qt.Key_L:
    747             self.toggle_laser()
    748 
    749     def toggle_laser(self):
    750         if self.laser:
    751             self.set_pen(self.pen)
    752         else:
    753             self.laser = True
    754             self.unsetCursor()
    755 
    756     def mouseMoveEvent(self, ev):
    757         x, y = ev.x(), ev.y()
    758 
    759         b1 = bool(ev.buttons() & Qt.LeftButton)
    760         b2 = bool(ev.buttons() & Qt.RightButton)
    761         b3 = bool(ev.buttons() & Qt.MiddleButton)
    762 
    763         button = max(1*b1, 2*b2, 3*b3)
    764         buttons = (0, b1, b2, b3)
    765 
    766         if self.down is not None and not buttons[self.down]:
    767             if self.down == 1:
    768                 self.release_pen(x, y)
    769             elif self.down == 2:
    770                 self.release_erase(x, y)
    771             else:
    772                 self.release_menu(x, y)
    773 
    774             self.down = None
    775         elif self.down is None and button:
    776             self.down = button
    777             if self.down == 1:
    778                 self.down_pen(x, y)
    779             elif self.down == 2:
    780                 self.down_erase(x, y)
    781             else:
    782                 self.down_menu(x, y)
    783         elif self.down is not None and buttons[self.down]:
    784             if x == self.last_x and y == self.last_y:
    785                 return
    786 
    787             if self.down == 1:
    788                 self.move_pen(x, y)
    789             elif self.down == 2:
    790                 self.move_erase(x, y)
    791 
    792         self.last_x = x
    793         self.last_y = y
    794 
    795     def mousePressEvent(self, ev):
    796         self.mouseMoveEvent(ev)
    797 
    798     def mouseReleaseEvent(self, ev):
    799         self.mouseMoveEvent(ev)
    800 
    801     def resizeEvent(self, ev):
    802         self.set_size(ev.size().width(), ev.size().height())
    803 
    804     def set_size(self, width, height):
    805         if self.board is None:
    806             return
    807         self.board.set_size(width, height)
    808         self.setSceneRect(0, 0, width, height)
    809 
    810     def serialize(self):
    811         e = et.Element(BXW+"room")
    812         e.set("active-board", str(self.board.id))
    813         e.set("created", self.created.isoformat())
    814         e.set("current-id", str(self.current_id))
    815 
    816         for n, board in enumerate(self.boards):
    817             se = board.serialize()
    818             e.insert(n, se)
    819 
    820         return e
    821 
    822     @classmethod
    823     def deserialize(cls, e, *args, **kwargs):
    824         if e.tag != BXW+"room":
    825             raise LoadError("Invalid room tag!")
    826 
    827         created = res_or_none(dt.datetime.fromisoformat, e.get("created"))
    828         active_board = res_or_none(int, e.get("active-board"))
    829         current_id = res_or_none(int, e.get("current-id"))
    830         if None in (created, active_board, current_id):
    831             raise LoadError("Invalid room!")
    832 
    833         room = cls(*args, current_id=current_id, created=created, **kwargs)
    834 
    835         boards = []
    836         for board in e:
    837             board = Board.deserialize(board, show_number=self.show_number, site_text=self.site_text)
    838             boards.append(board)
    839 
    840         room.boards = boards
    841         room.set_active_board_by_id(active_board)
    842 
    843         return room
    844 
    845     def set_active_board(self, idx, save=True):
    846         idx = idx % len(self.boards)
    847         if idx == self.board_idx:
    848             return
    849         do_save = save and self.board is not None and self.board.changed
    850         self.board = self.boards[idx]
    851         self.board.changed = False
    852         self.board_idx = idx
    853         self.setScene(self.board)
    854         self.set_size(self.width(), self.height())
    855         self.board.show_message("Board %d/%d" % (self.board_idx+1, len(self.boards)))
    856         if do_save:
    857             self.save()
    858 
    859     def set_active_board_by_id(self, id):
    860         for n, board in enumerate(self.boards):
    861             if board.id == id:
    862                 self.set_active_board(n)
    863         else:
    864             return False
    865 
    866         return True
    867 
    868     def append_board(self, activate=True):
    869         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)
    870         self.current_id += 1
    871 
    872         self.boards.append(board)
    873         if activate:
    874             self.set_active_board(len(self.boards)-1, save=self.autosave)
    875 
    876     def next_board(self):
    877         self.set_active_board(self.board_idx+1, save=self.autosave)
    878 
    879     def previous_board(self):
    880         self.set_active_board(self.board_idx-1, save=self.autosave)
    881 
    882     def save(self, filename=None):
    883         if filename is None:
    884             filename = self.filename
    885         et.register_namespace("", BXW.strip("{}"))
    886         root = self.serialize()
    887         xml_indent(root)
    888         tree = et.ElementTree(element=root)
    889         tree.write(filename, encoding="unicode")
    890 
    891     @classmethod
    892     def load(cls, filename, *args, **kwargs):
    893         tree = et.parse(filename)
    894         root = tree.getroot()
    895 
    896         return cls.deserialize(root, *args, **kwargs)
    897 
    898     def to_site(self, path):
    899         images = []
    900         for n, board in enumerate(self.boards, 1):
    901             images.append((n, board.to_image()))
    902 
    903         class TheThread(QThread):
    904             actual_start = pyqtSignal()
    905             def run(this):
    906                 with self.to_site_lock:
    907                     this.actual_start.emit()
    908                     items = []
    909                     for id, image in images:
    910                         name = "Board %d" % id
    911                         src = "%s_%03d.png" % (self.created_name, id)
    912                         items.append(HTML_ITEM_TEMPLATE.format(name=name, src=src))
    913                         image.save(os.path.join(path, src))
    914                         os.chmod(os.path.join(path, src), 0o744)
    915                     html = HTML_TEMPLATE.format(items='\n'.join(items))
    916                     with open(os.path.join(path, "index.html"), 'w') as f:
    917                         f.write(html)
    918                         os.chmod(os.path.join(path, "index.html"), 0o744)
    919 
    920         def message1():
    921             self.board.show_message("Uploading...")
    922 
    923         def message2():
    924             self.board.show_message("Uploaded!")
    925             self.to_site_threads.pop(0)
    926 
    927         thread = TheThread()
    928         self.to_site_threads.append(thread)
    929         thread.actual_start.connect(message1)
    930         thread.finished.connect(message2)
    931         thread.start()
    932 
    933     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):
    934         self.board = None
    935         self.board_idx = -1
    936         self.boards = []
    937 
    938         super().__init__(self.board, *args, **kwargs)
    939 
    940         if pens is None:
    941             pens = [SerializablePen(colour, width, join=Qt.RoundJoin, cap=Qt.RoundCap) for colour in DEFAULT_COLOURS]
    942 
    943         self.default_background = default_background
    944         self.current_id = current_id
    945 
    946         self.setAlignment(Qt.AlignTop | Qt.AlignLeft)
    947 
    948         self.down = None
    949         self.last_time = None
    950 
    951         self.pens = pens
    952         self.pen = self.pens[0]
    953         self.laser_pen = QPen(QColor(255, 0, 0), width)
    954         self.laser = False
    955 
    956         self.setWindowTitle("BTNS v2")
    957         
    958         self.last_x = None
    959         self.last_y = None
    960         self.last_used_x = None
    961         self.last_used_y = None
    962         self.cursor = None
    963         self.eraser_cursor = QCursor(QPixmap.fromImage(ERASER), 4, 30)
    964 
    965         self.setRenderHints(QPainter.HighQualityAntialiasing)
    966         self.setMouseTracking(True)
    967         self.setStyleSheet("border: 0px")
    968 
    969         self.autosave = True
    970         self.to_site_lock = threading.Lock()
    971         self.to_site_threads = []
    972 
    973         self.site_path = site_path
    974         self.site_text = site_text
    975         self.show_number = show_number
    976 
    977         if created is None:
    978             self.created = dt.datetime.now()
    979             self.append_board()
    980         else:
    981             self.created = created
    982 
    983 
    984         self.created_name = self.created.isoformat().replace(':', '_').replace('.', '_')
    985 
    986         if filename is None:
    987             self.filename = self.created_name + ".xml"
    988         else:
    989             self.filename = filename
    990 
    991         self.set_pen(self.pen)
    992 
    993 if __name__ == "__main__":
    994     parser = argparse.ArgumentParser(
    995             description="Ben's Terrible Notetaking Software, v2", 
    996             formatter_class=argparse.RawDescriptionHelpFormatter,
    997             epilog=__doc__
    998     )
    999     parser.add_argument("file", nargs="?", default=None, help="Room file to load")
   1000     parser.add_argument("-n", "--show-numbers", action="store_true", default=False, help="Show board numbers")
   1001     parser.add_argument("-s", "--site", action="store", default="https://unsuspicious.services/cb", help="Text to show in the bottom corner")
   1002     parser.add_argument("-p", "--path", action="store", default="cb", help="Folder to store screenshots in (for uploading)")
   1003     args = parser.parse_args()
   1004 
   1005     app = QApplication(sys.argv)
   1006     if args.file and os.path.isfile(args.file):
   1007         shutil.copyfile(args.file, args.file+".bak")
   1008         w = Room.load(args.file, show_number=args.show_numbers, site_path=args.path, site_text=args.site)
   1009     else:
   1010         w = Room(filename=args.file, show_number=args.show_numbers, site_path=args.path, site_text=args.site)
   1011     w.show()
   1012     sys.exit(app.exec())