| btns2 - Simple Qt virtual chalkboard used for online tutorials.
git clone https://benconnors.ca/git-repos/btns2 |
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())