sketchingpy.sketch2dweb
HTML5 Canvas-based renderer for Sketchingpy.
License:
BSD
1"""HTML5 Canvas-based renderer for Sketchingpy. 2 3License: 4 BSD 5""" 6 7import csv 8import io 9import json 10import os.path 11import time 12import typing 13import urllib.parse 14 15has_js = False 16try: 17 import js # type: ignore 18 import pyodide.ffi # type: ignore 19 import pyodide.http # type: ignore 20 import pyscript # type: ignore 21 has_js = True 22except: 23 pass 24 25import sketchingpy.abstracted 26import sketchingpy.const 27import sketchingpy.control_struct 28import sketchingpy.data_struct 29import sketchingpy.state_struct 30 31DEFAULT_FPS = 20 32 33KEY_MAP = { 34 'arrowleft': sketchingpy.const.KEYBOARD_LEFT_BUTTON, 35 'arrowup': sketchingpy.const.KEYBOARD_UP_BUTTON, 36 'arrowright': sketchingpy.const.KEYBOARD_RIGHT_BUTTON, 37 'arrowdown': sketchingpy.const.KEYBOARD_DOWN_BUTTON, 38 ' ': sketchingpy.const.KEYBOARD_SPACE_BUTTON, 39 'control': sketchingpy.const.KEYBOARD_CTRL_BUTTON, 40 'alt': sketchingpy.const.KEYBOARD_ALT_BUTTON, 41 'shift': sketchingpy.const.KEYBOARD_SHIFT_BUTTON, 42 'tab': sketchingpy.const.KEYBOARD_TAB_BUTTON, 43 'home': sketchingpy.const.KEYBOARD_HOME_BUTTON, 44 'end': sketchingpy.const.KEYBOARD_END_BUTTON, 45 'enter': sketchingpy.const.KEYBOARD_RETURN_BUTTON, 46 'backspace': sketchingpy.const.KEYBOARD_BACKSPACE_BUTTON, 47 'null': None 48} 49 50OPTIONAL_SKETCH_CALLBACK = typing.Optional[typing.Callable[[sketchingpy.abstracted.Sketch], None]] 51 52 53class CanvasRegionEllipse: 54 """Description of a region of a canvas expressed as an ellipse.""" 55 56 def __init__(self, x: float, y: float, radius_x: float, radius_y: float): 57 """Create a new elliptical region record. 58 59 Args: 60 x: The center x coordinate of the region. 61 y: The center y coordinate of the region. 62 radius_x: The horizontal radius of the region. 63 radius_y: The vertical radius of the region. 64 """ 65 self._x = x 66 self._y = y 67 self._radius_x = radius_x 68 self._radius_y = radius_y 69 70 def get_x(self) -> float: 71 """Get the center horizontal coordinate of this region. 72 73 Returns: 74 The center x coordinate of the region. 75 """ 76 return self._x 77 78 def get_y(self) -> float: 79 """Get the center vertical coordinate of this region. 80 81 Returns: 82 The center y coordinate of the region. 83 """ 84 return self._y 85 86 def get_radius_x(self) -> float: 87 """Get the horizontal size of this region. 88 89 Returns: 90 The horizontal radius of the region. 91 """ 92 return self._radius_x 93 94 def get_radius_y(self) -> float: 95 """Get the vertical size of this region. 96 97 Returns: 98 The vertical radius of the region. 99 """ 100 return self._radius_y 101 102 103class CanvasRegionRect: 104 """Description of a region of a canvas expressed as a rectangle.""" 105 106 def __init__(self, x: float, y: float, width: float, height: float): 107 """Create a new rectangular region record. 108 109 Args: 110 x: The center x coordinate of the region. 111 y: The center y coordinate of the region. 112 radius_x: The horizontal size of the region. 113 radius_y: The vertical size of the region. 114 """ 115 self._x = x 116 self._y = y 117 self._width = width 118 self._height = height 119 120 def get_x(self) -> float: 121 """Get the start horizontal coordinate of this region. 122 123 Returns: 124 The left x coordinate of the region. 125 """ 126 return self._x 127 128 def get_y(self) -> float: 129 """Get the start vertical coordinate of this region. 130 131 Returns: 132 The top y coordinate of the region. 133 """ 134 return self._y 135 136 def get_width(self) -> float: 137 """Get the horizontal size of this region. 138 139 Returns: 140 The horizontal width of the region. 141 """ 142 return self._width 143 144 def get_height(self) -> float: 145 """Get the vertical size of this region. 146 147 Returns: 148 The vertical height of the region. 149 """ 150 return self._height 151 152 153class WebBuffer: 154 """Structure for an offscreen buffer.""" 155 156 def __init__(self, canvas, context, width: int, height: int): 157 """Create a new offscreen record. 158 159 Args: 160 canvas: The offscreen canvas element. 161 context: The 2D drawing context. 162 width: The horizontal size of the buffer in pixels. 163 height: The vertical size of the buffer in pixels. 164 """ 165 self._canvas = canvas 166 self._context = context 167 self._width = width 168 self._height = height 169 170 def get_element(self): 171 """Get the offscreen canvas object. 172 173 Returns: 174 The offscreen canvas element. 175 """ 176 return self._canvas 177 178 def get_context(self): 179 """Get the drawing context compatiable with the main canvas. 180 181 Returns: 182 The 2D drawing context. 183 """ 184 return self._context 185 186 def get_width(self) -> int: 187 """Get the horizontal size of the buffer in pixels. 188 189 Returns: 190 Width of the offscreen canvas. 191 """ 192 return self._width 193 194 def get_height(self) -> int: 195 """Get the vertical size of the buffer in pixels. 196 197 Returns: 198 Height of the offscreen canvas. 199 """ 200 return self._height 201 202 203class Sketch2DWeb(sketchingpy.abstracted.Sketch): 204 """Sketch renderer for web / HTML5.""" 205 206 def __init__(self, width: float, height: float, element_id: str = 'sketch-canvas', 207 loading_id: typing.Optional[str] = 'sketch-load-message'): 208 """Create a new HTML5 Canvas-based sketch. 209 210 Args: 211 width: The horizontal size of the sketch in pixels. Will update the HTML5 element. 212 height: The vertical size of the sketch in pixels. Will update the HTML5 element. 213 element_id: The ID (HTML) of the canvas into which this sketch should be drawn. 214 loading_id: The ID (HTML) of the loading message to hide upon showing the sketch. 215 """ 216 super().__init__() 217 218 if not has_js: 219 raise RuntimeError('Cannot access JS / pyodide.') 220 221 # Save elements required for running the canvas 222 self._element_id = element_id 223 self._element = js.document.getElementById(element_id) 224 self._element.width = width 225 self._element.height = height 226 self._element.style.display = 'none' 227 self._context = self._element.getContext('2d') 228 self._last_render = None 229 230 self._loading_id = loading_id 231 self._loading_element = js.document.getElementById(loading_id) 232 233 # Internal only elements 234 self._internal_loop_callback = None 235 self._internal_mouse_x = 0 236 self._internal_mouse_y = 0 237 self._internal_pre_show_actions: typing.List[typing.Callable] = [] 238 self._added_fonts: typing.Set[str] = set() 239 240 # Buffers 241 self._buffers: typing.Dict[str, WebBuffer] = {} 242 self._base_context = self._context 243 self._base_element = self._element 244 self._width = self._element.width 245 self._height = self._element.height 246 247 # User configurable state 248 self._state_frame_rate = DEFAULT_FPS 249 self._stopped = False 250 251 # Callback 252 self._callback_step: OPTIONAL_SKETCH_CALLBACK = None 253 self._callback_quit: OPTIONAL_SKETCH_CALLBACK = None 254 255 # Control 256 self._keyboard = PyscriptKeyboard(self._element) 257 self._mouse = PyscriptMouse(self._element) 258 259 ########## 260 # Buffer # 261 ########## 262 263 def create_buffer(self, name: str, width: int, height: int, 264 background: typing.Optional[str] = None): 265 canvas = js.window.OffscreenCanvas.new(width, height) 266 context = canvas.getContext('2d') 267 self._buffers[name] = WebBuffer(canvas, context, width, height) 268 if background is not None: 269 context.clearRect(0, 0, self._width, self._height) 270 context.fillStyle = background 271 context.fillRect(0, 0, self._width, self._height) 272 273 def enter_buffer(self, name: str): 274 web_buffer = self._buffers[name] 275 self._context = web_buffer.get_context() 276 self._element = web_buffer.get_element() 277 self._width = web_buffer.get_width() 278 self._height = web_buffer.get_height() 279 280 def exit_buffer(self): 281 self._context = self._base_context 282 self._element = self._base_element 283 self._width = self._base_element.width 284 self._height = self._base_element.height 285 286 def draw_buffer(self, x: float, y: float, name: str): 287 web_buffer = self._buffers[name] 288 self._context.drawImage(web_buffer.get_element(), x, y) 289 290 ############ 291 # Controls # 292 ############ 293 294 def get_keyboard(self) -> typing.Optional[sketchingpy.control_struct.Keyboard]: 295 return self._keyboard 296 297 def get_mouse(self) -> typing.Optional[sketchingpy.control_struct.Mouse]: 298 return self._mouse 299 300 ######## 301 # Data # 302 ######## 303 304 def get_data_layer(self) -> typing.Optional[sketchingpy.data_struct.DataLayer]: 305 return WebDataLayer() 306 307 ########### 308 # Dialogs # 309 ########### 310 311 def get_dialog_layer(self) -> typing.Optional[sketchingpy.dialog_struct.DialogLayer]: 312 return WebDialogLayer(self) 313 314 ########### 315 # Drawing # 316 ########### 317 318 def clear(self, color: str): 319 self._context.clearRect(0, 0, self._width, self._height) 320 self._context.fillStyle = color 321 self._context.fillRect(0, 0, self._width, self._height) 322 323 def draw_arc(self, x1: float, y1: float, x2: float, y2: float, a1: float, a2: float): 324 self._load_draw_params() 325 326 a1_rad = self._convert_to_radians(a1) - js.Math.PI / 2 327 a2_rad = self._convert_to_radians(a2) - js.Math.PI / 2 328 329 current_machine = self._get_current_state_machine() 330 mode_native = current_machine.get_arc_mode_native() 331 mode_str = current_machine.get_arc_mode() 332 333 self._draw_arc_rad(x1, y1, x2, y2, a1_rad, a2_rad, mode_native, mode_str) 334 335 def draw_ellipse(self, x1: float, y1: float, x2: float, y2: float): 336 current_machine = self._get_current_state_machine() 337 mode_native = current_machine.get_ellipse_mode_native() 338 mode_str = current_machine.get_ellipse_mode() 339 340 self._draw_arc_rad(x1, y1, x2, y2, 0, 2 * js.Math.PI, mode_native, mode_str) 341 342 def draw_line(self, x1: float, y1: float, x2: float, y2: float): 343 current_machine = self._get_current_state_machine() 344 if not current_machine.get_stroke_enabled(): 345 return 346 347 self._load_draw_params() 348 349 self._context.beginPath() 350 self._context.moveTo(x1, y1) 351 self._context.lineTo(x2, y2) 352 self._context.stroke() 353 354 def draw_rect(self, x1: float, y1: float, x2: float, y2: float): 355 self._load_draw_params() 356 357 current_machine = self._get_current_state_machine() 358 native_mode = current_machine.get_rect_mode_native() 359 mode_str = current_machine.get_rect_mode_native() 360 361 region = self._get_canvas_region_rect_like(x1, y1, x2, y2, native_mode, mode_str) 362 363 self._context.beginPath() 364 self._context.rect(region.get_x(), region.get_y(), region.get_width(), region.get_height()) 365 366 if current_machine.get_fill_enabled(): 367 self._context.fill() 368 369 if current_machine.get_stroke_enabled(): 370 self._context.stroke() 371 372 def draw_shape(self, shape: sketchingpy.shape_struct.Shape): 373 current_machine = self._get_current_state_machine() 374 375 self._load_draw_params() 376 377 self._context.beginPath() 378 self._context.moveTo(shape.get_start_x(), shape.get_start_y()) 379 380 for segment in shape.get_segments(): 381 strategy = segment.get_strategy() 382 if strategy == 'straight': 383 self._context.lineTo(segment.get_destination_x(), segment.get_destination_y()) 384 elif strategy == 'bezier': 385 self._context.bezierCurveTo( 386 segment.get_control_x1(), 387 segment.get_control_y1(), 388 segment.get_control_x2(), 389 segment.get_control_y2(), 390 segment.get_destination_x(), 391 segment.get_destination_y() 392 ) 393 else: 394 raise RuntimeError('Unsupported segment type: ' + strategy) 395 396 if shape.get_is_closed(): 397 self._context.closePath() 398 399 if current_machine.get_fill_enabled(): 400 self._context.fill() 401 402 if current_machine.get_stroke_enabled(): 403 self._context.stroke() 404 405 def draw_text(self, x: float, y: float, content: str): 406 content = str(content) 407 current_machine = self._get_current_state_machine() 408 409 self._load_draw_params() 410 self._load_font_params() 411 412 if current_machine.get_fill_enabled(): 413 self._context.fillText(content, x, y) 414 415 if current_machine.get_stroke_enabled(): 416 self._context.strokeText(content, x, y) 417 418 ########## 419 # Events # 420 ########## 421 422 def on_step(self, callback: sketchingpy.abstracted.StepCallback): 423 self._callback_step = callback 424 425 def on_quit(self, callback: sketchingpy.abstracted.QuitCallback): 426 self._callback_quit = callback 427 428 ######### 429 # Image # 430 ######### 431 432 def get_image(self, src: str) -> sketchingpy.abstracted.Image: 433 return WebImage(src) 434 435 def draw_image(self, x: float, y: float, image: sketchingpy.abstracted.Image): 436 if not image.get_is_loaded(): 437 return 438 439 self._load_draw_params() 440 441 current_machine = self._get_current_state_machine() 442 native_mode = current_machine.get_image_mode_native() 443 mode_str = current_machine.get_image_mode_native() 444 445 width = image.get_width() 446 height = image.get_height() 447 448 region = self._get_canvas_region_rect_like(x, y, width, height, native_mode, mode_str) 449 450 self._context.drawImage( 451 image.get_native(), 452 region.get_x(), 453 region.get_y(), 454 region.get_width(), 455 region.get_height() 456 ) 457 458 def save_image(self, path: str): 459 if not path.endswith('.png'): 460 raise RuntimeError('Web export only supported to PNG.') 461 462 link = js.document.createElement('a') 463 link.download = path 464 link.href = self._element.toDataURL('image/png') 465 link.click() 466 467 ######### 468 # State # 469 ######### 470 471 def set_text_font(self, identifier: str, size: float): 472 super().set_text_font(identifier, size) 473 474 is_otf = identifier.endswith('.otf') 475 is_ttf = identifier.endswith('.ttf') 476 is_file = is_otf or is_ttf 477 478 if not is_file: 479 return 480 481 current_machine = self._get_current_state_machine() 482 font = current_machine.get_text_font() 483 font_name = sketchingpy.abstracted.get_font_name(font, '/') 484 485 if font_name in self._added_fonts: 486 return 487 488 naked_font_name_components = font_name.split(' ')[1:] 489 naked_font_name = ' '.join(naked_font_name_components) 490 491 new_font = pyscript.window.FontFace.new(naked_font_name, 'url(%s)' % identifier) 492 new_font.load() 493 pyscript.document.fonts.add(new_font) 494 self._added_fonts.add(font_name) 495 496 def push_transform(self): 497 self._context.save() 498 499 def pop_transform(self): 500 self._context.restore() 501 502 ########## 503 # System # 504 ########## 505 506 def get_native(self): 507 return self._element 508 509 def set_fps(self, rate: int): 510 self._state_frame_rate = rate 511 512 def set_title(self, title: str): 513 js.document.title = title 514 515 def quit(self): 516 self._stopped = True 517 518 def show(self, ax=None): 519 self._show_internal(ax=ax, quit_immediately=False) 520 521 def show_and_quit(self, ax=None): 522 self._show_internal(ax=ax, quit_immediately=True) 523 524 def print(self, message: str): 525 console_id = self._element_id + '-console' 526 target_root = js.document.getElementById(console_id) 527 528 if target_root is None: 529 print(message) 530 return 531 532 new_li = pyscript.document.createElement('li') 533 new_content = pyscript.document.createTextNode(message) 534 new_li.appendChild(new_content) 535 536 target_root.appendChild(new_li) 537 538 ############# 539 # Transform # 540 ############# 541 542 def translate(self, x: float, y: float): 543 self._context.translate(x, y) 544 545 def rotate(self, angle: float): 546 angle_rad = self._convert_to_radians(angle) 547 self._context.rotate(angle_rad) 548 549 def scale(self, scale: float): 550 self._context.scale(scale, scale) 551 552 ########### 553 # Support # 554 ########### 555 556 def _show_internal(self, ax=None, quit_immediately=False): 557 self._loading_element.style.display = 'none' 558 self._element.style.display = 'inline-block' 559 self._version = str(round(time.time())) 560 self._element.setAttribute('version', self._version) 561 562 self._snapshot_time() 563 564 for action in self._internal_pre_show_actions: 565 action() 566 567 if not quit_immediately: 568 self._stopped = False 569 570 self._last_render = time.time() 571 self._internal_loop_callback = pyodide.ffi.create_proxy(lambda: self._inner_loop()) 572 573 self._inner_loop() 574 575 def _inner_loop(self): 576 if self._element.getAttribute('version') != self._version: 577 self._stopped = True 578 579 if self._stopped: 580 if self._callback_quit is not None: 581 self._callback_quit(self) 582 return 583 584 if self._callback_step is not None: 585 self._callback_step(self) 586 587 time_elapsed = (time.time() - self._last_render) * 1000 588 time_delay = round(1000 / self._state_frame_rate - time_elapsed) 589 590 js.setTimeout(self._internal_loop_callback, time_delay) 591 592 def _create_state_machine(self): 593 return PyscriptSketchStateMachine() 594 595 def _get_canvas_region_arc_ellipse(self, x1: float, y1: float, x2: float, 596 y2: float, mode_native: int, mode_str: str) -> CanvasRegionEllipse: 597 if mode_native == sketchingpy.const.CENTER: 598 center_x = x1 599 center_y = y1 600 radius_x = x2 / 2 601 radius_y = y2 / 2 602 elif mode_native == sketchingpy.const.RADIUS: 603 center_x = x1 604 center_y = y1 605 radius_x = x2 606 radius_y = y2 607 elif mode_native == sketchingpy.const.CORNER: 608 center_x = x1 + x2 / 2 609 center_y = y1 + y2 / 2 610 radius_x = x2 / 2 611 radius_y = y2 / 2 612 elif mode_native == sketchingpy.const.CORNERS: 613 (x1, y1, x2, y2) = sketchingpy.abstracted.reorder_coords(x1, y1, x2, y2) 614 width = x2 - x1 615 height = y2 - y1 616 center_x = x1 + width / 2 617 center_y = y1 + height / 2 618 radius_x = width / 2 619 radius_y = height / 2 620 else: 621 raise RuntimeError('Unknown mode: ' + mode_str) 622 623 return CanvasRegionEllipse(center_x, center_y, radius_x, radius_y) 624 625 def _get_canvas_region_rect_like(self, x1: float, y1: float, x2: float, 626 y2: float, native_mode: int, mode_str: str) -> CanvasRegionRect: 627 if native_mode == sketchingpy.const.CENTER: 628 start_x = x1 - x2 / 2 629 start_y = y1 - y2 / 2 630 width = x2 631 height = y2 632 elif native_mode == sketchingpy.const.RADIUS: 633 start_x = x1 - x2 634 start_y = y1 - y2 635 width = x2 * 2 636 height = y2 * 2 637 elif native_mode == sketchingpy.const.CORNER: 638 start_x = x1 639 start_y = y1 640 width = 1 if x2 == 0 else x2 641 height = 1 if y2 == 0 else y2 642 elif native_mode == sketchingpy.const.CORNERS: 643 (x1, y1, x2, y2) = sketchingpy.abstracted.reorder_coords(x1, y1, x2, y2) 644 start_x = x1 645 start_y = y1 646 width = x2 - x1 647 height = y2 - y1 648 else: 649 raise RuntimeError('Unknown mode: ' + mode_str) 650 651 return CanvasRegionRect(start_x, start_y, width, height) 652 653 def _load_draw_params(self): 654 current_machine = self._get_current_state_machine() 655 self._context.fillStyle = current_machine.get_fill_native() 656 self._context.strokeStyle = current_machine.get_stroke_native() 657 self._context.lineWidth = current_machine.get_stroke_weight_native() 658 659 def _load_font_params(self): 660 current_machine = self._get_current_state_machine() 661 662 self._context.font = current_machine.get_text_font_native() 663 664 text_align = current_machine.get_text_align_native() 665 self._context.textAlign = text_align.get_horizontal_align() 666 self._context.textBaseline = text_align.get_vertical_align() 667 668 def _draw_arc_rad(self, x1: float, y1: float, x2: float, y2: float, a1: float, a2: float, 669 mode_native: int, mode_str: str): 670 self._load_draw_params() 671 672 current_machine = self._get_current_state_machine() 673 region = self._get_canvas_region_arc_ellipse(x1, y1, x2, y2, mode_native, mode_str) 674 675 self._context.beginPath() 676 677 self._context.ellipse( 678 region.get_x(), 679 region.get_y(), 680 region.get_radius_x(), 681 region.get_radius_y(), 682 0, 683 a1, 684 a2 685 ) 686 687 if current_machine.get_fill_enabled(): 688 self._context.fill() 689 690 if current_machine.get_stroke_enabled(): 691 self._context.stroke() 692 693 694class PyscriptSketchStateMachine(sketchingpy.state_struct.SketchStateMachine): 695 """Implementation of SketchStateMachine for Pyscript types.""" 696 697 def __init__(self): 698 """Create a new state machine for Pyscript-based sketches.""" 699 super().__init__() 700 self._text_align_native = self._transform_text_align(super().get_text_align_native()) 701 702 def set_text_align(self, text_align: sketchingpy.state_struct.TextAlign): 703 super().set_text_align(text_align) 704 self._text_align_native = self._transform_text_align(super().get_text_align_native()) 705 706 def get_text_align_native(self): 707 return self._text_align_native 708 709 def get_text_font_native(self): 710 return sketchingpy.abstracted.get_font_name(self.get_text_font(), os.path.sep) 711 712 def _transform_text_align(self, 713 text_align: sketchingpy.state_struct.TextAlign) -> sketchingpy.state_struct.TextAlign: 714 715 HORIZONTAL_ALIGNS = { 716 sketchingpy.const.LEFT: 'left', 717 sketchingpy.const.CENTER: 'center', 718 sketchingpy.const.RIGHT: 'right' 719 } 720 721 VERTICAL_ALIGNS = { 722 sketchingpy.const.TOP: 'top', 723 sketchingpy.const.CENTER: 'middle', 724 sketchingpy.const.BASELINE: 'alphabetic', 725 sketchingpy.const.BOTTOM: 'bottom' 726 } 727 728 return sketchingpy.state_struct.TextAlign( 729 HORIZONTAL_ALIGNS[text_align.get_horizontal_align()], 730 VERTICAL_ALIGNS[text_align.get_vertical_align()] 731 ) 732 733 734class WebImage(sketchingpy.abstracted.Image): 735 """Strategy implementation for HTML images.""" 736 737 def __init__(self, src: str): 738 """Create a new image. 739 740 Args: 741 src: Path to the image. 742 """ 743 super().__init__(src) 744 745 preload_suffix = src.replace("./", "").replace("/", "").replace(".", "-").replace(" ", "-") 746 preload_name = "preload-img-" + preload_suffix 747 preloaded_image = js.document.getElementById(preload_name) 748 749 if preloaded_image: 750 image = preloaded_image 751 else: 752 image = js.document.createElement("img") 753 image.src = src 754 755 self._native = image 756 self._width: typing.Optional[float] = None 757 self._height: typing.Optional[float] = None 758 759 def get_width(self) -> float: 760 if self._width is None: 761 return self._native.width 762 else: 763 return self._width 764 765 def get_height(self) -> float: 766 if self._height is None: 767 return self._native.height 768 else: 769 return self._height 770 771 def resize(self, width: float, height: float): 772 self._width = width 773 self._height = height 774 775 def get_native(self): 776 return self._native 777 778 def get_is_loaded(self): 779 return self._native.width > 0 780 781 782class PyscriptMouse(sketchingpy.control_struct.Mouse): 783 """Strategy implementation for Pyscript-based mouse access.""" 784 785 def __init__(self, element): 786 """Create a new mouse strategy using HTML5. 787 788 Args: 789 element: The element to which mouse event listeners should be added. 790 """ 791 self._element = element 792 793 self._x = 0 794 self._y = 0 795 796 self._buttons_pressed = set() 797 798 mouse_move_callback = pyodide.ffi.create_proxy( 799 lambda event: self._report_mouse_move(event) 800 ) 801 self._element.addEventListener( 802 'mousemove', 803 mouse_move_callback 804 ) 805 806 mouse_down_callback = pyodide.ffi.create_proxy( 807 lambda event: self._report_mouse_down(event) 808 ) 809 self._element.addEventListener( 810 'mousedown', 811 mouse_down_callback 812 ) 813 814 click_callback = pyodide.ffi.create_proxy( 815 lambda event: self._report_click(event) 816 ) 817 self._element.addEventListener( 818 'click', 819 click_callback 820 ) 821 822 context_menu_callback = pyodide.ffi.create_proxy( 823 lambda event: self._report_context_menu(event) 824 ) 825 self._element.addEventListener( 826 'contextmenu', 827 context_menu_callback 828 ) 829 830 self._press_callback = None 831 self._release_callback = None 832 833 def get_pointer_x(self): 834 return self._x 835 836 def get_pointer_y(self): 837 return self._y 838 839 def get_buttons_pressed(self) -> sketchingpy.control_struct.Buttons: 840 return map(lambda x: sketchingpy.control_struct.Button(x), self._buttons_pressed) 841 842 def on_button_press(self, callback: sketchingpy.control_struct.MouseCallback): 843 self._press_callback = callback 844 845 def on_button_release(self, callback: sketchingpy.control_struct.MouseCallback): 846 self._release_callback = callback 847 848 def _report_mouse_move(self, event): 849 bounding_box = self._element.getBoundingClientRect() 850 self._x = event.clientX - bounding_box.left 851 self._y = event.clientY - bounding_box.top 852 853 def _report_mouse_down(self, event): 854 if event.button == 0: 855 self._buttons_pressed.add(sketchingpy.const.MOUSE_LEFT_BUTTON) 856 if self._press_callback is not None: 857 button = sketchingpy.control_struct.Button(sketchingpy.const.MOUSE_LEFT_BUTTON) 858 self._press_callback(button) 859 elif event.button == 2: 860 self._buttons_pressed.add(sketchingpy.const.MOUSE_RIGHT_BUTTON) 861 if self._press_callback is not None: 862 button = sketchingpy.control_struct.Button(sketchingpy.const.MOUSE_RIGHT_BUTTON) 863 self._press_callback(button) 864 865 def _report_click(self, event): 866 self._buttons_pressed.remove(sketchingpy.const.MOUSE_LEFT_BUTTON) 867 868 if self._release_callback is not None: 869 button = sketchingpy.control_struct.Button(sketchingpy.const.MOUSE_LEFT_BUTTON) 870 self._release_callback(button) 871 872 event.preventDefault() 873 874 def _report_context_menu(self, event): 875 self._buttons_pressed.remove(sketchingpy.const.MOUSE_RIGHT_BUTTON) 876 877 if self._release_callback is not None: 878 button = sketchingpy.control_struct.Button(sketchingpy.const.MOUSE_RIGHT_BUTTON) 879 self._release_callback(button) 880 881 event.preventDefault() 882 883 884class PyscriptKeyboard(sketchingpy.control_struct.Keyboard): 885 """Strategy implementation for Pyscript-based keyboard access.""" 886 887 def __init__(self, element): 888 """Create a new mouse strategy using HTML5 and Pyscript. 889 890 Args: 891 element: The element to which keyboard event listeners should be added. 892 """ 893 super().__init__() 894 self._element = element 895 self._pressed = set() 896 self._press_callback = None 897 self._release_callback = None 898 899 keydown_callback = pyodide.ffi.create_proxy( 900 lambda event: self._report_key_down(event) 901 ) 902 self._element.addEventListener( 903 'keydown', 904 keydown_callback 905 ) 906 907 keyup_callback = pyodide.ffi.create_proxy( 908 lambda event: self._report_key_up(event) 909 ) 910 self._element.addEventListener( 911 'keyup', 912 keyup_callback 913 ) 914 915 def get_keys_pressed(self) -> sketchingpy.control_struct.Buttons: 916 return map(lambda x: sketchingpy.control_struct.Button(x), self._pressed) 917 918 def on_key_press(self, callback: sketchingpy.control_struct.KeyboardCallback): 919 self._press_callback = callback 920 921 def on_key_release(self, callback: sketchingpy.control_struct.KeyboardCallback): 922 self._release_callback = callback 923 924 def _report_key_down(self, event): 925 key = self._map_key(event.key) 926 927 if key is None: 928 return 929 930 self._pressed.add(key) 931 932 if self._press_callback is not None: 933 button = sketchingpy.control_struct.Button(key) 934 self._press_callback(button) 935 936 event.preventDefault() 937 938 def _report_key_up(self, event): 939 key = self._map_key(event.key) 940 941 if key is None: 942 return 943 944 self._pressed.remove(key) 945 946 if self._release_callback is not None: 947 button = sketchingpy.control_struct.Button(key) 948 self._release_callback(button) 949 950 event.preventDefault() 951 952 def _map_key(self, target: str) -> typing.Optional[str]: 953 if target in KEY_MAP: 954 return KEY_MAP[target.lower()] # Required for browser compatibility 955 else: 956 return target.lower() 957 958 959class WebDataLayer(sketchingpy.data_struct.DataLayer): 960 """Data layer which interfaces with network and browser.""" 961 962 def get_csv(self, path: str) -> sketchingpy.data_struct.Records: 963 if os.path.exists(path): 964 with open(path) as f: 965 reader = csv.DictReader(f) 966 return list(reader) 967 else: 968 string_io = pyodide.http.open_url(path) 969 reader = csv.DictReader(string_io) 970 return list(reader) 971 972 def write_csv(self, records: sketchingpy.data_struct.Records, 973 columns: sketchingpy.data_struct.Columns, path: str): 974 def build_record(target: typing.Dict) -> typing.Dict: 975 return dict(map(lambda key: (key, target[key]), columns)) 976 977 records_serialized = map(build_record, records) 978 979 target = io.StringIO() 980 981 writer = csv.DictWriter(target, fieldnames=columns) # type: ignore 982 writer.writeheader() 983 writer.writerows(records_serialized) 984 985 self._download_text(target.getvalue(), path, 'text/csv') 986 987 def get_json(self, path: str): 988 if os.path.exists(path): 989 with open(path) as f: 990 return json.load(f) 991 else: 992 string_io = pyodide.http.open_url(path) 993 return json.loads(string_io.read()) 994 995 def write_json(self, target, path: str): 996 self._download_text(json.dumps(target), path, 'application/json') 997 998 def get_text(self, path: str): 999 string_io = pyodide.http.open_url(path) 1000 return string_io.read() 1001 1002 def write_text(self, target, path: str): 1003 self._download_text(target, path, 'text/plain') 1004 1005 def _download_text(self, text: str, filename: str, mime: str): 1006 text_encoded = urllib.parse.quote(text) 1007 1008 link = js.document.createElement('a') 1009 link.download = filename 1010 link.href = 'data:%s;charset=utf-8,%s' % (mime, text_encoded) 1011 1012 link.click() 1013 1014 1015class WebDialogLayer(sketchingpy.dialog_struct.DialogLayer): 1016 """Dialog / simple UI layer for web apps.""" 1017 1018 def __init__(self, sketch: Sketch2DWeb): 1019 """"Initialize tkinter but hide the root window.""" 1020 self._sketch = sketch 1021 1022 def show_alert(self, message: str, callback: typing.Optional[typing.Callable[[], None]] = None): 1023 pyscript.window.alert(message) 1024 if callback is not None: 1025 callback() 1026 1027 def show_prompt(self, message: str, 1028 callback: typing.Optional[typing.Callable[[str], None]] = None): 1029 response = pyscript.window.prompt(message) 1030 if callback is not None and response is not None: 1031 callback(response) 1032 1033 def get_file_save_location(self, 1034 callback: typing.Optional[typing.Callable[[str], None]] = None): 1035 self.show_prompt('Filename to save:', callback) 1036 1037 def get_file_load_location(self, 1038 callback: typing.Optional[typing.Callable[[str], None]] = None): 1039 self.show_prompt('Filename to load:', callback)
54class CanvasRegionEllipse: 55 """Description of a region of a canvas expressed as an ellipse.""" 56 57 def __init__(self, x: float, y: float, radius_x: float, radius_y: float): 58 """Create a new elliptical region record. 59 60 Args: 61 x: The center x coordinate of the region. 62 y: The center y coordinate of the region. 63 radius_x: The horizontal radius of the region. 64 radius_y: The vertical radius of the region. 65 """ 66 self._x = x 67 self._y = y 68 self._radius_x = radius_x 69 self._radius_y = radius_y 70 71 def get_x(self) -> float: 72 """Get the center horizontal coordinate of this region. 73 74 Returns: 75 The center x coordinate of the region. 76 """ 77 return self._x 78 79 def get_y(self) -> float: 80 """Get the center vertical coordinate of this region. 81 82 Returns: 83 The center y coordinate of the region. 84 """ 85 return self._y 86 87 def get_radius_x(self) -> float: 88 """Get the horizontal size of this region. 89 90 Returns: 91 The horizontal radius of the region. 92 """ 93 return self._radius_x 94 95 def get_radius_y(self) -> float: 96 """Get the vertical size of this region. 97 98 Returns: 99 The vertical radius of the region. 100 """ 101 return self._radius_y
Description of a region of a canvas expressed as an ellipse.
57 def __init__(self, x: float, y: float, radius_x: float, radius_y: float): 58 """Create a new elliptical region record. 59 60 Args: 61 x: The center x coordinate of the region. 62 y: The center y coordinate of the region. 63 radius_x: The horizontal radius of the region. 64 radius_y: The vertical radius of the region. 65 """ 66 self._x = x 67 self._y = y 68 self._radius_x = radius_x 69 self._radius_y = radius_y
Create a new elliptical region record.
Arguments:
- x: The center x coordinate of the region.
- y: The center y coordinate of the region.
- radius_x: The horizontal radius of the region.
- radius_y: The vertical radius of the region.
71 def get_x(self) -> float: 72 """Get the center horizontal coordinate of this region. 73 74 Returns: 75 The center x coordinate of the region. 76 """ 77 return self._x
Get the center horizontal coordinate of this region.
Returns:
The center x coordinate of the region.
79 def get_y(self) -> float: 80 """Get the center vertical coordinate of this region. 81 82 Returns: 83 The center y coordinate of the region. 84 """ 85 return self._y
Get the center vertical coordinate of this region.
Returns:
The center y coordinate of the region.
104class CanvasRegionRect: 105 """Description of a region of a canvas expressed as a rectangle.""" 106 107 def __init__(self, x: float, y: float, width: float, height: float): 108 """Create a new rectangular region record. 109 110 Args: 111 x: The center x coordinate of the region. 112 y: The center y coordinate of the region. 113 radius_x: The horizontal size of the region. 114 radius_y: The vertical size of the region. 115 """ 116 self._x = x 117 self._y = y 118 self._width = width 119 self._height = height 120 121 def get_x(self) -> float: 122 """Get the start horizontal coordinate of this region. 123 124 Returns: 125 The left x coordinate of the region. 126 """ 127 return self._x 128 129 def get_y(self) -> float: 130 """Get the start vertical coordinate of this region. 131 132 Returns: 133 The top y coordinate of the region. 134 """ 135 return self._y 136 137 def get_width(self) -> float: 138 """Get the horizontal size of this region. 139 140 Returns: 141 The horizontal width of the region. 142 """ 143 return self._width 144 145 def get_height(self) -> float: 146 """Get the vertical size of this region. 147 148 Returns: 149 The vertical height of the region. 150 """ 151 return self._height
Description of a region of a canvas expressed as a rectangle.
107 def __init__(self, x: float, y: float, width: float, height: float): 108 """Create a new rectangular region record. 109 110 Args: 111 x: The center x coordinate of the region. 112 y: The center y coordinate of the region. 113 radius_x: The horizontal size of the region. 114 radius_y: The vertical size of the region. 115 """ 116 self._x = x 117 self._y = y 118 self._width = width 119 self._height = height
Create a new rectangular region record.
Arguments:
- x: The center x coordinate of the region.
- y: The center y coordinate of the region.
- radius_x: The horizontal size of the region.
- radius_y: The vertical size of the region.
121 def get_x(self) -> float: 122 """Get the start horizontal coordinate of this region. 123 124 Returns: 125 The left x coordinate of the region. 126 """ 127 return self._x
Get the start horizontal coordinate of this region.
Returns:
The left x coordinate of the region.
129 def get_y(self) -> float: 130 """Get the start vertical coordinate of this region. 131 132 Returns: 133 The top y coordinate of the region. 134 """ 135 return self._y
Get the start vertical coordinate of this region.
Returns:
The top y coordinate of the region.
154class WebBuffer: 155 """Structure for an offscreen buffer.""" 156 157 def __init__(self, canvas, context, width: int, height: int): 158 """Create a new offscreen record. 159 160 Args: 161 canvas: The offscreen canvas element. 162 context: The 2D drawing context. 163 width: The horizontal size of the buffer in pixels. 164 height: The vertical size of the buffer in pixels. 165 """ 166 self._canvas = canvas 167 self._context = context 168 self._width = width 169 self._height = height 170 171 def get_element(self): 172 """Get the offscreen canvas object. 173 174 Returns: 175 The offscreen canvas element. 176 """ 177 return self._canvas 178 179 def get_context(self): 180 """Get the drawing context compatiable with the main canvas. 181 182 Returns: 183 The 2D drawing context. 184 """ 185 return self._context 186 187 def get_width(self) -> int: 188 """Get the horizontal size of the buffer in pixels. 189 190 Returns: 191 Width of the offscreen canvas. 192 """ 193 return self._width 194 195 def get_height(self) -> int: 196 """Get the vertical size of the buffer in pixels. 197 198 Returns: 199 Height of the offscreen canvas. 200 """ 201 return self._height
Structure for an offscreen buffer.
157 def __init__(self, canvas, context, width: int, height: int): 158 """Create a new offscreen record. 159 160 Args: 161 canvas: The offscreen canvas element. 162 context: The 2D drawing context. 163 width: The horizontal size of the buffer in pixels. 164 height: The vertical size of the buffer in pixels. 165 """ 166 self._canvas = canvas 167 self._context = context 168 self._width = width 169 self._height = height
Create a new offscreen record.
Arguments:
- canvas: The offscreen canvas element.
- context: The 2D drawing context.
- width: The horizontal size of the buffer in pixels.
- height: The vertical size of the buffer in pixels.
171 def get_element(self): 172 """Get the offscreen canvas object. 173 174 Returns: 175 The offscreen canvas element. 176 """ 177 return self._canvas
Get the offscreen canvas object.
Returns:
The offscreen canvas element.
179 def get_context(self): 180 """Get the drawing context compatiable with the main canvas. 181 182 Returns: 183 The 2D drawing context. 184 """ 185 return self._context
Get the drawing context compatiable with the main canvas.
Returns:
The 2D drawing context.
204class Sketch2DWeb(sketchingpy.abstracted.Sketch): 205 """Sketch renderer for web / HTML5.""" 206 207 def __init__(self, width: float, height: float, element_id: str = 'sketch-canvas', 208 loading_id: typing.Optional[str] = 'sketch-load-message'): 209 """Create a new HTML5 Canvas-based sketch. 210 211 Args: 212 width: The horizontal size of the sketch in pixels. Will update the HTML5 element. 213 height: The vertical size of the sketch in pixels. Will update the HTML5 element. 214 element_id: The ID (HTML) of the canvas into which this sketch should be drawn. 215 loading_id: The ID (HTML) of the loading message to hide upon showing the sketch. 216 """ 217 super().__init__() 218 219 if not has_js: 220 raise RuntimeError('Cannot access JS / pyodide.') 221 222 # Save elements required for running the canvas 223 self._element_id = element_id 224 self._element = js.document.getElementById(element_id) 225 self._element.width = width 226 self._element.height = height 227 self._element.style.display = 'none' 228 self._context = self._element.getContext('2d') 229 self._last_render = None 230 231 self._loading_id = loading_id 232 self._loading_element = js.document.getElementById(loading_id) 233 234 # Internal only elements 235 self._internal_loop_callback = None 236 self._internal_mouse_x = 0 237 self._internal_mouse_y = 0 238 self._internal_pre_show_actions: typing.List[typing.Callable] = [] 239 self._added_fonts: typing.Set[str] = set() 240 241 # Buffers 242 self._buffers: typing.Dict[str, WebBuffer] = {} 243 self._base_context = self._context 244 self._base_element = self._element 245 self._width = self._element.width 246 self._height = self._element.height 247 248 # User configurable state 249 self._state_frame_rate = DEFAULT_FPS 250 self._stopped = False 251 252 # Callback 253 self._callback_step: OPTIONAL_SKETCH_CALLBACK = None 254 self._callback_quit: OPTIONAL_SKETCH_CALLBACK = None 255 256 # Control 257 self._keyboard = PyscriptKeyboard(self._element) 258 self._mouse = PyscriptMouse(self._element) 259 260 ########## 261 # Buffer # 262 ########## 263 264 def create_buffer(self, name: str, width: int, height: int, 265 background: typing.Optional[str] = None): 266 canvas = js.window.OffscreenCanvas.new(width, height) 267 context = canvas.getContext('2d') 268 self._buffers[name] = WebBuffer(canvas, context, width, height) 269 if background is not None: 270 context.clearRect(0, 0, self._width, self._height) 271 context.fillStyle = background 272 context.fillRect(0, 0, self._width, self._height) 273 274 def enter_buffer(self, name: str): 275 web_buffer = self._buffers[name] 276 self._context = web_buffer.get_context() 277 self._element = web_buffer.get_element() 278 self._width = web_buffer.get_width() 279 self._height = web_buffer.get_height() 280 281 def exit_buffer(self): 282 self._context = self._base_context 283 self._element = self._base_element 284 self._width = self._base_element.width 285 self._height = self._base_element.height 286 287 def draw_buffer(self, x: float, y: float, name: str): 288 web_buffer = self._buffers[name] 289 self._context.drawImage(web_buffer.get_element(), x, y) 290 291 ############ 292 # Controls # 293 ############ 294 295 def get_keyboard(self) -> typing.Optional[sketchingpy.control_struct.Keyboard]: 296 return self._keyboard 297 298 def get_mouse(self) -> typing.Optional[sketchingpy.control_struct.Mouse]: 299 return self._mouse 300 301 ######## 302 # Data # 303 ######## 304 305 def get_data_layer(self) -> typing.Optional[sketchingpy.data_struct.DataLayer]: 306 return WebDataLayer() 307 308 ########### 309 # Dialogs # 310 ########### 311 312 def get_dialog_layer(self) -> typing.Optional[sketchingpy.dialog_struct.DialogLayer]: 313 return WebDialogLayer(self) 314 315 ########### 316 # Drawing # 317 ########### 318 319 def clear(self, color: str): 320 self._context.clearRect(0, 0, self._width, self._height) 321 self._context.fillStyle = color 322 self._context.fillRect(0, 0, self._width, self._height) 323 324 def draw_arc(self, x1: float, y1: float, x2: float, y2: float, a1: float, a2: float): 325 self._load_draw_params() 326 327 a1_rad = self._convert_to_radians(a1) - js.Math.PI / 2 328 a2_rad = self._convert_to_radians(a2) - js.Math.PI / 2 329 330 current_machine = self._get_current_state_machine() 331 mode_native = current_machine.get_arc_mode_native() 332 mode_str = current_machine.get_arc_mode() 333 334 self._draw_arc_rad(x1, y1, x2, y2, a1_rad, a2_rad, mode_native, mode_str) 335 336 def draw_ellipse(self, x1: float, y1: float, x2: float, y2: float): 337 current_machine = self._get_current_state_machine() 338 mode_native = current_machine.get_ellipse_mode_native() 339 mode_str = current_machine.get_ellipse_mode() 340 341 self._draw_arc_rad(x1, y1, x2, y2, 0, 2 * js.Math.PI, mode_native, mode_str) 342 343 def draw_line(self, x1: float, y1: float, x2: float, y2: float): 344 current_machine = self._get_current_state_machine() 345 if not current_machine.get_stroke_enabled(): 346 return 347 348 self._load_draw_params() 349 350 self._context.beginPath() 351 self._context.moveTo(x1, y1) 352 self._context.lineTo(x2, y2) 353 self._context.stroke() 354 355 def draw_rect(self, x1: float, y1: float, x2: float, y2: float): 356 self._load_draw_params() 357 358 current_machine = self._get_current_state_machine() 359 native_mode = current_machine.get_rect_mode_native() 360 mode_str = current_machine.get_rect_mode_native() 361 362 region = self._get_canvas_region_rect_like(x1, y1, x2, y2, native_mode, mode_str) 363 364 self._context.beginPath() 365 self._context.rect(region.get_x(), region.get_y(), region.get_width(), region.get_height()) 366 367 if current_machine.get_fill_enabled(): 368 self._context.fill() 369 370 if current_machine.get_stroke_enabled(): 371 self._context.stroke() 372 373 def draw_shape(self, shape: sketchingpy.shape_struct.Shape): 374 current_machine = self._get_current_state_machine() 375 376 self._load_draw_params() 377 378 self._context.beginPath() 379 self._context.moveTo(shape.get_start_x(), shape.get_start_y()) 380 381 for segment in shape.get_segments(): 382 strategy = segment.get_strategy() 383 if strategy == 'straight': 384 self._context.lineTo(segment.get_destination_x(), segment.get_destination_y()) 385 elif strategy == 'bezier': 386 self._context.bezierCurveTo( 387 segment.get_control_x1(), 388 segment.get_control_y1(), 389 segment.get_control_x2(), 390 segment.get_control_y2(), 391 segment.get_destination_x(), 392 segment.get_destination_y() 393 ) 394 else: 395 raise RuntimeError('Unsupported segment type: ' + strategy) 396 397 if shape.get_is_closed(): 398 self._context.closePath() 399 400 if current_machine.get_fill_enabled(): 401 self._context.fill() 402 403 if current_machine.get_stroke_enabled(): 404 self._context.stroke() 405 406 def draw_text(self, x: float, y: float, content: str): 407 content = str(content) 408 current_machine = self._get_current_state_machine() 409 410 self._load_draw_params() 411 self._load_font_params() 412 413 if current_machine.get_fill_enabled(): 414 self._context.fillText(content, x, y) 415 416 if current_machine.get_stroke_enabled(): 417 self._context.strokeText(content, x, y) 418 419 ########## 420 # Events # 421 ########## 422 423 def on_step(self, callback: sketchingpy.abstracted.StepCallback): 424 self._callback_step = callback 425 426 def on_quit(self, callback: sketchingpy.abstracted.QuitCallback): 427 self._callback_quit = callback 428 429 ######### 430 # Image # 431 ######### 432 433 def get_image(self, src: str) -> sketchingpy.abstracted.Image: 434 return WebImage(src) 435 436 def draw_image(self, x: float, y: float, image: sketchingpy.abstracted.Image): 437 if not image.get_is_loaded(): 438 return 439 440 self._load_draw_params() 441 442 current_machine = self._get_current_state_machine() 443 native_mode = current_machine.get_image_mode_native() 444 mode_str = current_machine.get_image_mode_native() 445 446 width = image.get_width() 447 height = image.get_height() 448 449 region = self._get_canvas_region_rect_like(x, y, width, height, native_mode, mode_str) 450 451 self._context.drawImage( 452 image.get_native(), 453 region.get_x(), 454 region.get_y(), 455 region.get_width(), 456 region.get_height() 457 ) 458 459 def save_image(self, path: str): 460 if not path.endswith('.png'): 461 raise RuntimeError('Web export only supported to PNG.') 462 463 link = js.document.createElement('a') 464 link.download = path 465 link.href = self._element.toDataURL('image/png') 466 link.click() 467 468 ######### 469 # State # 470 ######### 471 472 def set_text_font(self, identifier: str, size: float): 473 super().set_text_font(identifier, size) 474 475 is_otf = identifier.endswith('.otf') 476 is_ttf = identifier.endswith('.ttf') 477 is_file = is_otf or is_ttf 478 479 if not is_file: 480 return 481 482 current_machine = self._get_current_state_machine() 483 font = current_machine.get_text_font() 484 font_name = sketchingpy.abstracted.get_font_name(font, '/') 485 486 if font_name in self._added_fonts: 487 return 488 489 naked_font_name_components = font_name.split(' ')[1:] 490 naked_font_name = ' '.join(naked_font_name_components) 491 492 new_font = pyscript.window.FontFace.new(naked_font_name, 'url(%s)' % identifier) 493 new_font.load() 494 pyscript.document.fonts.add(new_font) 495 self._added_fonts.add(font_name) 496 497 def push_transform(self): 498 self._context.save() 499 500 def pop_transform(self): 501 self._context.restore() 502 503 ########## 504 # System # 505 ########## 506 507 def get_native(self): 508 return self._element 509 510 def set_fps(self, rate: int): 511 self._state_frame_rate = rate 512 513 def set_title(self, title: str): 514 js.document.title = title 515 516 def quit(self): 517 self._stopped = True 518 519 def show(self, ax=None): 520 self._show_internal(ax=ax, quit_immediately=False) 521 522 def show_and_quit(self, ax=None): 523 self._show_internal(ax=ax, quit_immediately=True) 524 525 def print(self, message: str): 526 console_id = self._element_id + '-console' 527 target_root = js.document.getElementById(console_id) 528 529 if target_root is None: 530 print(message) 531 return 532 533 new_li = pyscript.document.createElement('li') 534 new_content = pyscript.document.createTextNode(message) 535 new_li.appendChild(new_content) 536 537 target_root.appendChild(new_li) 538 539 ############# 540 # Transform # 541 ############# 542 543 def translate(self, x: float, y: float): 544 self._context.translate(x, y) 545 546 def rotate(self, angle: float): 547 angle_rad = self._convert_to_radians(angle) 548 self._context.rotate(angle_rad) 549 550 def scale(self, scale: float): 551 self._context.scale(scale, scale) 552 553 ########### 554 # Support # 555 ########### 556 557 def _show_internal(self, ax=None, quit_immediately=False): 558 self._loading_element.style.display = 'none' 559 self._element.style.display = 'inline-block' 560 self._version = str(round(time.time())) 561 self._element.setAttribute('version', self._version) 562 563 self._snapshot_time() 564 565 for action in self._internal_pre_show_actions: 566 action() 567 568 if not quit_immediately: 569 self._stopped = False 570 571 self._last_render = time.time() 572 self._internal_loop_callback = pyodide.ffi.create_proxy(lambda: self._inner_loop()) 573 574 self._inner_loop() 575 576 def _inner_loop(self): 577 if self._element.getAttribute('version') != self._version: 578 self._stopped = True 579 580 if self._stopped: 581 if self._callback_quit is not None: 582 self._callback_quit(self) 583 return 584 585 if self._callback_step is not None: 586 self._callback_step(self) 587 588 time_elapsed = (time.time() - self._last_render) * 1000 589 time_delay = round(1000 / self._state_frame_rate - time_elapsed) 590 591 js.setTimeout(self._internal_loop_callback, time_delay) 592 593 def _create_state_machine(self): 594 return PyscriptSketchStateMachine() 595 596 def _get_canvas_region_arc_ellipse(self, x1: float, y1: float, x2: float, 597 y2: float, mode_native: int, mode_str: str) -> CanvasRegionEllipse: 598 if mode_native == sketchingpy.const.CENTER: 599 center_x = x1 600 center_y = y1 601 radius_x = x2 / 2 602 radius_y = y2 / 2 603 elif mode_native == sketchingpy.const.RADIUS: 604 center_x = x1 605 center_y = y1 606 radius_x = x2 607 radius_y = y2 608 elif mode_native == sketchingpy.const.CORNER: 609 center_x = x1 + x2 / 2 610 center_y = y1 + y2 / 2 611 radius_x = x2 / 2 612 radius_y = y2 / 2 613 elif mode_native == sketchingpy.const.CORNERS: 614 (x1, y1, x2, y2) = sketchingpy.abstracted.reorder_coords(x1, y1, x2, y2) 615 width = x2 - x1 616 height = y2 - y1 617 center_x = x1 + width / 2 618 center_y = y1 + height / 2 619 radius_x = width / 2 620 radius_y = height / 2 621 else: 622 raise RuntimeError('Unknown mode: ' + mode_str) 623 624 return CanvasRegionEllipse(center_x, center_y, radius_x, radius_y) 625 626 def _get_canvas_region_rect_like(self, x1: float, y1: float, x2: float, 627 y2: float, native_mode: int, mode_str: str) -> CanvasRegionRect: 628 if native_mode == sketchingpy.const.CENTER: 629 start_x = x1 - x2 / 2 630 start_y = y1 - y2 / 2 631 width = x2 632 height = y2 633 elif native_mode == sketchingpy.const.RADIUS: 634 start_x = x1 - x2 635 start_y = y1 - y2 636 width = x2 * 2 637 height = y2 * 2 638 elif native_mode == sketchingpy.const.CORNER: 639 start_x = x1 640 start_y = y1 641 width = 1 if x2 == 0 else x2 642 height = 1 if y2 == 0 else y2 643 elif native_mode == sketchingpy.const.CORNERS: 644 (x1, y1, x2, y2) = sketchingpy.abstracted.reorder_coords(x1, y1, x2, y2) 645 start_x = x1 646 start_y = y1 647 width = x2 - x1 648 height = y2 - y1 649 else: 650 raise RuntimeError('Unknown mode: ' + mode_str) 651 652 return CanvasRegionRect(start_x, start_y, width, height) 653 654 def _load_draw_params(self): 655 current_machine = self._get_current_state_machine() 656 self._context.fillStyle = current_machine.get_fill_native() 657 self._context.strokeStyle = current_machine.get_stroke_native() 658 self._context.lineWidth = current_machine.get_stroke_weight_native() 659 660 def _load_font_params(self): 661 current_machine = self._get_current_state_machine() 662 663 self._context.font = current_machine.get_text_font_native() 664 665 text_align = current_machine.get_text_align_native() 666 self._context.textAlign = text_align.get_horizontal_align() 667 self._context.textBaseline = text_align.get_vertical_align() 668 669 def _draw_arc_rad(self, x1: float, y1: float, x2: float, y2: float, a1: float, a2: float, 670 mode_native: int, mode_str: str): 671 self._load_draw_params() 672 673 current_machine = self._get_current_state_machine() 674 region = self._get_canvas_region_arc_ellipse(x1, y1, x2, y2, mode_native, mode_str) 675 676 self._context.beginPath() 677 678 self._context.ellipse( 679 region.get_x(), 680 region.get_y(), 681 region.get_radius_x(), 682 region.get_radius_y(), 683 0, 684 a1, 685 a2 686 ) 687 688 if current_machine.get_fill_enabled(): 689 self._context.fill() 690 691 if current_machine.get_stroke_enabled(): 692 self._context.stroke()
Sketch renderer for web / HTML5.
207 def __init__(self, width: float, height: float, element_id: str = 'sketch-canvas', 208 loading_id: typing.Optional[str] = 'sketch-load-message'): 209 """Create a new HTML5 Canvas-based sketch. 210 211 Args: 212 width: The horizontal size of the sketch in pixels. Will update the HTML5 element. 213 height: The vertical size of the sketch in pixels. Will update the HTML5 element. 214 element_id: The ID (HTML) of the canvas into which this sketch should be drawn. 215 loading_id: The ID (HTML) of the loading message to hide upon showing the sketch. 216 """ 217 super().__init__() 218 219 if not has_js: 220 raise RuntimeError('Cannot access JS / pyodide.') 221 222 # Save elements required for running the canvas 223 self._element_id = element_id 224 self._element = js.document.getElementById(element_id) 225 self._element.width = width 226 self._element.height = height 227 self._element.style.display = 'none' 228 self._context = self._element.getContext('2d') 229 self._last_render = None 230 231 self._loading_id = loading_id 232 self._loading_element = js.document.getElementById(loading_id) 233 234 # Internal only elements 235 self._internal_loop_callback = None 236 self._internal_mouse_x = 0 237 self._internal_mouse_y = 0 238 self._internal_pre_show_actions: typing.List[typing.Callable] = [] 239 self._added_fonts: typing.Set[str] = set() 240 241 # Buffers 242 self._buffers: typing.Dict[str, WebBuffer] = {} 243 self._base_context = self._context 244 self._base_element = self._element 245 self._width = self._element.width 246 self._height = self._element.height 247 248 # User configurable state 249 self._state_frame_rate = DEFAULT_FPS 250 self._stopped = False 251 252 # Callback 253 self._callback_step: OPTIONAL_SKETCH_CALLBACK = None 254 self._callback_quit: OPTIONAL_SKETCH_CALLBACK = None 255 256 # Control 257 self._keyboard = PyscriptKeyboard(self._element) 258 self._mouse = PyscriptMouse(self._element)
Create a new HTML5 Canvas-based sketch.
Arguments:
- width: The horizontal size of the sketch in pixels. Will update the HTML5 element.
- height: The vertical size of the sketch in pixels. Will update the HTML5 element.
- element_id: The ID (HTML) of the canvas into which this sketch should be drawn.
- loading_id: The ID (HTML) of the loading message to hide upon showing the sketch.
264 def create_buffer(self, name: str, width: int, height: int, 265 background: typing.Optional[str] = None): 266 canvas = js.window.OffscreenCanvas.new(width, height) 267 context = canvas.getContext('2d') 268 self._buffers[name] = WebBuffer(canvas, context, width, height) 269 if background is not None: 270 context.clearRect(0, 0, self._width, self._height) 271 context.fillStyle = background 272 context.fillRect(0, 0, self._width, self._height)
Create a new named in-memory (or equivalent) buffer.
Arguments:
- name: The name of the buffer. If a prior buffer of this name exists, it will be replaced.
- width: The width of the buffer in pixels. In some renderers, the buffer will clip. In others, out of buffer values may be drawn.
- height: The height of the buffer in pixels. In some renderers, the buffer will clip. In others, out of buffer values may be drawn.
- background: The background to use for this buffer or None if transparent. Defaults to None.
274 def enter_buffer(self, name: str): 275 web_buffer = self._buffers[name] 276 self._context = web_buffer.get_context() 277 self._element = web_buffer.get_element() 278 self._width = web_buffer.get_width() 279 self._height = web_buffer.get_height()
Switch rendering context to a buffer, exiting current buffer if active.
Arguments:
- name: The name of the buffer to which context should switch.
281 def exit_buffer(self): 282 self._context = self._base_context 283 self._element = self._base_element 284 self._width = self._base_element.width 285 self._height = self._base_element.height
Exit the current offscreen buffer.
Exit the current offscreen buffer, returning to the actual sketch. This will act as a noop if not currently in a buffer.
287 def draw_buffer(self, x: float, y: float, name: str): 288 web_buffer = self._buffers[name] 289 self._context.drawImage(web_buffer.get_element(), x, y)
Draw an offscreen buffer to the current buffer or sketch.
Arguments:
- x: The horizontal position in pixels at which the left should be drawn.
- y: The vertical position in pixels at which the top should be drawn.
- name: The name of the buffer to draw.
295 def get_keyboard(self) -> typing.Optional[sketchingpy.control_struct.Keyboard]: 296 return self._keyboard
Get access to the keyboard.
Get access to the keyboard currently registered with the operating system for the sketch. Different sketches running at the same time may have different keyboards depending on focus or OS configuration.
Returns:
Current keyboard or None if not found / supported.
298 def get_mouse(self) -> typing.Optional[sketchingpy.control_struct.Mouse]: 299 return self._mouse
Get access to the mouse.
Get access to the mouse currently registered with the operating system for the sketch. Different sketches running at the same time may have different mouse objects depending on focus or OS configuration. Note that the mouse may also be emulated if the device uses a touch screen.
Returns:
Current mouse or None if not found / supported.
305 def get_data_layer(self) -> typing.Optional[sketchingpy.data_struct.DataLayer]: 306 return WebDataLayer()
Get access to reading and writing data.
Open access to the file system, network, or browser to read or write data.
Returns:
Facade for data access or None if not supported or insufficient permissions.
312 def get_dialog_layer(self) -> typing.Optional[sketchingpy.dialog_struct.DialogLayer]: 313 return WebDialogLayer(self)
Get access to rendering and using simple dialogs.
Open access to a simple dialog prefabricated UI system to show alerts, prompts, and other dialog boxes.
Returns:
Facade for rendering dialogs or None if not supported or insufficient permissions.
319 def clear(self, color: str): 320 self._context.clearRect(0, 0, self._width, self._height) 321 self._context.fillStyle = color 322 self._context.fillRect(0, 0, self._width, self._height)
Clear the sketch to a color.
Peform the equivalent of drawing a rectangle the size of the sketch without stroke and with the given fill color.
Arguments:
- color: The color to use in clearing.
324 def draw_arc(self, x1: float, y1: float, x2: float, y2: float, a1: float, a2: float): 325 self._load_draw_params() 326 327 a1_rad = self._convert_to_radians(a1) - js.Math.PI / 2 328 a2_rad = self._convert_to_radians(a2) - js.Math.PI / 2 329 330 current_machine = self._get_current_state_machine() 331 mode_native = current_machine.get_arc_mode_native() 332 mode_str = current_machine.get_arc_mode() 333 334 self._draw_arc_rad(x1, y1, x2, y2, a1_rad, a2_rad, mode_native, mode_str)
Draw a partial ellipse using starting and ending angles.
Using starting and ending angles, draw a partial ellipse which is either drawn outside line only (stroke) and / or filled from the center of that ellipse.
Arguments:
- x1: The x location at which to draw the arc.
- y1: The y location at which to draw the arc.
- x2: Horizontal size.
- y2: Vertical size.
- a1: Starting angle.
- a2: Ending angle.
336 def draw_ellipse(self, x1: float, y1: float, x2: float, y2: float): 337 current_machine = self._get_current_state_machine() 338 mode_native = current_machine.get_ellipse_mode_native() 339 mode_str = current_machine.get_ellipse_mode() 340 341 self._draw_arc_rad(x1, y1, x2, y2, 0, 2 * js.Math.PI, mode_native, mode_str)
Draw a circle or ellipse.
Draw an ellipse or, in the case of equal width and height, a circle.
Arguments:
- x1: The x location at which to draw the ellipse.
- y1: The y location at which to draw the ellipse.
- x2: Horizontal size.
- y2: Vertical size.
343 def draw_line(self, x1: float, y1: float, x2: float, y2: float): 344 current_machine = self._get_current_state_machine() 345 if not current_machine.get_stroke_enabled(): 346 return 347 348 self._load_draw_params() 349 350 self._context.beginPath() 351 self._context.moveTo(x1, y1) 352 self._context.lineTo(x2, y2) 353 self._context.stroke()
Draw a simple line.
Draw a line between two points.
Arguments:
- x1: The x coordinate from which the line should be drawn.
- y1: The y coordinate from which the line should be drawn.
- x2: The x coordinate to which the line should be drawn.
- y2: The y coordinate to which the line should be drawn.
355 def draw_rect(self, x1: float, y1: float, x2: float, y2: float): 356 self._load_draw_params() 357 358 current_machine = self._get_current_state_machine() 359 native_mode = current_machine.get_rect_mode_native() 360 mode_str = current_machine.get_rect_mode_native() 361 362 region = self._get_canvas_region_rect_like(x1, y1, x2, y2, native_mode, mode_str) 363 364 self._context.beginPath() 365 self._context.rect(region.get_x(), region.get_y(), region.get_width(), region.get_height()) 366 367 if current_machine.get_fill_enabled(): 368 self._context.fill() 369 370 if current_machine.get_stroke_enabled(): 371 self._context.stroke()
Draw a rectangle.
Draw a rectangle or, if width and height are the same, a square.
Arguments:
- x1: The x location at which to draw the rectangle.
- y1: The y location at which to draw the rectangle.
- x2: Horizontal size.
- y2: Vertical size.
373 def draw_shape(self, shape: sketchingpy.shape_struct.Shape): 374 current_machine = self._get_current_state_machine() 375 376 self._load_draw_params() 377 378 self._context.beginPath() 379 self._context.moveTo(shape.get_start_x(), shape.get_start_y()) 380 381 for segment in shape.get_segments(): 382 strategy = segment.get_strategy() 383 if strategy == 'straight': 384 self._context.lineTo(segment.get_destination_x(), segment.get_destination_y()) 385 elif strategy == 'bezier': 386 self._context.bezierCurveTo( 387 segment.get_control_x1(), 388 segment.get_control_y1(), 389 segment.get_control_x2(), 390 segment.get_control_y2(), 391 segment.get_destination_x(), 392 segment.get_destination_y() 393 ) 394 else: 395 raise RuntimeError('Unsupported segment type: ' + strategy) 396 397 if shape.get_is_closed(): 398 self._context.closePath() 399 400 if current_machine.get_fill_enabled(): 401 self._context.fill() 402 403 if current_machine.get_stroke_enabled(): 404 self._context.stroke()
Draw a shape.
Draw a shape which consists of multiple line or curve segments and which can be either open (stroke only) or closed (can be filled).
Arguments:
- shape: The shape to draw.
406 def draw_text(self, x: float, y: float, content: str): 407 content = str(content) 408 current_machine = self._get_current_state_machine() 409 410 self._load_draw_params() 411 self._load_font_params() 412 413 if current_machine.get_fill_enabled(): 414 self._context.fillText(content, x, y) 415 416 if current_machine.get_stroke_enabled(): 417 self._context.strokeText(content, x, y)
Draw text using the current font.
Draw text using the current font and alignment.
Arguments:
- x: The x coordinate at which to draw the text.
- y: The y coordinate at which to draw the text.
- text: The string to draw.
423 def on_step(self, callback: sketchingpy.abstracted.StepCallback): 424 self._callback_step = callback
Callback for when the sketch ends execution.
Register a callback for when the sketch redraws. This function should expect a single parameter which is the sketch redrawing.
Arguments:
- callback: The function to invoke when the sketch stops execution.
426 def on_quit(self, callback: sketchingpy.abstracted.QuitCallback): 427 self._callback_quit = callback
Callback for when the sketch ends execution.
Register a callback for when the sketch terminates.
Arguments:
- callback: The function to invoke when the sketch stops execution.
Load an image file.
Load an image from the local file system or URL.
Arguments:
- src: The location from which the file should be read.
436 def draw_image(self, x: float, y: float, image: sketchingpy.abstracted.Image): 437 if not image.get_is_loaded(): 438 return 439 440 self._load_draw_params() 441 442 current_machine = self._get_current_state_machine() 443 native_mode = current_machine.get_image_mode_native() 444 mode_str = current_machine.get_image_mode_native() 445 446 width = image.get_width() 447 height = image.get_height() 448 449 region = self._get_canvas_region_rect_like(x, y, width, height, native_mode, mode_str) 450 451 self._context.drawImage( 452 image.get_native(), 453 region.get_x(), 454 region.get_y(), 455 region.get_width(), 456 region.get_height() 457 )
Draw an image at a location.
Draw a previously loaded image at a specific coordinate using its current size.
Arguments:
- x: Horizontal coordinate at which to draw the image.
- y: Vertical coordinate at which to draw the image.
- image: The image to draw.
459 def save_image(self, path: str): 460 if not path.endswith('.png'): 461 raise RuntimeError('Web export only supported to PNG.') 462 463 link = js.document.createElement('a') 464 link.download = path 465 link.href = self._element.toDataURL('image/png') 466 link.click()
Save an image file.
Save the sketch as an image file, either directly to the file system or as a download.
Arguments:
- path: The location at which the file should be written.
472 def set_text_font(self, identifier: str, size: float): 473 super().set_text_font(identifier, size) 474 475 is_otf = identifier.endswith('.otf') 476 is_ttf = identifier.endswith('.ttf') 477 is_file = is_otf or is_ttf 478 479 if not is_file: 480 return 481 482 current_machine = self._get_current_state_machine() 483 font = current_machine.get_text_font() 484 font_name = sketchingpy.abstracted.get_font_name(font, '/') 485 486 if font_name in self._added_fonts: 487 return 488 489 naked_font_name_components = font_name.split(' ')[1:] 490 naked_font_name = ' '.join(naked_font_name_components) 491 492 new_font = pyscript.window.FontFace.new(naked_font_name, 'url(%s)' % identifier) 493 new_font.load() 494 pyscript.document.fonts.add(new_font) 495 self._added_fonts.add(font_name)
Set the type and size of text to draw.
Set the size and font to use for drawing text.
Arguments:
- font: Path to the TTF font file.
- size: Size of the font (px).
Save current transformation state.
Save current sketch transformation state to the matrix history. This works as a stack (like a stack of plates) where this puts a new plate on the top of the pile. This will leave the current transformation matrix in the sketch unchanged.
Restore a previously saved transformation state.
Restore the most recently transformation configuration saved in matrix history, removing that "transform matrix" from the history. This works as a stack (like a stack of plates) where the top of the pile is taken off and restored, removing it from that stack. This will overwrite the current transformation configuration in the sketch.
Get a reference to the underlying native renderer object.
Returns:
Native render object.
Indicate how fast the sketch should redraw.
Indicate a target frames per second that the sketch will take a "step" or redraw. Note that this is a goal and, if the system fall behind, it will drop frames and cause the on_step callback to be executed fewer times than the target.
Arguments:
- rate: The number of frames to try to draw per second.
Indicate the title to assign the window in the operating system.
Indicate the human-readable string title to assign to the sketch window.
Arguments:
- title: The text of the title.
Show the sketch.
Show the sketch to the user and, if applicable, start the draw loop specified by set_fps. For Sketch2DApp, will execute any waiting drawing instructions provided to the sketch prior to showing. This is conceptually the same as "starting" the sketch.
Arguments:
- ax: The container into which the sketch should be shown. Currently only supported for Sketch2DStatic. Optional and ignored on most renderers.
Show the sketch and quit immediatley afterwards.
Show the sketch to the user and quit immediately afterwards, a routine potentially useful for testing.
525 def print(self, message: str): 526 console_id = self._element_id + '-console' 527 target_root = js.document.getElementById(console_id) 528 529 if target_root is None: 530 print(message) 531 return 532 533 new_li = pyscript.document.createElement('li') 534 new_content = pyscript.document.createTextNode(message) 535 new_li.appendChild(new_content) 536 537 target_root.appendChild(new_li)
Print a message to terminal or equivalent.
Arguments:
- message: The string message to be printed.
Change the location of the origin.
Change the transform matrix such that any drawing afterwards is moved by a set amount.
Arguments:
- x: The number of pixels to offset horizontally.
- y: The number of pixels to offset vertically.
546 def rotate(self, angle: float): 547 angle_rad = self._convert_to_radians(angle) 548 self._context.rotate(angle_rad)
Rotate around the current origin.
Change the transform matrix such that any drawing afterwards is rotated around the current origin clock-wise.
Arguments:
- angle: The angle by which to rotate.
Scale outwards from the current origin.
Change the transform matrix such that any drawing afterwards is scaled from the current origin.
Arguments:
- scale: The factor by which to scale where values over 1 scale up and less than 1 scale down. A value of 1 will have no effect.
Inherited Members
- sketchingpy.abstracted.Sketch
- set_fill
- clear_fill
- set_stroke
- clear_stroke
- set_arc_mode
- set_ellipse_mode
- set_rect_mode
- draw_pixel
- start_shape
- set_stroke_weight
- set_text_align
- set_map_pan
- set_map_zoom
- set_map_placement
- convert_geo_to_pixel
- start_geo_polygon
- push_map
- pop_map
- parse_geojson
- set_image_mode
- set_angle_mode
- push_style
- pop_style
- get_millis_shown
695class PyscriptSketchStateMachine(sketchingpy.state_struct.SketchStateMachine): 696 """Implementation of SketchStateMachine for Pyscript types.""" 697 698 def __init__(self): 699 """Create a new state machine for Pyscript-based sketches.""" 700 super().__init__() 701 self._text_align_native = self._transform_text_align(super().get_text_align_native()) 702 703 def set_text_align(self, text_align: sketchingpy.state_struct.TextAlign): 704 super().set_text_align(text_align) 705 self._text_align_native = self._transform_text_align(super().get_text_align_native()) 706 707 def get_text_align_native(self): 708 return self._text_align_native 709 710 def get_text_font_native(self): 711 return sketchingpy.abstracted.get_font_name(self.get_text_font(), os.path.sep) 712 713 def _transform_text_align(self, 714 text_align: sketchingpy.state_struct.TextAlign) -> sketchingpy.state_struct.TextAlign: 715 716 HORIZONTAL_ALIGNS = { 717 sketchingpy.const.LEFT: 'left', 718 sketchingpy.const.CENTER: 'center', 719 sketchingpy.const.RIGHT: 'right' 720 } 721 722 VERTICAL_ALIGNS = { 723 sketchingpy.const.TOP: 'top', 724 sketchingpy.const.CENTER: 'middle', 725 sketchingpy.const.BASELINE: 'alphabetic', 726 sketchingpy.const.BOTTOM: 'bottom' 727 } 728 729 return sketchingpy.state_struct.TextAlign( 730 HORIZONTAL_ALIGNS[text_align.get_horizontal_align()], 731 VERTICAL_ALIGNS[text_align.get_vertical_align()] 732 )
Implementation of SketchStateMachine for Pyscript types.
698 def __init__(self): 699 """Create a new state machine for Pyscript-based sketches.""" 700 super().__init__() 701 self._text_align_native = self._transform_text_align(super().get_text_align_native())
Create a new state machine for Pyscript-based sketches.
703 def set_text_align(self, text_align: sketchingpy.state_struct.TextAlign): 704 super().set_text_align(text_align) 705 self._text_align_native = self._transform_text_align(super().get_text_align_native())
Indicate the alignment to use when drawing text.
Arguments:
- text_align: Structure describing horizontal and vertical text alignment.
Get the alignment to use when drawing text.
Returns:
Renderer-specific value.
710 def get_text_font_native(self): 711 return sketchingpy.abstracted.get_font_name(self.get_text_font(), os.path.sep)
Get the type and size for text drawing.
Returns:
Renderer-specific value.
Inherited Members
- sketchingpy.state_struct.SketchStateMachine
- set_fill
- get_fill
- get_fill_native
- get_fill_enabled
- clear_fill
- set_stroke
- get_stroke
- get_stroke_native
- get_stroke_enabled
- clear_stroke
- set_arc_mode
- get_arc_mode
- get_arc_mode_native
- set_ellipse_mode
- get_ellipse_mode
- get_ellipse_mode_native
- set_rect_mode
- get_rect_mode
- get_rect_mode_native
- set_stroke_weight
- get_stroke_weight
- get_stroke_weight_native
- set_text_font
- get_text_font
- get_text_align
- set_image_mode
- get_image_mode
- get_image_mode_native
- set_angle_mode
- get_angle_mode
- get_angle_mode_native
735class WebImage(sketchingpy.abstracted.Image): 736 """Strategy implementation for HTML images.""" 737 738 def __init__(self, src: str): 739 """Create a new image. 740 741 Args: 742 src: Path to the image. 743 """ 744 super().__init__(src) 745 746 preload_suffix = src.replace("./", "").replace("/", "").replace(".", "-").replace(" ", "-") 747 preload_name = "preload-img-" + preload_suffix 748 preloaded_image = js.document.getElementById(preload_name) 749 750 if preloaded_image: 751 image = preloaded_image 752 else: 753 image = js.document.createElement("img") 754 image.src = src 755 756 self._native = image 757 self._width: typing.Optional[float] = None 758 self._height: typing.Optional[float] = None 759 760 def get_width(self) -> float: 761 if self._width is None: 762 return self._native.width 763 else: 764 return self._width 765 766 def get_height(self) -> float: 767 if self._height is None: 768 return self._native.height 769 else: 770 return self._height 771 772 def resize(self, width: float, height: float): 773 self._width = width 774 self._height = height 775 776 def get_native(self): 777 return self._native 778 779 def get_is_loaded(self): 780 return self._native.width > 0
Strategy implementation for HTML images.
738 def __init__(self, src: str): 739 """Create a new image. 740 741 Args: 742 src: Path to the image. 743 """ 744 super().__init__(src) 745 746 preload_suffix = src.replace("./", "").replace("/", "").replace(".", "-").replace(" ", "-") 747 preload_name = "preload-img-" + preload_suffix 748 preloaded_image = js.document.getElementById(preload_name) 749 750 if preloaded_image: 751 image = preloaded_image 752 else: 753 image = js.document.createElement("img") 754 image.src = src 755 756 self._native = image 757 self._width: typing.Optional[float] = None 758 self._height: typing.Optional[float] = None
Create a new image.
Arguments:
- src: Path to the image.
760 def get_width(self) -> float: 761 if self._width is None: 762 return self._native.width 763 else: 764 return self._width
Get the width of this image in pixels.
Returns:
Horizontal width of this image.
766 def get_height(self) -> float: 767 if self._height is None: 768 return self._native.height 769 else: 770 return self._height
Get the height of this image in pixels.
Returns:
Vertical height of this image.
772 def resize(self, width: float, height: float): 773 self._width = width 774 self._height = height
Resize this image by scaling.
Arguments:
- width: The new desired width of this image in pixels.
- height: The new desired height of this image in pixels.
Access the underlying native version of this image.
Returns:
Renderer specific native version.
Determine if this image has finished loading.
Returns:
True if loaded and ready to draw. False otherwise.
Inherited Members
783class PyscriptMouse(sketchingpy.control_struct.Mouse): 784 """Strategy implementation for Pyscript-based mouse access.""" 785 786 def __init__(self, element): 787 """Create a new mouse strategy using HTML5. 788 789 Args: 790 element: The element to which mouse event listeners should be added. 791 """ 792 self._element = element 793 794 self._x = 0 795 self._y = 0 796 797 self._buttons_pressed = set() 798 799 mouse_move_callback = pyodide.ffi.create_proxy( 800 lambda event: self._report_mouse_move(event) 801 ) 802 self._element.addEventListener( 803 'mousemove', 804 mouse_move_callback 805 ) 806 807 mouse_down_callback = pyodide.ffi.create_proxy( 808 lambda event: self._report_mouse_down(event) 809 ) 810 self._element.addEventListener( 811 'mousedown', 812 mouse_down_callback 813 ) 814 815 click_callback = pyodide.ffi.create_proxy( 816 lambda event: self._report_click(event) 817 ) 818 self._element.addEventListener( 819 'click', 820 click_callback 821 ) 822 823 context_menu_callback = pyodide.ffi.create_proxy( 824 lambda event: self._report_context_menu(event) 825 ) 826 self._element.addEventListener( 827 'contextmenu', 828 context_menu_callback 829 ) 830 831 self._press_callback = None 832 self._release_callback = None 833 834 def get_pointer_x(self): 835 return self._x 836 837 def get_pointer_y(self): 838 return self._y 839 840 def get_buttons_pressed(self) -> sketchingpy.control_struct.Buttons: 841 return map(lambda x: sketchingpy.control_struct.Button(x), self._buttons_pressed) 842 843 def on_button_press(self, callback: sketchingpy.control_struct.MouseCallback): 844 self._press_callback = callback 845 846 def on_button_release(self, callback: sketchingpy.control_struct.MouseCallback): 847 self._release_callback = callback 848 849 def _report_mouse_move(self, event): 850 bounding_box = self._element.getBoundingClientRect() 851 self._x = event.clientX - bounding_box.left 852 self._y = event.clientY - bounding_box.top 853 854 def _report_mouse_down(self, event): 855 if event.button == 0: 856 self._buttons_pressed.add(sketchingpy.const.MOUSE_LEFT_BUTTON) 857 if self._press_callback is not None: 858 button = sketchingpy.control_struct.Button(sketchingpy.const.MOUSE_LEFT_BUTTON) 859 self._press_callback(button) 860 elif event.button == 2: 861 self._buttons_pressed.add(sketchingpy.const.MOUSE_RIGHT_BUTTON) 862 if self._press_callback is not None: 863 button = sketchingpy.control_struct.Button(sketchingpy.const.MOUSE_RIGHT_BUTTON) 864 self._press_callback(button) 865 866 def _report_click(self, event): 867 self._buttons_pressed.remove(sketchingpy.const.MOUSE_LEFT_BUTTON) 868 869 if self._release_callback is not None: 870 button = sketchingpy.control_struct.Button(sketchingpy.const.MOUSE_LEFT_BUTTON) 871 self._release_callback(button) 872 873 event.preventDefault() 874 875 def _report_context_menu(self, event): 876 self._buttons_pressed.remove(sketchingpy.const.MOUSE_RIGHT_BUTTON) 877 878 if self._release_callback is not None: 879 button = sketchingpy.control_struct.Button(sketchingpy.const.MOUSE_RIGHT_BUTTON) 880 self._release_callback(button) 881 882 event.preventDefault()
Strategy implementation for Pyscript-based mouse access.
786 def __init__(self, element): 787 """Create a new mouse strategy using HTML5. 788 789 Args: 790 element: The element to which mouse event listeners should be added. 791 """ 792 self._element = element 793 794 self._x = 0 795 self._y = 0 796 797 self._buttons_pressed = set() 798 799 mouse_move_callback = pyodide.ffi.create_proxy( 800 lambda event: self._report_mouse_move(event) 801 ) 802 self._element.addEventListener( 803 'mousemove', 804 mouse_move_callback 805 ) 806 807 mouse_down_callback = pyodide.ffi.create_proxy( 808 lambda event: self._report_mouse_down(event) 809 ) 810 self._element.addEventListener( 811 'mousedown', 812 mouse_down_callback 813 ) 814 815 click_callback = pyodide.ffi.create_proxy( 816 lambda event: self._report_click(event) 817 ) 818 self._element.addEventListener( 819 'click', 820 click_callback 821 ) 822 823 context_menu_callback = pyodide.ffi.create_proxy( 824 lambda event: self._report_context_menu(event) 825 ) 826 self._element.addEventListener( 827 'contextmenu', 828 context_menu_callback 829 ) 830 831 self._press_callback = None 832 self._release_callback = None
Create a new mouse strategy using HTML5.
Arguments:
- element: The element to which mouse event listeners should be added.
Get the x coordinate of the mouse pointer.
Get the current horizontal coordinate of the mouse pointer or, in the case of a touchscreen, the point of last touch input if available. Defaults to 0 if no mouse events have been seen.
Returns:
The horizontal coordinate of the mouse pointer.
Get the y coordinate of the mouse pointer.
Get the current vertical coordinate of the mouse pointer or, in the case of a touchscreen, the point of last touch input if available. Defaults to 0 if no mouse events have been seen.
Returns:
The vertical coordinate of the mouse pointer.
885class PyscriptKeyboard(sketchingpy.control_struct.Keyboard): 886 """Strategy implementation for Pyscript-based keyboard access.""" 887 888 def __init__(self, element): 889 """Create a new mouse strategy using HTML5 and Pyscript. 890 891 Args: 892 element: The element to which keyboard event listeners should be added. 893 """ 894 super().__init__() 895 self._element = element 896 self._pressed = set() 897 self._press_callback = None 898 self._release_callback = None 899 900 keydown_callback = pyodide.ffi.create_proxy( 901 lambda event: self._report_key_down(event) 902 ) 903 self._element.addEventListener( 904 'keydown', 905 keydown_callback 906 ) 907 908 keyup_callback = pyodide.ffi.create_proxy( 909 lambda event: self._report_key_up(event) 910 ) 911 self._element.addEventListener( 912 'keyup', 913 keyup_callback 914 ) 915 916 def get_keys_pressed(self) -> sketchingpy.control_struct.Buttons: 917 return map(lambda x: sketchingpy.control_struct.Button(x), self._pressed) 918 919 def on_key_press(self, callback: sketchingpy.control_struct.KeyboardCallback): 920 self._press_callback = callback 921 922 def on_key_release(self, callback: sketchingpy.control_struct.KeyboardCallback): 923 self._release_callback = callback 924 925 def _report_key_down(self, event): 926 key = self._map_key(event.key) 927 928 if key is None: 929 return 930 931 self._pressed.add(key) 932 933 if self._press_callback is not None: 934 button = sketchingpy.control_struct.Button(key) 935 self._press_callback(button) 936 937 event.preventDefault() 938 939 def _report_key_up(self, event): 940 key = self._map_key(event.key) 941 942 if key is None: 943 return 944 945 self._pressed.remove(key) 946 947 if self._release_callback is not None: 948 button = sketchingpy.control_struct.Button(key) 949 self._release_callback(button) 950 951 event.preventDefault() 952 953 def _map_key(self, target: str) -> typing.Optional[str]: 954 if target in KEY_MAP: 955 return KEY_MAP[target.lower()] # Required for browser compatibility 956 else: 957 return target.lower()
Strategy implementation for Pyscript-based keyboard access.
888 def __init__(self, element): 889 """Create a new mouse strategy using HTML5 and Pyscript. 890 891 Args: 892 element: The element to which keyboard event listeners should be added. 893 """ 894 super().__init__() 895 self._element = element 896 self._pressed = set() 897 self._press_callback = None 898 self._release_callback = None 899 900 keydown_callback = pyodide.ffi.create_proxy( 901 lambda event: self._report_key_down(event) 902 ) 903 self._element.addEventListener( 904 'keydown', 905 keydown_callback 906 ) 907 908 keyup_callback = pyodide.ffi.create_proxy( 909 lambda event: self._report_key_up(event) 910 ) 911 self._element.addEventListener( 912 'keyup', 913 keyup_callback 914 )
Create a new mouse strategy using HTML5 and Pyscript.
Arguments:
- element: The element to which keyboard event listeners should be added.
916 def get_keys_pressed(self) -> sketchingpy.control_struct.Buttons: 917 return map(lambda x: sketchingpy.control_struct.Button(x), self._pressed)
Get a list of keys currently pressed.
Get a list of keys as Buttons.
Returns:
Get list of buttons pressed.
919 def on_key_press(self, callback: sketchingpy.control_struct.KeyboardCallback): 920 self._press_callback = callback
Callback for when a key is pressed.
Register a callback for when a key is pressed, calling a function with the key and keyboard. Will pass two arguments to that callback function: first a Button followed by a Keyboard. Will unregister prior callbacks for on_key_press.
Arguments:
- callback: The function to invoke when a key is pressed.
922 def on_key_release(self, callback: sketchingpy.control_struct.KeyboardCallback): 923 self._release_callback = callback
Callback for when a key is released.
Register a callback for when a key is unpressed, calling a function with the key and keyboard. Will pass two arguments to that callback function: first a Button followed by a Keyboard. Will unregister prior callbacks for on_key_release.
Arguments:
- callback: The function to invoke when a key is released.
960class WebDataLayer(sketchingpy.data_struct.DataLayer): 961 """Data layer which interfaces with network and browser.""" 962 963 def get_csv(self, path: str) -> sketchingpy.data_struct.Records: 964 if os.path.exists(path): 965 with open(path) as f: 966 reader = csv.DictReader(f) 967 return list(reader) 968 else: 969 string_io = pyodide.http.open_url(path) 970 reader = csv.DictReader(string_io) 971 return list(reader) 972 973 def write_csv(self, records: sketchingpy.data_struct.Records, 974 columns: sketchingpy.data_struct.Columns, path: str): 975 def build_record(target: typing.Dict) -> typing.Dict: 976 return dict(map(lambda key: (key, target[key]), columns)) 977 978 records_serialized = map(build_record, records) 979 980 target = io.StringIO() 981 982 writer = csv.DictWriter(target, fieldnames=columns) # type: ignore 983 writer.writeheader() 984 writer.writerows(records_serialized) 985 986 self._download_text(target.getvalue(), path, 'text/csv') 987 988 def get_json(self, path: str): 989 if os.path.exists(path): 990 with open(path) as f: 991 return json.load(f) 992 else: 993 string_io = pyodide.http.open_url(path) 994 return json.loads(string_io.read()) 995 996 def write_json(self, target, path: str): 997 self._download_text(json.dumps(target), path, 'application/json') 998 999 def get_text(self, path: str): 1000 string_io = pyodide.http.open_url(path) 1001 return string_io.read() 1002 1003 def write_text(self, target, path: str): 1004 self._download_text(target, path, 'text/plain') 1005 1006 def _download_text(self, text: str, filename: str, mime: str): 1007 text_encoded = urllib.parse.quote(text) 1008 1009 link = js.document.createElement('a') 1010 link.download = filename 1011 link.href = 'data:%s;charset=utf-8,%s' % (mime, text_encoded) 1012 1013 link.click()
Data layer which interfaces with network and browser.
963 def get_csv(self, path: str) -> sketchingpy.data_struct.Records: 964 if os.path.exists(path): 965 with open(path) as f: 966 reader = csv.DictReader(f) 967 return list(reader) 968 else: 969 string_io = pyodide.http.open_url(path) 970 reader = csv.DictReader(string_io) 971 return list(reader)
Load a CSV file as a list of dictionaries.
Load a CSV file and parse it as a list where each row after the "header row" becomes a dictionary.
Arguments:
- path: The location at which the CSV file can be found.
Returns:
List of dictionary.
973 def write_csv(self, records: sketchingpy.data_struct.Records, 974 columns: sketchingpy.data_struct.Columns, path: str): 975 def build_record(target: typing.Dict) -> typing.Dict: 976 return dict(map(lambda key: (key, target[key]), columns)) 977 978 records_serialized = map(build_record, records) 979 980 target = io.StringIO() 981 982 writer = csv.DictWriter(target, fieldnames=columns) # type: ignore 983 writer.writeheader() 984 writer.writerows(records_serialized) 985 986 self._download_text(target.getvalue(), path, 'text/csv')
Write a list of dictionaries as a CSV file.
Write a CSV file with header row, saving it to local file system or offering it as a download in the browser.
Arguments:
- records: List of dictionaries to be written.
- columns: Ordered list of columns to include in the CSV file.
- path: The location at which the file should be written.
988 def get_json(self, path: str): 989 if os.path.exists(path): 990 with open(path) as f: 991 return json.load(f) 992 else: 993 string_io = pyodide.http.open_url(path) 994 return json.loads(string_io.read())
Read a JSON file.
Read a JSON file either from local file system or the network.
Arguments:
- path: The location at which the JSON file can be found.
Returns:
Loaded JSON content.
996 def write_json(self, target, path: str): 997 self._download_text(json.dumps(target), path, 'application/json')
Write a JSON file.
Write a JSON file, saving it to local file system or offering it as a download in the browser.
Arguments:
- target: The value to write as JSON.
- path: The location at which the file should be written.
999 def get_text(self, path: str): 1000 string_io = pyodide.http.open_url(path) 1001 return string_io.read()
Read a text file.
Read a text file either from local file system or the network.
Arguments:
- path: The location where the file can be found.
Returns:
Loaded content as a string.
1016class WebDialogLayer(sketchingpy.dialog_struct.DialogLayer): 1017 """Dialog / simple UI layer for web apps.""" 1018 1019 def __init__(self, sketch: Sketch2DWeb): 1020 """"Initialize tkinter but hide the root window.""" 1021 self._sketch = sketch 1022 1023 def show_alert(self, message: str, callback: typing.Optional[typing.Callable[[], None]] = None): 1024 pyscript.window.alert(message) 1025 if callback is not None: 1026 callback() 1027 1028 def show_prompt(self, message: str, 1029 callback: typing.Optional[typing.Callable[[str], None]] = None): 1030 response = pyscript.window.prompt(message) 1031 if callback is not None and response is not None: 1032 callback(response) 1033 1034 def get_file_save_location(self, 1035 callback: typing.Optional[typing.Callable[[str], None]] = None): 1036 self.show_prompt('Filename to save:', callback) 1037 1038 def get_file_load_location(self, 1039 callback: typing.Optional[typing.Callable[[str], None]] = None): 1040 self.show_prompt('Filename to load:', callback)
Dialog / simple UI layer for web apps.
1019 def __init__(self, sketch: Sketch2DWeb): 1020 """"Initialize tkinter but hide the root window.""" 1021 self._sketch = sketch
"Initialize tkinter but hide the root window.
1023 def show_alert(self, message: str, callback: typing.Optional[typing.Callable[[], None]] = None): 1024 pyscript.window.alert(message) 1025 if callback is not None: 1026 callback()
Show an alert dialog box.
Arguments:
- callback: Method to invoke when the box closes.
- message: The string to show the user.
1028 def show_prompt(self, message: str, 1029 callback: typing.Optional[typing.Callable[[str], None]] = None): 1030 response = pyscript.window.prompt(message) 1031 if callback is not None and response is not None: 1032 callback(response)
Get a string input from the user.
Arguments:
- message: The message to display to the user within the dialog.
- callback: Method to invoke when the box closes with a single string parameter provided by the user. Not invoked if cancelled.
1034 def get_file_save_location(self, 1035 callback: typing.Optional[typing.Callable[[str], None]] = None): 1036 self.show_prompt('Filename to save:', callback)
Get either the filename or full location for saving a file.
Arguments:
- callback: Method to invoke when the box closes with single string parameter which is the filename or the path selected by the user. Not invoked if cancelled.
1038 def get_file_load_location(self, 1039 callback: typing.Optional[typing.Callable[[str], None]] = None): 1040 self.show_prompt('Filename to load:', callback)
Get either the filename or full location for opening a file.
Arguments:
- callback: Method to invoke when the box closes with single string parameter which is the filename or the path selected by the user. Not invoked if cancelled.