Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add basic tldraw poll shape rendering #55

Merged
merged 7 commits into from
Oct 4, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions bbb_presentation_video/events/tldraw.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
4 changes: 4 additions & 0 deletions bbb_presentation_video/renderer/tldraw/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
HighlighterShape,
LineShape,
OvalGeoShape,
PollShape,
RectangleGeoShape,
RectangleShape,
RhombusGeoShape,
Expand All @@ -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
Expand Down Expand Up @@ -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):
Expand Down
44 changes: 44 additions & 0 deletions bbb_presentation_video/renderer/tldraw/shape/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -608,6 +649,7 @@ def update_from_data(self, data: ShapeData) -> None:
TriangleGeoShape,
TriangleShape,
XBoxGeoShape,
PollShape,
]


Expand Down Expand Up @@ -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"])
Expand Down
152 changes: 152 additions & 0 deletions bbb_presentation_video/renderer/tldraw/shape/poll.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
# 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

FONT_FAMILY = "Arial"
POLL_BAR_COLOR = Color.from_int(0x333333)
POLL_LINE_WIDTH = 2.0
POLL_FONT_SIZE = 22
POLL_BG = Color.from_int(0xFFFFFF)
POLL_FG = Color.from_int(0x000000)
POLL_VPADDING = 20.0
POLL_HPADDING = 10.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

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(*POLL_BG)
ctx.fill_preserve()
ctx.set_source_rgb(*POLL_FG)
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)

bar_height = (height - POLL_VPADDING) / 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
for i, answer in enumerate(shape.answers):
bar_y = (bar_height + POLL_VPADDING) * i + POLL_VPADDING
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(*POLL_BAR_COLOR)
ctx.fill_preserve()
ctx.stroke()

# Draw the label and percentage
layout.set_ellipsize(Pango.EllipsizeMode.END)
ctx.set_source_rgb(*POLL_FG)
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(*POLL_BG)
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(*POLL_FG)
PangoCairo.show_layout(ctx, layout)
Loading