diff --git a/bbb_presentation_video/events/tldraw.py b/bbb_presentation_video/events/tldraw.py index ecf9a32..315c3c0 100644 --- a/bbb_presentation_video/events/tldraw.py +++ b/bbb_presentation_video/events/tldraw.py @@ -54,6 +54,12 @@ class PropsData(StyleData, total=False): text: str verticalAlign: str w: float + question: str + numResponders: int + numRespondents: int + questionType: str + questionText: str + answers: List[Dict[str, Any]] class ShapeData(TypedDict, total=False): diff --git a/bbb_presentation_video/renderer/tldraw/__init__.py b/bbb_presentation_video/renderer/tldraw/__init__.py index 3227599..cc01381 100644 --- a/bbb_presentation_video/renderer/tldraw/__init__.py +++ b/bbb_presentation_video/renderer/tldraw/__init__.py @@ -64,6 +64,7 @@ HighlighterShape, LineShape, OvalGeoShape, + PollShape, RectangleGeoShape, RectangleShape, RhombusGeoShape, @@ -87,6 +88,7 @@ from bbb_presentation_video.renderer.tldraw.shape.frame import finalize_frame from bbb_presentation_video.renderer.tldraw.shape.highlighter import finalize_highlight from bbb_presentation_video.renderer.tldraw.shape.line import finalize_line +from bbb_presentation_video.renderer.tldraw.shape.poll import finalize_poll from bbb_presentation_video.renderer.tldraw.shape.rectangle import finalize_rectangle from bbb_presentation_video.renderer.tldraw.shape.sticky import finalize_sticky from bbb_presentation_video.renderer.tldraw.shape.sticky_v2 import finalize_sticky_v2 @@ -310,6 +312,8 @@ def finalize_shapes( finalize_line(ctx, id, shape) elif isinstance(shape, OvalGeoShape): finalize_oval(ctx, id, shape) + elif isinstance(shape, PollShape): + finalize_poll(ctx, id, shape) elif isinstance(shape, RectangleShape): finalize_rectangle(ctx, id, shape) elif isinstance(shape, RectangleGeoShape): diff --git a/bbb_presentation_video/renderer/tldraw/shape/__init__.py b/bbb_presentation_video/renderer/tldraw/shape/__init__.py index 77a59fb..e8f75d6 100644 --- a/bbb_presentation_video/renderer/tldraw/shape/__init__.py +++ b/bbb_presentation_video/renderer/tldraw/shape/__init__.py @@ -580,6 +580,47 @@ def update_from_data(self, data: ShapeData) -> None: self.spline = SplineType.NONE +@attr.s(order=False, slots=True, auto_attribs=True) +class PollShapeAnswer: + key: str + numVotes: int + + +@attr.s(order=False, slots=True, auto_attribs=True) +class PollShape(RotatableShapeProto): + question: str = "" + numResponders: int = 0 + numRespondents: int = 0 + questionType: str = "" + questionText: str = "" + answers: List[PollShapeAnswer] = attr.Factory(list) + + def update_from_data(self, data: ShapeData) -> None: + # Poll shapes contain a prop "fill" which isn't a valid FillStyle + if "props" in data and "fill" in data["props"]: + del data["props"]["fill"] + + super().update_from_data(data) + + if "props" in data: + props = data["props"] + if "question" in props: + self.question = props["question"] + if "numResponders" in props: + self.numResponders = props["numResponders"] + if "numRespondents" in props: + self.numRespondents = props["numRespondents"] + if "questionType" in props: + self.questionType = props["questionType"] + if "questionText" in props: + self.questionText = props["questionText"] + if "answers" in props: + self.answers = [ + PollShapeAnswer(key=answer["key"], numVotes=answer["numVotes"]) + for answer in props["answers"] + ] + + Shape = Union[ ArrowGeoShape, ArrowShape, @@ -608,6 +649,7 @@ def update_from_data(self, data: ShapeData) -> None: TriangleGeoShape, TriangleShape, XBoxGeoShape, + PollShape, ] @@ -645,6 +687,8 @@ def parse_shape_from_data(data: ShapeData, bbb_version: Version) -> Shape: return HighlighterShape.from_data(data) elif type == "frame": return FrameShape.from_data(data) + elif type == "poll": + return PollShape.from_data(data) elif type == "geo": if "geo" in data["props"]: geo_type = GeoShape(data["props"]["geo"]) diff --git a/bbb_presentation_video/renderer/tldraw/shape/poll.py b/bbb_presentation_video/renderer/tldraw/shape/poll.py new file mode 100644 index 0000000..d6353ed --- /dev/null +++ b/bbb_presentation_video/renderer/tldraw/shape/poll.py @@ -0,0 +1,178 @@ +# SPDX-FileCopyrightText: 2024 BigBlueButton Inc. and by respective authors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import annotations + +from typing import TypeVar + +import cairo +from gi.repository import Pango, PangoCairo + +from bbb_presentation_video.events.helpers import Color +from bbb_presentation_video.renderer.tldraw.shape import PollShape, apply_shape_rotation +from bbb_presentation_video.renderer.tldraw.utils import ( + V2_COLORS, + V2_TEXT_COLOR, + ColorStyle, +) + +FONT_FAMILY = "Arial" +POLL_LINE_WIDTH = 2.0 +POLL_FONT_SIZE = 18 +POLL_VPADDING = 12.0 +POLL_HPADDING = 12.0 + +CairoSomeSurface = TypeVar("CairoSomeSurface", bound=cairo.Surface) + + +def finalize_poll( + ctx: cairo.Context[CairoSomeSurface], id: str, shape: PollShape +) -> None: + print(f"\tTldraw: Finalizing Poll: {id}") + + if len(shape.answers) == 0: + return + + apply_shape_rotation(ctx, shape) + + width = shape.size.width + height = shape.size.height + color = V2_COLORS.get(shape.style.color, V2_COLORS[ColorStyle.BLACK]) + + ctx.set_line_join(cairo.LINE_JOIN_MITER) + ctx.set_line_cap(cairo.LINE_CAP_SQUARE) + + # Draw the background and poll outline + half_lw = POLL_LINE_WIDTH / 2 + ctx.set_line_width(POLL_LINE_WIDTH) + ctx.move_to(half_lw, half_lw) + ctx.line_to(width - half_lw, half_lw) + ctx.line_to(width - half_lw, height - half_lw) + ctx.line_to(half_lw, height - half_lw) + ctx.close_path() + ctx.set_source_rgb(*color.semi) + ctx.fill_preserve() + ctx.set_source_rgb(*color.solid) + ctx.stroke() + + font = Pango.FontDescription() + font.set_family(FONT_FAMILY) + font.set_absolute_size(int(POLL_FONT_SIZE * Pango.SCALE)) + + # Use Pango to calculate the label width space needed + pctx = PangoCairo.create_context(ctx) + layout = Pango.Layout(pctx) + layout.set_font_description(font) + + max_label_width = 0.0 + max_percent_width = 0.0 + for answer in shape.answers: + layout.set_text(answer.key, -1) + (label_width, _) = layout.get_pixel_size() + if label_width > max_label_width: + max_label_width = label_width + percent: str + if shape.numResponders > 0: + percent = "{}%".format( + int(float(answer.numVotes) / float(shape.numResponders) * 100) + ) + else: + percent = "0%" + layout.set_text(percent, -1) + (percent_width, _) = layout.get_pixel_size() + if percent_width > max_percent_width: + max_percent_width = percent_width + + max_label_width = min(max_label_width, width * 0.3) + max_percent_width = min(max_percent_width, width * 0.3) + + title_height = 0.0 + if shape.questionText != "": + title_height = POLL_FONT_SIZE + POLL_VPADDING + + bar_height = (height - POLL_VPADDING - title_height) / len( + shape.answers + ) - POLL_VPADDING + bar_width = width - 4 * POLL_HPADDING - max_label_width - max_percent_width + bar_x = 2 * POLL_HPADDING + max_label_width + + # All sizes are calculated, so draw the poll + layout.set_ellipsize(Pango.EllipsizeMode.END) + if shape.questionText != "": + title_font = Pango.FontDescription() + title_font.set_family(FONT_FAMILY) + title_font.set_absolute_size(int(POLL_FONT_SIZE * Pango.SCALE)) + title_font.set_weight(Pango.Weight.BOLD) + layout.set_font_description(title_font) + layout.set_width(int(width - 2 * POLL_HPADDING) * Pango.SCALE) + layout.set_text(shape.questionText, -1) + _label_width, label_height = layout.get_pixel_size() + ctx.move_to( + POLL_HPADDING, + (POLL_FONT_SIZE - label_height) / 2 + POLL_VPADDING, + ) + ctx.set_source_rgb(*V2_TEXT_COLOR) + PangoCairo.show_layout(ctx, layout) + layout.set_font_description(font) + + for i, answer in enumerate(shape.answers): + bar_y = (bar_height + POLL_VPADDING) * i + POLL_VPADDING + title_height + if shape.numResponders > 0: + result_ratio = float(answer.numVotes) / float(shape.numResponders) + else: + result_ratio = 0.0 + percent = "{}%".format(int(result_ratio * 100)) + + bar_x2 = bar_x + (bar_width * result_ratio) + + # Draw the bar + ctx.set_line_width(POLL_LINE_WIDTH) + ctx.move_to(bar_x + half_lw, bar_y + half_lw) + ctx.line_to(max(bar_x + half_lw, bar_x2 - half_lw), bar_y + half_lw) + ctx.line_to( + max(bar_x + half_lw, bar_x2 - half_lw), bar_y + bar_height - half_lw + ) + ctx.line_to(bar_x + half_lw, bar_y + bar_height - half_lw) + ctx.close_path() + ctx.set_source_rgb(*color.solid) + ctx.fill_preserve() + ctx.stroke() + + # Draw the label and percentage + ctx.set_source_rgb(*V2_TEXT_COLOR) + layout.set_width(int(max_label_width * Pango.SCALE)) + layout.set_text(answer.key, -1) + label_width, label_height = layout.get_pixel_size() + ctx.move_to( + bar_x - POLL_HPADDING - label_width, + bar_y + (bar_height - label_height) / 2, + ) + PangoCairo.show_layout(ctx, layout) + layout.set_width(int(max_percent_width * Pango.SCALE)) + layout.set_text(percent, -1) + percent_width, percent_height = layout.get_pixel_size() + ctx.move_to( + width - POLL_HPADDING - percent_width, + bar_y + (bar_height - percent_height) / 2, + ) + PangoCairo.show_layout(ctx, layout) + + # Draw the result count + layout.set_ellipsize(Pango.EllipsizeMode.NONE) + layout.set_width(-1) + layout.set_text(str(answer.numVotes), -1) + votes_width, votes_height = layout.get_pixel_size() + if votes_width < (bar_x2 - bar_x - 2 * POLL_HPADDING): + # Votes fit in the bar + ctx.move_to( + bar_x + (bar_x2 - bar_x - votes_width) / 2, + bar_y + (bar_height - votes_height) / 2, + ) + ctx.set_source_rgb(*color.semi) + PangoCairo.show_layout(ctx, layout) + else: + # Votes do not fit in the bar, so put them after + ctx.move_to(bar_x2 + POLL_HPADDING, bar_y + (bar_height - votes_height) / 2) + ctx.set_source_rgb(*V2_TEXT_COLOR) + PangoCairo.show_layout(ctx, layout) diff --git a/bbb_presentation_video/renderer/tldraw/utils.py b/bbb_presentation_video/renderer/tldraw/utils.py index 47b614b..79ec0ce 100644 --- a/bbb_presentation_video/renderer/tldraw/utils.py +++ b/bbb_presentation_video/renderer/tldraw/utils.py @@ -167,6 +167,92 @@ class ColorStyle(Enum): ] ) +V2_TEXT_COLOR: Color = Color.from_int(0x000000) + + +@attr.s(order=False, slots=True, auto_attribs=True) +class V2Color: + solid: Color + semi: Color + pattern: Color + highlight: Color + + +V2_COLORS: Dict[ColorStyle, V2Color] = { + ColorStyle.BLACK: V2Color( + solid=Color.from_int(0x1D1D1D), + semi=Color.from_int(0xE8E8E8), + pattern=Color.from_int(0x494949), + highlight=Color.from_int(0xFDDD00), + ), + ColorStyle.BLUE: V2Color( + solid=Color.from_int(0x4263EB), + semi=Color.from_int(0xDCE1F8), + pattern=Color.from_int(0x6681EE), + highlight=Color.from_int(0x10ACFF), + ), + ColorStyle.GREEN: V2Color( + solid=Color.from_int(0x099268), + semi=Color.from_int(0xD3E9E3), + pattern=Color.from_int(0x39A785), + highlight=Color.from_int(0x00FFC8), + ), + ColorStyle.GREY: V2Color( + solid=Color.from_int(0xADB5BD), + semi=Color.from_int(0xECEEF0), + pattern=Color.from_int(0xBCC3C9), + highlight=Color.from_int(0xCBE7F1), + ), + ColorStyle.LIGHT_BLUE: V2Color( + solid=Color.from_int(0x4DABF7), + semi=Color.from_int(0xDDEDFA), + pattern=Color.from_int(0x6FBBF8), + highlight=Color.from_int(0x00F4FF), + ), + ColorStyle.LIGHT_GREEN: V2Color( + solid=Color.from_int(0x40C057), + semi=Color.from_int(0xDBF0E0), + pattern=Color.from_int(0x65CB78), + highlight=Color.from_int(0x65F641), + ), + ColorStyle.LIGHT_RED: V2Color( + solid=Color.from_int(0xFF8787), + semi=Color.from_int(0xF4DADB), + pattern=Color.from_int(0xFE9E9E), + highlight=Color.from_int(0xFF7FA3), + ), + ColorStyle.LIGHT_VIOLET: V2Color( + solid=Color.from_int(0xE599F7), + semi=Color.from_int(0xF5EAFA), + pattern=Color.from_int(0xE9ACF8), + highlight=Color.from_int(0xFF88FF), + ), + ColorStyle.ORANGE: V2Color( + solid=Color.from_int(0xF76707), + semi=Color.from_int(0xF8E2D4), + pattern=Color.from_int(0xF78438), + highlight=Color.from_int(0xFFA500), + ), + ColorStyle.RED: V2Color( + solid=Color.from_int(0xE03131), + semi=Color.from_int(0xF4DADB), + pattern=Color.from_int(0xE55959), + highlight=Color.from_int(0xFF636E), + ), + ColorStyle.VIOLET: V2Color( + solid=Color.from_int(0xAE3EC9), + semi=Color.from_int(0xECDCF2), + pattern=Color.from_int(0xBD63D3), + highlight=Color.from_int(0xC77CFF), + ), + ColorStyle.YELLOW: V2Color( + solid=Color.from_int(0xFFC078), + semi=Color.from_int(0xF9F0E6), + pattern=Color.from_int(0xFECB92), + highlight=Color.from_int(0xFDDD00), + ), +} + class DashStyle(Enum): DRAW: str = "draw" diff --git a/typings/gi/repository/Pango.pyi b/typings/gi/repository/Pango.pyi index 7936b5a..88898ec 100644 --- a/typings/gi/repository/Pango.pyi +++ b/typings/gi/repository/Pango.pyi @@ -252,6 +252,8 @@ class FontDescription: if the field was explicitly set or not. """ def get_size_is_absolute(self) -> bool: ... + def get_weight(self) -> int: + """Gets the weight field of a font description.""" def set_absolute_size(self, size: float) -> None: ... def set_family(self, family: str) -> None: """Sets the family name field of a font description. @@ -278,6 +280,11 @@ class FontDescription: Use :meth:`Pango.FontDescription.set_absolute_size` if you need a particular size in device units. """ + def set_weight(self, weight: Weight | int) -> None: + """Sets the weight field of a font description. + + The weight field specifies how bold or light the font should be. + """ class FontMetrics: def get_ascent(self) -> int: ... @@ -377,6 +384,37 @@ class EllipsizeMode(Enum): MIDDLE: int END: int +class Weight(Enum): + """An enumeration specifying the weight (boldness) of a font. + + Weight is specified as a numeric value ranging from 100 to 1000. + This enumeration simply provides some common, predefined values.""" + + THIN: int + """the thin weight (= 100) Since: 1.24""" + ULTRALIGHT: int + """the ultralight weight (= 200)""" + LIGHT: int + """the light weight (= 300)""" + SEMILIGHT: int + """the semilight weight (= 350) Since: 1.36.7""" + BOOK: int + """the book weight (= 380) Since: 1.24)""" + NORMAL: int + """the default weight (= 400)""" + MEDIUM: int + """the medium weight (= 500) Since: 1.24""" + SEMIBOLD: int + """the semibold weight (= 600)""" + BOLD: int + """the bold weight (= 700)""" + ULTRABOLD: int + """the ultrabold weight (= 800)""" + HEAVY: int + """the heavy weight (= 900)""" + ULTRAHEAVY: int + """the ultraheavy weight (= 1000) Since: 1.24""" + class WrapMode(Enum): """:class:`Pango.WrapMode` describes how to wrap the lines of a :class:`Pango.Layout` to the desired width.