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 Canvas and OpenGLCanvas widgets #121

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all 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
16 changes: 11 additions & 5 deletions tukaan/_tcl.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,16 @@
from tukaan.exceptions import TukaanTclError


def print_traceback():
# remove unnecessary lines:
# File "/home/.../_tcl.py", line 46, in __call__
# return func(*args)
tb = traceback.format_exc().split("\n")[3:]
print("Exception in Tukaan callback")
print("Traceback (most recent call last):")
print("\n".join(tb))


class TclCallback:
def __init__(self, callback: Callable[..., Any], converters=(), args=(), kwargs={}):
self._callback = callback
Expand All @@ -42,11 +52,7 @@ def __call__(self, *tcl_args) -> Any:
try:
return self._callback(*tcl_args, *self._args, **self._kwargs)
except Exception:
# remove unnecessary lines:
# File "/home/.../_tcl.py", line 46, in __call__
# return func(*args)
tb = traceback.format_exc().split("\n")[3:]
print("Traceback (most recent call last):\n", "\n".join(tb))
print_traceback()

def dispose(self) -> None:
Tcl._interp.deletecommand(self._name)
Expand Down
3 changes: 2 additions & 1 deletion tukaan/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
except ImportError as e:
raise ImportError("Tukaan needs PIL and PIL._imagingtk to work with images.") from e

from libtukaan import Serif
from libtukaan import Serif, Sulfur

from tukaan._tcl import Tcl
from tukaan.theming import LookAndFeel, NativeTheme, Theme
Expand All @@ -36,6 +36,7 @@ def __init__(
Tcl.init(name, screen)

Serif.init()
Sulfur.init()

try:
# TODO: remove this try block, when Pillow 9.3.0 becomes 4-5 months old,
Expand Down
2 changes: 2 additions & 0 deletions tukaan/colors.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,8 @@ def __to_tcl__(self) -> str:

@classmethod
def __from_tcl__(cls, tcl_value: str) -> Color:
if not tcl_value:
return None
return Color(rgb=Hex.convert_from(tcl_value))

@property
Expand Down
33 changes: 25 additions & 8 deletions tukaan/fonts/font.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,6 @@ def config(
if size:
if isinstance(size, float):
size = round(size)
size = -size # Negative value means points in Tk, but pixels in Tukaan. Invert it
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe revert this, and use a global tkfont instance to convert points to pixels in get_font_options_for_text_item


args = Tcl.to_tcl_args(
family=family,
Expand All @@ -164,9 +163,15 @@ def config(

try:
Tcl.call(None, "font", "create", self._name, *args)
except TukaanTclError:
except TukaanTclError as e:
# Font already exists in Tcl
Tcl.call(None, "font", "configure", self._name, *args)
try:
Tcl.call(None, "font", "configure", self._name, *args)
except TukaanTclError as f:
if "doesn't exist" in str(f):
raise e from None
else:
raise f from None

def __to_tcl__(self) -> str:
return self._name
Expand Down Expand Up @@ -204,20 +209,35 @@ def __from_tcl__(cls, tcl_value: str) -> Font | dict[str, str | int | bool]:

return kwargs

def _get_props(self):
return Tcl.call(
{
"-family": str,
"-size": int,
"-weight": str,
"-slant": str,
"-underline": bool,
"-overstrike": bool,
},
"font",
"actual",
self,
)

family = _StrFontProperty()
"""Get or set the current font family."""

@property
def size(self) -> int:
"""Get or set the current font size."""
return -Tcl.call(int, "font", "actual", self, "-size")
return Tcl.call(int, "font", "actual", self, "-size")

@size.setter
def size(self, value: int) -> None:
if isinstance(value, float):
value = round(value)

Tcl.call(None, "font", "configure", self, "-size", -value)
Tcl.call(None, "font", "configure", self, "-size", value)

bold = _FontStyleProperty("weight", "bold", "normal")
"""Get or set whether the font should be drawn as bold."""
Expand Down Expand Up @@ -285,9 +305,6 @@ def font(
weight = {True: "bold", False: "normal"}.get(bold)
slant = {True: "italic", False: "roman"}.get(italic)

if size:
size = -size # Negative value means points in Tk, but pixels in Tukaan. Invert it

return Tcl.to_tcl_args(
family=family,
size=size,
Expand Down
21 changes: 21 additions & 0 deletions tukaan/graphics/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from .canvas import Canvas
from .enums import FillRule
from .equipment import Brush, Pen
from .items import (
CanvasItem,
Circle,
Ellipse,
Group,
Image,
Line,
Path,
Polygon,
PolygonalLine,
Rectangle,
Square,
Text,
Transform,
Vector,
)
from .transform import Transform
from .utils import ArrowHead, DashPattern
38 changes: 38 additions & 0 deletions tukaan/graphics/canvas.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from __future__ import annotations

from abc import ABC, abstractmethod
from dataclasses import dataclass

from tukaan._base import TkWidget, WidgetBase
from tukaan._tcl import Tcl
from .equipment import Brush, Pen
from .gradient import LinearGradient, RadialGradient, Gradient
from tukaan.colors import Color


class Canvas(WidgetBase):
_tcl_class = "::tkp::canvas"

def __init__(
self,
parent: TkWidget,
*,
width: int | None = 400,
height: int | None = 400,
bg_color: Color | str | None = None,
) -> None:
super().__init__(
parent,
width=width,
height=height,
background=bg_color,
borderwidth=0,
highlightthickness=0,
)

self.brush = Brush()
self.pen = Pen()

self._gradient_superclass = Gradient(self)
self.linear_gradient = LinearGradient(self)
self.radial_gradient = RadialGradient(self)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is super unintuitive, and makes no sense.

Unfortunately we can't make it totally independent from the camvas wodget, because that's not how TkPath works, but it shouldn't be a "method".

Plus another thing came into my mind, that's actually a serious problem. There are random helper objects that are mutable and there are others that are immutable. This is because the objects that are mutable are actual Tk stuff, and others are just simple Python objects, e.g dataclasses, which can't notify the widget is an attribute is changed. This is NOT good. Something must be done.

Mutable:

  • Font
  • gradients
  • Control variables

Immutable

  • Color
  • Brush and Pen
  • ArrowHead

5 changes: 5 additions & 0 deletions tukaan/graphics/enums.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from enum import Enum

class FillRule(Enum):
EvenOdd = "evenodd"
NonZero = "nonzero"
57 changes: 57 additions & 0 deletions tukaan/graphics/equipment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
from __future__ import annotations

from dataclasses import dataclass

from tukaan._tcl import Tcl
from tukaan.colors import Color

from .utils import DashPattern


class Missing:
...


def get_missing_or_none(value):
if value is Missing:
return None
elif value is None:
return ""
else:
return value


@dataclass(frozen=True)
class Brush:
fill: Color | str | None | Missing = Missing
opacity: float = None
fillrule: str = None

def __to_tcl__(self) -> tuple:
return Tcl.to_tcl_args(
fill=get_missing_or_none(self.fill),
fillopacity=self.opacity,
fillrule=self.fillrule,
)


@dataclass(frozen=True)
class Pen:
color: Color | str | None | Missing = Missing
width: float = None
pattern: DashPattern = Missing
# opacity: float = None # causes segfaults in TkPath
line_cap: str = None
line_join: str = None
# miterlimit: float = None # buggy!

def __to_tcl__(self) -> tuple:
return Tcl.to_tcl_args(
stroke=get_missing_or_none(self.color),
strokedasharray=get_missing_or_none(self.pattern),
strokelinecap=self.line_cap,
strokelinejoin=self.line_join,
# strokeopacity=self.opacity,
strokewidth=self.width,
# strokemiterlimit=self.miterlimit,
)
124 changes: 124 additions & 0 deletions tukaan/graphics/gradient.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
from __future__ import annotations

from math import cos, radians, sin, tan
from typing import TYPE_CHECKING

from tukaan._tcl import Tcl
from tukaan._utils import seq_pairs

if TYPE_CHECKING:
from .canvas import Canvas


class Gradient:
def __init__(self, parent: Canvas) -> None:
self._canvas = parent

def __from_tcl__(self, gradient_name: str) -> Gradient:
gradient = {"linear": LinearGradient, "radial": RadialGradient}[
Tcl.call(str, self._canvas, "gradient", "type", gradient_name)
](self._canvas)
gradient._name = gradient_name
return gradient

@property
def in_use(self) -> bool:
return Tcl.call(bool, self._canvas, "gradient", "inuse", self)

@property
def stops(self):
result = Tcl.call((str,), self._canvas, "gradient", "cget", self, "-stops")

result_dict = {}
for x in result:
k, v = x.split(" ")
result_dict[float(k)] = v

return result_dict

@stops.setter
def stops(self, new_stops) -> None:
Tcl.call(
None,
self._canvas,
"gradient",
"configure",
self,
"-stops",
self._process_stops(new_stops),
)

def dispose(self) -> None:
Tcl.call(None, self._canvas, "gradient", "delete", self)

def _process_stops(self, stops: list[str] | tuple[str, ...] | dict[int, str]):
if not isinstance(stops, dict):
step = 1 / (len(stops) - 1)
stops = {(step * index): color for index, color in enumerate(stops)}

result = list(seq_pairs(Tcl.to(stops)))
result.sort(key=lambda e: e[0])
return result


class LinearGradient(Gradient):
def __call__(
self, stops: list[str] | tuple[str, ...] | dict[int, str], *, angle: int = 0
) -> Gradient:
angle_sin = sin(radians(angle))
angle_cos = cos(radians(angle))

if 0 <= angle <= 90:
ltransition = (0, 0, angle_cos, angle_sin)
elif 90 < angle <= 180:
ltransition = (-angle_cos, 0, 0, angle_sin)
elif 180 < angle <= 270:
ltransition = (-angle_cos, -angle_sin, 0, 0)
elif 270 < angle <= 360:
ltransition = (0, -angle_sin, angle_cos, 0)
else:
raise ValueError("angle must be between 0 and 360")

self._name = Tcl.call(
str,
self._canvas,
"gradient",
"create",
"linear",
"-stops",
self._process_stops(stops),
*Tcl.to_tcl_args(lineartransition=ltransition),
)
return self


class RadialGradient(Gradient):
def __call__(
self,
stops: list[str] | tuple[str, ...] | dict[int, str],
*,
center: int | tuple[int, int] = 0.5,
last_center: int | tuple[int, int] = 0.5,
radius: int = 0.5,
) -> Gradient:
if isinstance(center, (tuple, list)):
fx, fy = center
else:
fx = fy = center

if isinstance(last_center, (tuple, list)):
cx, cy = last_center
else:
cx = cy = last_center

self._name = Tcl.call(
str,
self._canvas,
"gradient",
"create",
"radial",
"-stops",
self._process_stops(stops),
*Tcl.to_tcl_args(radialtransition=(cx, cy, radius, fx, fy)),
)
return self
Loading