#!/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" (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 = """ BTNS v2

Chalkboards

{items}
""" HTML_ITEM_TEMPLATE = """

{name}

""" ## 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())