desklab 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- desklab/__init__.py +9 -0
- desklab/_assets/__init__.py +0 -0
- desklab/_assets/desklab.png +0 -0
- desklab/_check/__init__.py +10 -0
- desklab/_check/_check_operations/__init__.py +9 -0
- desklab/_check/_check_operations/_check.py +14 -0
- desklab/_check/_check_operations/_length.py +33 -0
- desklab/_check/_check_operations/_range.py +41 -0
- desklab/_check/_value.py +31 -0
- desklab/_utils/__init__.py +10 -0
- desklab/_utils/_geometry.py +36 -0
- desklab/_utils/_imageio_ffmpeg_exe.py +163 -0
- desklab/_utils/_regex.py +5 -0
- desklab/areas/__init__.py +9 -0
- desklab/areas/_area_interface.py +17 -0
- desklab/areas/_clickable_area.py +27 -0
- desklab/areas/_rectangular_area.py +102 -0
- desklab/components/__init__.py +15 -0
- desklab/components/_button.py +61 -0
- desklab/components/_drag_drop.py +58 -0
- desklab/components/_drawing_area.py +205 -0
- desklab/components/_text.py +117 -0
- desklab/components/_text_input.py +344 -0
- desklab/components/_window.py +124 -0
- desklab/containers/__init__.py +9 -0
- desklab/containers/_constants.py +18 -0
- desklab/containers/_flexbox.py +61 -0
- desklab/containers/_flexbox_interface.py +205 -0
- desklab/containers/_protected_flexbox.py +133 -0
- desklab/entity_types/__init__.py +20 -0
- desklab/entity_types/_colorable.py +26 -0
- desklab/entity_types/_containable.py +12 -0
- desklab/entity_types/_copiable.py +19 -0
- desklab/entity_types/_dimensionable.py +25 -0
- desklab/entity_types/_displayable.py +14 -0
- desklab/entity_types/_entity.py +5 -0
- desklab/entity_types/_event_sensitive.py +27 -0
- desklab/entity_types/_positionable.py +22 -0
- desklab/exceptions/__init__.py +12 -0
- desklab/exceptions/_exceptions.py +37 -0
- desklab/listeners/__init__.py +38 -0
- desklab/listeners/_clipboard.py +18 -0
- desklab/listeners/_default.py +21 -0
- desklab/listeners/_hover.py +36 -0
- desklab/listeners/_keyboard.py +71 -0
- desklab/listeners/_mouse.py +26 -0
- desklab/listeners/_protected_listener.py +89 -0
- desklab/listeners/_system_listener.py +46 -0
- desklab/media/__init__.py +7 -0
- desklab/media/_audio.py +55 -0
- desklab/media/_image.py +64 -0
- desklab/primitives/__init__.py +7 -0
- desklab/primitives/_color.py +142 -0
- desklab/primitives/_font.py +86 -0
- desklab/system/__init__.py +12 -0
- desklab/system/_clipboard.py +248 -0
- desklab/system/_keyboard.py +148 -0
- desklab/system/_mouse.py +63 -0
- desklab/system/_system_input.py +23 -0
- desklab-0.1.0.dist-info/METADATA +121 -0
- desklab-0.1.0.dist-info/RECORD +63 -0
- desklab-0.1.0.dist-info/WHEEL +5 -0
- desklab-0.1.0.dist-info/top_level.txt +1 -0
desklab/__init__.py
ADDED
|
File without changes
|
|
Binary file
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
from typing import Any, Callable
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Check:
|
|
6
|
+
def __init__(self, rule: Callable[[Any], bool], description: str | None = None) -> None:
|
|
7
|
+
self.__rule = rule
|
|
8
|
+
self.__description = description
|
|
9
|
+
|
|
10
|
+
def __call__(self, *args: Any, **kwargs: Any) -> bool:
|
|
11
|
+
return self.__rule(*args, **kwargs)
|
|
12
|
+
|
|
13
|
+
def __str__(self) -> str:
|
|
14
|
+
return self.__description or self.__rule.__name__
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from desklab._check._check_operations import Check
|
|
4
|
+
from desklab.exceptions import InvalidParameterValue
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class CheckLength(Check):
|
|
8
|
+
|
|
9
|
+
def __init__(self, reference_length: int, comparison: str = "=", variable_name: str | None = None) -> None:
|
|
10
|
+
|
|
11
|
+
operators = {"=", ">", "<", ">=", "<="}
|
|
12
|
+
if comparison not in operators:
|
|
13
|
+
raise InvalidParameterValue(f"comparison", comparison,
|
|
14
|
+
f"'comparison' must be one of: {operators}")
|
|
15
|
+
|
|
16
|
+
def compare(value: Any) -> bool:
|
|
17
|
+
if comparison == "=":
|
|
18
|
+
return len(value) == reference_length
|
|
19
|
+
elif comparison == ">":
|
|
20
|
+
return len(value) > reference_length
|
|
21
|
+
elif comparison == "<":
|
|
22
|
+
return len(value) < reference_length
|
|
23
|
+
elif comparison == ">=":
|
|
24
|
+
return len(value) >= reference_length
|
|
25
|
+
elif comparison == "<=":
|
|
26
|
+
return len(value) <= reference_length
|
|
27
|
+
raise InvalidParameterValue(f"comparison", comparison,
|
|
28
|
+
f"'comparison' must be one of: {operators}")
|
|
29
|
+
|
|
30
|
+
name = variable_name or "Value"
|
|
31
|
+
message = f"length({name}) {comparison} {reference_length}"
|
|
32
|
+
|
|
33
|
+
super().__init__(compare, message)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
from typing import Any, cast
|
|
3
|
+
from desklab.exceptions import MissingParameters
|
|
4
|
+
from desklab._check._check_operations import Check
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CheckRange(Check):
|
|
9
|
+
|
|
10
|
+
def __init__(self, min_value: Any = None, max_value: Any = None, variable_name: str | None = None) -> None:
|
|
11
|
+
|
|
12
|
+
if min_value is None and max_value is None:
|
|
13
|
+
raise MissingParameters(["min_value", "max_value"],
|
|
14
|
+
"At least one of 'min_value' or 'max_value' must be provided")
|
|
15
|
+
|
|
16
|
+
def is_valid(value: Any) -> bool:
|
|
17
|
+
|
|
18
|
+
if isinstance(value, Iterable) and not isinstance(value, (str, bytes)):
|
|
19
|
+
iterable = cast(Iterable[Any], value)
|
|
20
|
+
return all(is_valid(v) for v in iterable)
|
|
21
|
+
|
|
22
|
+
if min_value is not None and value < min_value:
|
|
23
|
+
return False
|
|
24
|
+
|
|
25
|
+
if max_value is not None and value > max_value:
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
return True
|
|
29
|
+
|
|
30
|
+
name = variable_name or "Value"
|
|
31
|
+
|
|
32
|
+
if min_value is not None and max_value is not None:
|
|
33
|
+
message = f"{name} must be between {min_value} and {max_value}"
|
|
34
|
+
|
|
35
|
+
elif min_value is not None:
|
|
36
|
+
message = f"{name} must be greater than or equal to {min_value}"
|
|
37
|
+
|
|
38
|
+
else:
|
|
39
|
+
message = f"{name} must be less than or equal to {max_value}"
|
|
40
|
+
|
|
41
|
+
super().__init__(is_valid, message)
|
desklab/_check/_value.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from typing import Callable, TypeVar, ParamSpec
|
|
3
|
+
from functools import wraps
|
|
4
|
+
from desklab.exceptions import InvalidParameterValue
|
|
5
|
+
from desklab._check._check_operations import Check
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
P = ParamSpec("P")
|
|
9
|
+
R = TypeVar("R")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def value_check(**validations: Check) -> Callable[[Callable[P, R]], Callable[P, R]]:
|
|
13
|
+
def decorator(function: Callable[P, R]) -> Callable[P, R]:
|
|
14
|
+
signature = inspect.signature(function)
|
|
15
|
+
|
|
16
|
+
@wraps(function)
|
|
17
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
18
|
+
bound_arguments = signature.bind(*args, **kwargs)
|
|
19
|
+
bound_arguments.apply_defaults()
|
|
20
|
+
|
|
21
|
+
for param_name, param_value in bound_arguments.arguments.items():
|
|
22
|
+
if param_name in ("self", "cls"):
|
|
23
|
+
continue
|
|
24
|
+
validation_rule = validations.get(param_name)
|
|
25
|
+
if validation_rule is not None and not validation_rule(param_value):
|
|
26
|
+
raise InvalidParameterValue(param_name, param_value,
|
|
27
|
+
str(validation_rule))
|
|
28
|
+
|
|
29
|
+
return function(*args, **kwargs)
|
|
30
|
+
return wrapper
|
|
31
|
+
return decorator
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from ._geometry import point_to_segment_distance, is_inside_circle
|
|
2
|
+
from ._regex import camel_case_to_snake_case
|
|
3
|
+
from ._imageio_ffmpeg_exe import get_ffmpeg_exe
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"point_to_segment_distance",
|
|
7
|
+
"is_inside_circle",
|
|
8
|
+
"camel_case_to_snake_case",
|
|
9
|
+
"get_ffmpeg_exe"
|
|
10
|
+
]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import math
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def point_to_segment_distance(point: tuple[int, int], segment_start: tuple[int, int], segment_end: tuple[int, int]) -> float:
|
|
5
|
+
point_x, point_y = point
|
|
6
|
+
start_x, start_y = segment_start
|
|
7
|
+
end_x, end_y = segment_end
|
|
8
|
+
|
|
9
|
+
vector_segment_x = end_x - start_x
|
|
10
|
+
vector_segment_y = end_y - start_y
|
|
11
|
+
|
|
12
|
+
vector_point_x = point_x - start_x
|
|
13
|
+
vector_point_y = point_y - start_y
|
|
14
|
+
|
|
15
|
+
segment_length_squared = vector_segment_x ** 2 + vector_segment_y ** 2
|
|
16
|
+
|
|
17
|
+
if segment_length_squared == 0:
|
|
18
|
+
return math.sqrt((point_x - start_x) ** 2 + (point_y - start_y) ** 2)
|
|
19
|
+
|
|
20
|
+
dot_product = vector_point_x * vector_segment_x + \
|
|
21
|
+
vector_point_y * vector_segment_y
|
|
22
|
+
projection_ratio = max(0.0, min(1.0, dot_product / segment_length_squared))
|
|
23
|
+
|
|
24
|
+
closest_x = start_x + vector_segment_x * projection_ratio
|
|
25
|
+
closest_y = start_y + vector_segment_y * projection_ratio
|
|
26
|
+
|
|
27
|
+
distance_x = point_x - closest_x
|
|
28
|
+
distance_y = point_y - closest_y
|
|
29
|
+
|
|
30
|
+
return math.sqrt(distance_x ** 2 + distance_y ** 2)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def is_inside_circle(coordinates: tuple[int, int], circle_center: tuple[int, int], circle_radius: int):
|
|
34
|
+
x, y = coordinates
|
|
35
|
+
cx, cy = circle_center
|
|
36
|
+
return (x - cx)**2 + (y - cy)**2 <= circle_radius**2
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# ==============================================================================
|
|
2
|
+
# The following code block or function incorporates code from 'imageio'.
|
|
3
|
+
#
|
|
4
|
+
# Copyright (c) 2019-2025, imageio
|
|
5
|
+
# All rights reserved.
|
|
6
|
+
#
|
|
7
|
+
# Redistribution and use in source and binary forms, with or without
|
|
8
|
+
# modification, are permitted provided that the following conditions are met:
|
|
9
|
+
#
|
|
10
|
+
# * Redistributions of source code must retain the above copyright notice, this
|
|
11
|
+
# list of conditions and the following disclaimer.
|
|
12
|
+
#
|
|
13
|
+
# * Redistributions in binary form must reproduce the above copyright notice,
|
|
14
|
+
# this list of conditions and the following disclaimer in the documentation
|
|
15
|
+
# and/or other materials provided with the distribution.
|
|
16
|
+
#
|
|
17
|
+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
18
|
+
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
19
|
+
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
20
|
+
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLD
|
|
21
|
+
|
|
22
|
+
import os
|
|
23
|
+
import platform
|
|
24
|
+
import subprocess
|
|
25
|
+
import sys
|
|
26
|
+
from functools import lru_cache
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any, Optional, no_type_check
|
|
29
|
+
|
|
30
|
+
FNAME_PER_PLATFORM = {
|
|
31
|
+
"macos-aarch64": "ffmpeg-macos-aarch64-v7.1",
|
|
32
|
+
"macos-x86_64": "ffmpeg-macos-x86_64-v7.1",
|
|
33
|
+
"windows-x86_64": "ffmpeg-win-x86_64-v7.1.exe",
|
|
34
|
+
"windows-i686": "ffmpeg-win32-v4.2.2.exe",
|
|
35
|
+
"linux-aarch64": "ffmpeg-linux-aarch64-v7.0.2",
|
|
36
|
+
"linux-x86_64": "ffmpeg-linux-x86_64-v7.0.2",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _get_os_string() -> str:
|
|
41
|
+
if sys.platform.startswith("win"):
|
|
42
|
+
return "windows"
|
|
43
|
+
elif sys.platform.startswith("darwin"):
|
|
44
|
+
return "macos"
|
|
45
|
+
elif sys.platform.startswith("linux"):
|
|
46
|
+
return "linux"
|
|
47
|
+
return sys.platform
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _get_arch() -> str:
|
|
51
|
+
is_64_bit = sys.maxsize > 2**32
|
|
52
|
+
machine = platform.machine()
|
|
53
|
+
|
|
54
|
+
if machine == "armv7l":
|
|
55
|
+
return "armv7"
|
|
56
|
+
elif is_64_bit and machine.startswith(("arm", "aarch64")):
|
|
57
|
+
return "aarch64"
|
|
58
|
+
elif is_64_bit:
|
|
59
|
+
return "x86_64"
|
|
60
|
+
return "i686"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _get_platform() -> str:
|
|
64
|
+
return _get_os_string() + "-" + _get_arch()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@no_type_check
|
|
68
|
+
def _popen_kwargs() -> dict[str, Any]:
|
|
69
|
+
startupinfo = None
|
|
70
|
+
creationflags = 0
|
|
71
|
+
if sys.platform.startswith("win"):
|
|
72
|
+
startupinfo = subprocess.STARTUPINFO()
|
|
73
|
+
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
|
74
|
+
return {
|
|
75
|
+
"startupinfo": startupinfo,
|
|
76
|
+
"creationflags": creationflags,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _is_valid_exe(exe: str) -> bool:
|
|
81
|
+
cmd = [exe, "-version"]
|
|
82
|
+
try:
|
|
83
|
+
with open(os.devnull, "w") as null:
|
|
84
|
+
subprocess.check_call(
|
|
85
|
+
cmd, stdout=null, stderr=subprocess.STDOUT, **_popen_kwargs()
|
|
86
|
+
)
|
|
87
|
+
return True
|
|
88
|
+
except (OSError, ValueError, subprocess.CalledProcessError):
|
|
89
|
+
return False
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@lru_cache(maxsize=1)
|
|
93
|
+
def _find_ffmpeg_path() -> Optional[Path]:
|
|
94
|
+
plat = _get_platform()
|
|
95
|
+
os_str = _get_os_string()
|
|
96
|
+
expected_filename = FNAME_PER_PLATFORM.get(plat, "ffmpeg")
|
|
97
|
+
|
|
98
|
+
possible_paths: list[Path] = []
|
|
99
|
+
home = Path.home()
|
|
100
|
+
|
|
101
|
+
imageio_cache_dir = home / ".imageio" / "ffmpeg"
|
|
102
|
+
desklab_cache_dir = home / ".desklab" / "ffmpeg"
|
|
103
|
+
|
|
104
|
+
possible_paths.extend([
|
|
105
|
+
imageio_cache_dir / expected_filename,
|
|
106
|
+
desklab_cache_dir / expected_filename,
|
|
107
|
+
])
|
|
108
|
+
|
|
109
|
+
prefix_path = Path(sys.prefix)
|
|
110
|
+
if os_str == "windows":
|
|
111
|
+
possible_paths.append(prefix_path / "Library" / "bin" / "ffmpeg.exe")
|
|
112
|
+
else:
|
|
113
|
+
possible_paths.append(prefix_path / "bin" / "ffmpeg")
|
|
114
|
+
|
|
115
|
+
if os_str == "windows":
|
|
116
|
+
appdata_local = Path(os.environ.get(
|
|
117
|
+
"LOCALAPPDATA", home / "AppData/Local"))
|
|
118
|
+
possible_paths.extend([
|
|
119
|
+
appdata_local / "Microsoft/WindowsApps/ffmpeg.exe",
|
|
120
|
+
home / "ffmpeg" / "bin" / "ffmpeg.exe",
|
|
121
|
+
Path("C:/Program Files/ffmpeg/bin/ffmpeg.exe"),
|
|
122
|
+
Path("C:/ffmpeg/bin/ffmpeg.exe"),
|
|
123
|
+
])
|
|
124
|
+
elif os_str == "macos":
|
|
125
|
+
possible_paths.extend([
|
|
126
|
+
Path("/opt/homebrew/bin/ffmpeg"),
|
|
127
|
+
Path("/usr/local/bin/ffmpeg"),
|
|
128
|
+
Path("/usr/bin/ffmpeg"),
|
|
129
|
+
home / "bin" / "ffmpeg",
|
|
130
|
+
])
|
|
131
|
+
elif os_str == "linux":
|
|
132
|
+
possible_paths.extend([
|
|
133
|
+
Path("/usr/bin/ffmpeg"),
|
|
134
|
+
Path("/usr/local/bin/ffmpeg"),
|
|
135
|
+
Path("/snap/bin/ffmpeg"),
|
|
136
|
+
home / "bin" / "ffmpeg",
|
|
137
|
+
home / ".local" / "bin" / "ffmpeg",
|
|
138
|
+
])
|
|
139
|
+
|
|
140
|
+
for path in possible_paths:
|
|
141
|
+
if path.is_file() and _is_valid_exe(str(path)):
|
|
142
|
+
return path
|
|
143
|
+
|
|
144
|
+
if _is_valid_exe("ffmpeg"):
|
|
145
|
+
return Path("ffmpeg")
|
|
146
|
+
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def get_ffmpeg_exe() -> Path:
|
|
151
|
+
exe_env = os.getenv("DESKLAB_FFMPEG_EXE") or os.getenv(
|
|
152
|
+
"IMAGEIO_FFMPEG_EXE")
|
|
153
|
+
if exe_env:
|
|
154
|
+
return Path(exe_env)
|
|
155
|
+
|
|
156
|
+
detected_path = _find_ffmpeg_path()
|
|
157
|
+
if detected_path:
|
|
158
|
+
return detected_path
|
|
159
|
+
|
|
160
|
+
raise RuntimeError(
|
|
161
|
+
"No ffmpeg exe could be found. Install ffmpeg on your system, "
|
|
162
|
+
"or set the DESKLAB_FFMPEG_EXE environment variable."
|
|
163
|
+
)
|
desklab/_utils/_regex.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from desklab.entity_types import ContainableEntity, DisplayableEntity, CopiableEntity, ColorableEntity
|
|
2
|
+
from desklab.primitives import Color
|
|
3
|
+
from abc import abstractmethod
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AreaInterface(ContainableEntity, DisplayableEntity, ColorableEntity, CopiableEntity):
|
|
7
|
+
|
|
8
|
+
def __init__(self, width: int, height: int, color: Color | tuple[int, ...] | str = "BLACK") -> None:
|
|
9
|
+
super().__init__(x=0, y=0, width=width, height=height, color=color)
|
|
10
|
+
|
|
11
|
+
@abstractmethod
|
|
12
|
+
def contains(self, coordinates: tuple[int, int]) -> bool:
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
def get_rect(self) -> tuple[int, int, int, int]:
|
|
16
|
+
return (self.get_x(), self.get_y(),
|
|
17
|
+
self.get_width(), self.get_height())
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
from ._rectangular_area import RectangularArea
|
|
3
|
+
from desklab.entity_types import EventSensitiveEntity
|
|
4
|
+
from desklab.primitives import Color
|
|
5
|
+
from desklab.system import Mouse
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ClickableArea(RectangularArea, EventSensitiveEntity):
|
|
9
|
+
|
|
10
|
+
def __init__(self, width: int, height: int, color: Color | tuple[int, ...] | str = "BLACK", corners_radius: tuple[int, int, int, int] | int = 0) -> None:
|
|
11
|
+
super().__init__(width, height, color, corners_radius)
|
|
12
|
+
self.__is_clicked = False
|
|
13
|
+
self.__is_held = False
|
|
14
|
+
|
|
15
|
+
def is_clicked(self) -> bool:
|
|
16
|
+
return self.__is_clicked
|
|
17
|
+
|
|
18
|
+
def is_held(self) -> bool:
|
|
19
|
+
return self.__is_held
|
|
20
|
+
|
|
21
|
+
def handle_event(self, *args: Any, **kwargs: Any) -> None:
|
|
22
|
+
super().handle_event(*args, **kwargs)
|
|
23
|
+
mouse = self._get_from_kwargs(Mouse, kwargs)
|
|
24
|
+
inside = self.contains(mouse.get_position())
|
|
25
|
+
|
|
26
|
+
self.__is_clicked = mouse.is_clicked() and inside
|
|
27
|
+
self.__is_held = mouse.is_held() and inside
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# fmt: off
|
|
2
|
+
from desklab._check import value_check, CheckRange, CheckLength
|
|
3
|
+
from desklab._utils import is_inside_circle
|
|
4
|
+
from ._area_interface import AreaInterface
|
|
5
|
+
from desklab.primitives import Color
|
|
6
|
+
from typing import Any
|
|
7
|
+
import os
|
|
8
|
+
os.environ['PYGAME_HIDE_SUPPORT_PROMPT'] = "hide"
|
|
9
|
+
import pygame
|
|
10
|
+
from pygame import Surface
|
|
11
|
+
# fmt: on
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RectangularArea(AreaInterface):
|
|
15
|
+
|
|
16
|
+
def __init__(self,
|
|
17
|
+
width: int,
|
|
18
|
+
height: int,
|
|
19
|
+
color: Color | tuple[int, ...] | str = "BLACK",
|
|
20
|
+
corners_radius: tuple[int, int, int, int] | int = 0) -> None:
|
|
21
|
+
super().__init__(width, height, color)
|
|
22
|
+
self.set_corners_radius(corners_radius)
|
|
23
|
+
|
|
24
|
+
@value_check(corners_radius=CheckRange(min_value=0, variable_name="corners_radius"))
|
|
25
|
+
def __validate_corners_range(self, corners_radius: tuple[int, ...]) -> None:
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
@value_check(corners_radius=CheckLength(reference_length=4, variable_name="corners_radius"))
|
|
29
|
+
def __validate_corners_length(self, corners_radius: tuple[int, ...]) -> None:
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
def set_corners_radius(self, corners_radius: tuple[int, int, int, int] | int) -> None:
|
|
33
|
+
if isinstance(corners_radius, int):
|
|
34
|
+
corners = (corners_radius, ) * 4
|
|
35
|
+
else:
|
|
36
|
+
corners = corners_radius
|
|
37
|
+
|
|
38
|
+
self.__validate_corners_length(corners)
|
|
39
|
+
self.__validate_corners_range(corners)
|
|
40
|
+
self.__set_corners_radius(corners)
|
|
41
|
+
|
|
42
|
+
def __set_corners_radius(self, corners: tuple[int, ...]) -> None:
|
|
43
|
+
self.__corners_radius = corners
|
|
44
|
+
|
|
45
|
+
def get_corners_radius(self) -> tuple[int, int, int, int]:
|
|
46
|
+
assert len(self.__corners_radius) == 4
|
|
47
|
+
return self.__corners_radius
|
|
48
|
+
|
|
49
|
+
def contains(self, coordinates: tuple[int, int]) -> bool:
|
|
50
|
+
x, y = coordinates
|
|
51
|
+
self_x, self_y, self_w, self_h = self.get_rect()
|
|
52
|
+
if not (self_x <= x <= self_x + self_w and
|
|
53
|
+
self_y <= y <= self_y + self_h):
|
|
54
|
+
return False
|
|
55
|
+
return not self.__exceeds_corners(coordinates)
|
|
56
|
+
|
|
57
|
+
def __exceeds_corners(self, coordinates: tuple[int, int]) -> bool:
|
|
58
|
+
rect_x, rect_y, rect_width, rect_height = self.get_rect()
|
|
59
|
+
radius_top_left, radius_top_right, radius_bottom_left, radius_bottom_right = self.get_corners_radius()
|
|
60
|
+
|
|
61
|
+
corners_geometry_map = [
|
|
62
|
+
(radius_top_left, rect_x + radius_top_left,
|
|
63
|
+
rect_y + radius_top_left, True, True), # Top-Left
|
|
64
|
+
(radius_top_right, rect_x + rect_width - radius_top_right,
|
|
65
|
+
rect_y + radius_top_right, False, True), # Top-Right
|
|
66
|
+
(radius_bottom_left, rect_x + radius_bottom_left, rect_y +
|
|
67
|
+
rect_height - radius_bottom_left, True, False), # Bottom-Left
|
|
68
|
+
(radius_bottom_right, rect_x + rect_width - radius_bottom_right,
|
|
69
|
+
rect_y + rect_height - radius_bottom_right, False, False) # Bottom-Right
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
for corner_radius, arc_center_x, arc_center_y, check_left, check_top in corners_geometry_map:
|
|
73
|
+
|
|
74
|
+
if self.__is_point_inside_corner_bounding_box(coordinates, arc_center_x, arc_center_y, check_left, check_top):
|
|
75
|
+
if not is_inside_circle(coordinates, (arc_center_x, arc_center_y), corner_radius):
|
|
76
|
+
return True
|
|
77
|
+
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
def __is_point_inside_corner_bounding_box(self, coordinates: tuple[int, int], arc_center_x: int, arc_center_y: int, check_left: bool, check_top: bool) -> bool:
|
|
81
|
+
point_x, point_y = coordinates
|
|
82
|
+
is_within_horizontal_boundary = point_x < arc_center_x if check_left else point_x > arc_center_x
|
|
83
|
+
is_within_vertical_boundary = point_y < arc_center_y if check_top else point_y > arc_center_y
|
|
84
|
+
return is_within_horizontal_boundary and is_within_vertical_boundary
|
|
85
|
+
|
|
86
|
+
def display(self, screen: Surface) -> None:
|
|
87
|
+
corners = self.get_corners_radius()
|
|
88
|
+
pygame.draw.rect(screen,
|
|
89
|
+
self.get_color_tuple(),
|
|
90
|
+
self.get_rect(),
|
|
91
|
+
border_top_left_radius=corners[0],
|
|
92
|
+
border_top_right_radius=corners[1],
|
|
93
|
+
border_bottom_left_radius=corners[2],
|
|
94
|
+
border_bottom_right_radius=corners[3])
|
|
95
|
+
|
|
96
|
+
def _get_copy_replacement_map(self) -> dict[str, Any]:
|
|
97
|
+
return {
|
|
98
|
+
"width": self.get_width(),
|
|
99
|
+
"height": self.get_height(),
|
|
100
|
+
"color": self.get_color(),
|
|
101
|
+
"corners_radius": self.get_corners_radius()
|
|
102
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from ._text import Text
|
|
2
|
+
from ._button import Button
|
|
3
|
+
from ._drag_drop import DragDrop
|
|
4
|
+
from ._drawing_area import DrawingArea
|
|
5
|
+
from ._text_input import TextInput
|
|
6
|
+
from ._window import Window
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Text",
|
|
10
|
+
"Button",
|
|
11
|
+
"DragDrop",
|
|
12
|
+
"DrawingArea",
|
|
13
|
+
"TextInput",
|
|
14
|
+
"Window"
|
|
15
|
+
]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from typing import Any, Callable
|
|
2
|
+
from desklab.containers import (FlexDirection, HorizontalAlignment,
|
|
3
|
+
VerticalAlignment, FlexBox)
|
|
4
|
+
from desklab.areas import ClickableArea
|
|
5
|
+
from desklab.primitives import Color
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Button(FlexBox, ClickableArea):
|
|
9
|
+
|
|
10
|
+
def __init__(self,
|
|
11
|
+
width: int,
|
|
12
|
+
height: int,
|
|
13
|
+
actions: Callable[..., Any] | list[Callable[..., Any]] = [],
|
|
14
|
+
padding: int = 0,
|
|
15
|
+
space_between: int = 0,
|
|
16
|
+
flex_direction: str | FlexDirection = FlexDirection.COLUMN,
|
|
17
|
+
horizontal_alignment: str | HorizontalAlignment = HorizontalAlignment.CENTER,
|
|
18
|
+
vertical_alignment: str | VerticalAlignment = VerticalAlignment.CENTER,
|
|
19
|
+
corners_radius: tuple[int, int, int, int] | int = 0,
|
|
20
|
+
color: Color | tuple[int, ...] | str = "BLACK",
|
|
21
|
+
bounded: bool = True) -> None:
|
|
22
|
+
|
|
23
|
+
super().__init__(width, height, padding,
|
|
24
|
+
space_between, flex_direction,
|
|
25
|
+
horizontal_alignment, vertical_alignment,
|
|
26
|
+
corners_radius, color, bounded)
|
|
27
|
+
self.__actions: list[Callable[..., Any]] = []
|
|
28
|
+
self.add_actions(actions)
|
|
29
|
+
|
|
30
|
+
def __add_click_listener(self):
|
|
31
|
+
if self.is_clicked():
|
|
32
|
+
for action in self.get_actions():
|
|
33
|
+
action()
|
|
34
|
+
|
|
35
|
+
def set_actions(self, actions: Callable[..., Any] | list[Callable[..., Any]]):
|
|
36
|
+
if not isinstance(actions, list):
|
|
37
|
+
actions = [actions]
|
|
38
|
+
self.__actions = actions
|
|
39
|
+
|
|
40
|
+
def add_actions(self, actions: Callable[..., Any] | list[Callable[..., Any]]):
|
|
41
|
+
if isinstance(actions, Callable):
|
|
42
|
+
actions = [actions]
|
|
43
|
+
self.__actions.extend(actions)
|
|
44
|
+
|
|
45
|
+
def get_actions(self) -> list[Callable[..., Any]]:
|
|
46
|
+
return self.__actions.copy()
|
|
47
|
+
|
|
48
|
+
def remove_actions(self, actions: Callable[..., Any] | list[Callable[..., Any]]) -> None:
|
|
49
|
+
if isinstance(actions, Callable):
|
|
50
|
+
actions = [actions]
|
|
51
|
+
for a in actions:
|
|
52
|
+
self.__actions.remove(a)
|
|
53
|
+
|
|
54
|
+
def handle_event(self, *args: Any, **kwargs: Any) -> None:
|
|
55
|
+
super().handle_event(*args, **kwargs)
|
|
56
|
+
self.__add_click_listener()
|
|
57
|
+
|
|
58
|
+
def _get_copy_replacement_map(self) -> dict[str, Any]:
|
|
59
|
+
replace = super()._get_copy_replacement_map()
|
|
60
|
+
replace["actions"] = self.get_actions()
|
|
61
|
+
return replace
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# fmt: off
|
|
2
|
+
from desklab.primitives import Color
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
from desklab.system import Mouse
|
|
5
|
+
from desklab.containers import FlexBox, FlexDirection, HorizontalAlignment, VerticalAlignment
|
|
6
|
+
import os
|
|
7
|
+
os.environ['PYGAME_HIDE_SUPPORT_PROMPT'] = "hide"
|
|
8
|
+
from pygame.event import Event
|
|
9
|
+
# fmt: on
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DragDrop(FlexBox):
|
|
13
|
+
|
|
14
|
+
def __init__(self,
|
|
15
|
+
width: int,
|
|
16
|
+
height: int,
|
|
17
|
+
padding: int = 0,
|
|
18
|
+
space_between: int = 0,
|
|
19
|
+
flex_direction: str | FlexDirection = FlexDirection.COLUMN,
|
|
20
|
+
horizontal_alignment: str | HorizontalAlignment = HorizontalAlignment.CENTER,
|
|
21
|
+
vertical_alignment: str | VerticalAlignment = VerticalAlignment.CENTER,
|
|
22
|
+
corners_radius: tuple[int, int, int, int] | int = 0,
|
|
23
|
+
color: Color | tuple[int, ...] | str = "BLACK",
|
|
24
|
+
bounded: bool = True) -> None:
|
|
25
|
+
|
|
26
|
+
self.__files: list[str] = []
|
|
27
|
+
super().__init__(width, height, padding, space_between, flex_direction,
|
|
28
|
+
horizontal_alignment, vertical_alignment, corners_radius,
|
|
29
|
+
color, bounded)
|
|
30
|
+
|
|
31
|
+
def handle_event(self, *args: Any, **kwargs: Any) -> None:
|
|
32
|
+
super().handle_event(*args, **kwargs)
|
|
33
|
+
mouse = self._get_from_kwargs(Mouse, kwargs)
|
|
34
|
+
event = self._get_from_kwargs(Event, kwargs, _raise=False)
|
|
35
|
+
if event:
|
|
36
|
+
self.__add_file_drop_listener(event, mouse)
|
|
37
|
+
|
|
38
|
+
def __add_file_drop_listener(self, event: Event, mouse: Mouse) -> None:
|
|
39
|
+
if mouse.is_dropping_file() and self.contains(mouse.get_position()):
|
|
40
|
+
file_path = event.file
|
|
41
|
+
self.__files.append(file_path)
|
|
42
|
+
|
|
43
|
+
def get_files(self) -> list[str]:
|
|
44
|
+
return self.__files.copy()
|
|
45
|
+
|
|
46
|
+
def pop_file(self) -> Optional[str]:
|
|
47
|
+
if not self.__files:
|
|
48
|
+
return None
|
|
49
|
+
return self.__files.pop()
|
|
50
|
+
|
|
51
|
+
def clear_files(self) -> None:
|
|
52
|
+
self.__files.clear()
|
|
53
|
+
|
|
54
|
+
def has_files(self) -> bool:
|
|
55
|
+
return len(self.__files) > 0
|
|
56
|
+
|
|
57
|
+
def count_files(self) -> int:
|
|
58
|
+
return len(self.__files)
|