summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore2
-rwxr-xr-xbtns2.py1012
2 files changed, 1014 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b444e8f
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+cb/
+*.xml
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())