dokibox 1.0.0__tar.gz
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.
- dokibox-1.0.0/PKG-INFO +150 -0
- dokibox-1.0.0/README.md +130 -0
- dokibox-1.0.0/dokibox/__init__.py +7 -0
- dokibox-1.0.0/dokibox/_base.py +150 -0
- dokibox-1.0.0/dokibox/choicebox.py +264 -0
- dokibox-1.0.0/dokibox/dialogbox.py +507 -0
- dokibox-1.0.0/dokibox/garbled.py +67 -0
- dokibox-1.0.0/dokibox/msgbox.py +161 -0
- dokibox-1.0.0/dokibox/ynbox.py +214 -0
- dokibox-1.0.0/dokibox.egg-info/PKG-INFO +150 -0
- dokibox-1.0.0/dokibox.egg-info/SOURCES.txt +13 -0
- dokibox-1.0.0/dokibox.egg-info/dependency_links.txt +1 -0
- dokibox-1.0.0/dokibox.egg-info/top_level.txt +1 -0
- dokibox-1.0.0/pyproject.toml +35 -0
- dokibox-1.0.0/setup.cfg +4 -0
dokibox-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dokibox
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A fan-made Python library for Doki Doki Literature Club!, providing DDLC-style character dialogs, choice menus, and typewriter effects. Only support Windows system.
|
|
5
|
+
Author-email: windows99-hue <ninezkh@qq.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.7
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
17
|
+
Classifier: Topic :: Software Development :: User Interfaces
|
|
18
|
+
Requires-Python: >=3.7
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# dokibox
|
|
22
|
+
|
|
23
|
+
A fan-made Python library for *Doki Doki Literature Club*, implementing various in-game prompt boxes using `tkinter`.
|
|
24
|
+
|
|
25
|
+
> ### ⚠️ Credit & IP Guidelines
|
|
26
|
+
> `dokibox` is a fan derivative work based on *Doki Doki Literature Club* (DDLC) and is not officially affiliated with Team Salvato. The original game can be downloaded [here](https://ddlc.moe/) or from the [Steam store page](https://store.steampowered.com/app/698780/Doki_Doki_Literature_Club/).
|
|
27
|
+
>
|
|
28
|
+
> Any user creating derivative works with this library **must** strictly follow the [official IP guidelines](https://teamsalvato.com/ip-guidelines).
|
|
29
|
+
>
|
|
30
|
+
> Special thanks to Joseph from Team Salvato for his support and responses via email!
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
Install from PyPI:
|
|
35
|
+
|
|
36
|
+
~~~bash
|
|
37
|
+
pip install dokibox
|
|
38
|
+
~~~
|
|
39
|
+
|
|
40
|
+
## Import
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
import dokibox
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
### ynbox — Yes/No Dialog
|
|
49
|
+
|
|
50
|
+
<img width="2560" height="1392" alt="image" src="https://github.com/user-attachments/assets/d40c9881-14bd-4876-877c-1d2008805c06" />
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
dokibox.ynbox(msg="Delete?", tooltip=False) → bool
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
| Parameter | Type | Default | Description |
|
|
57
|
+
|-----------|------|---------|-------------|
|
|
58
|
+
| `msg` | str | `""` | Message text |
|
|
59
|
+
| `tooltip` | bool | `False` | Tooltip text |
|
|
60
|
+
| `btn_texts` | tuple | None | Confirm button prompt. When set to None, `dokibox` will automatically detect the system language. To modify, please pass in a tuple, replacing "Yes" with the first parameter and "No" with the second parameter. English is used by default when the language is unknown. |
|
|
61
|
+
|
|
62
|
+
Return value: clicking "Yes" returns `True`; clicking "No" or pressing Esc returns `False`.
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
if dokibox.ynbox("Delete?"):
|
|
66
|
+
print("User clicked Yes")
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
### msgbox — Message Box
|
|
72
|
+
|
|
73
|
+
<img width="2560" height="1392" alt="image" src="https://github.com/user-attachments/assets/da5064d3-8d90-47ec-9b9e-20e87faf0fdc" />
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
dokibox.msgbox(msg="Operation successful!", tooltip=False) → True
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
| Parameter | Type | Default | Description |
|
|
80
|
+
|-----------|------|---------|-------------|
|
|
81
|
+
| `msg` | str | `""` | Message text |
|
|
82
|
+
| `tooltip` | bool | `False` | Tooltip text |
|
|
83
|
+
|
|
84
|
+
Single OK button. Closes on click, Enter, or Esc; returns `True`.
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
dokibox.msgbox("Saved successfully!")
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
### choicebox — Multiple Choice Dialog
|
|
93
|
+
|
|
94
|
+
<img width="2560" height="1392" alt="image" src="https://github.com/user-attachments/assets/a33088b1-363b-48f5-8509-44a5d3ef5f4d" />
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
dokibox.choicebox(msg="", choices=None, tooltip=False, force=None) → str | None
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
| Parameter | Type | Default | Description |
|
|
101
|
+
|-----------|------|---------|-------------|
|
|
102
|
+
| `msg` | str | `""` | Prompt text; hidden when empty |
|
|
103
|
+
| `choices` | list | `None` | List of choices |
|
|
104
|
+
| `tooltip` | bool | `False` | Tooltip text |
|
|
105
|
+
| `force` | int | `None` | Force-select an index (0-based); cursor moves to the center of that choice |
|
|
106
|
+
|
|
107
|
+
Return value: the selected text content; Esc returns `None`.
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
char = dokibox.choicebox("Choose a character", ["Sayori", "Yuri", "Natsuki"], force=1)
|
|
111
|
+
print(char) # "Yuri"
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
### dialogbox — Bottom Dialog Box
|
|
117
|
+
|
|
118
|
+
<img width="2560" height="1392" alt="image" src="https://github.com/user-attachments/assets/3817a140-66eb-496d-988d-7b728289a1ca" />
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
dokibox.dialogbox(msg="", w=None, h=220, name=None, typewriter=True, speed=50, bold=False)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
| Parameter | Type | Default | Description |
|
|
125
|
+
|-----------|------|---------|-------------|
|
|
126
|
+
| `msg` | str | `""` | Message text |
|
|
127
|
+
| `w` | int | `None` | Width (default 60% of screen width) |
|
|
128
|
+
| `h` | int | `220` | Height |
|
|
129
|
+
| `name` | str | `None` | Character name tag (white rounded label protruding above the dialog) |
|
|
130
|
+
| `typewriter` | bool | `True` | Typewriter mode |
|
|
131
|
+
| `chardelay` | int | `50` | Typewriter interval per character (ms) |
|
|
132
|
+
| `bold` | bool | `False` | Bold black stroke on text |
|
|
133
|
+
| `overflow_mode` | string | `wrap` | Has three parameters: `wrap`, `overflow`, and `hide`. `wrap` enables automatic line wrapping, `overflow` causes content to overflow the screen, and `hide` hides the content that goes off-screen.|
|
|
134
|
+
|
|
135
|
+
In typewriter mode:
|
|
136
|
+
- Text appears character by character
|
|
137
|
+
- First click → reveals full text instantly
|
|
138
|
+
- Second click → closes
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
dokibox.dialogbox("You're actually Administrator, right?", name="Monika")
|
|
142
|
+
dokibox.dialogbox("Slower...", speed=80, bold=True)
|
|
143
|
+
dokibox.dialogbox("All at once", typewriter=False)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Finally
|
|
147
|
+
|
|
148
|
+
This project is licensed under the `MIT` license. When using it, please **strictly** comply with Team Salvato's relevant fan work IP creation guidelines.
|
|
149
|
+
|
|
150
|
+
> Sayori is my favorite!🎀
|
dokibox-1.0.0/README.md
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# dokibox
|
|
2
|
+
|
|
3
|
+
A fan-made Python library for *Doki Doki Literature Club*, implementing various in-game prompt boxes using `tkinter`.
|
|
4
|
+
|
|
5
|
+
> ### ⚠️ Credit & IP Guidelines
|
|
6
|
+
> `dokibox` is a fan derivative work based on *Doki Doki Literature Club* (DDLC) and is not officially affiliated with Team Salvato. The original game can be downloaded [here](https://ddlc.moe/) or from the [Steam store page](https://store.steampowered.com/app/698780/Doki_Doki_Literature_Club/).
|
|
7
|
+
>
|
|
8
|
+
> Any user creating derivative works with this library **must** strictly follow the [official IP guidelines](https://teamsalvato.com/ip-guidelines).
|
|
9
|
+
>
|
|
10
|
+
> Special thanks to Joseph from Team Salvato for his support and responses via email!
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
Install from PyPI:
|
|
15
|
+
|
|
16
|
+
~~~bash
|
|
17
|
+
pip install dokibox
|
|
18
|
+
~~~
|
|
19
|
+
|
|
20
|
+
## Import
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
import dokibox
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
### ynbox — Yes/No Dialog
|
|
29
|
+
|
|
30
|
+
<img width="2560" height="1392" alt="image" src="https://github.com/user-attachments/assets/d40c9881-14bd-4876-877c-1d2008805c06" />
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
dokibox.ynbox(msg="Delete?", tooltip=False) → bool
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
| Parameter | Type | Default | Description |
|
|
37
|
+
|-----------|------|---------|-------------|
|
|
38
|
+
| `msg` | str | `""` | Message text |
|
|
39
|
+
| `tooltip` | bool | `False` | Tooltip text |
|
|
40
|
+
| `btn_texts` | tuple | None | Confirm button prompt. When set to None, `dokibox` will automatically detect the system language. To modify, please pass in a tuple, replacing "Yes" with the first parameter and "No" with the second parameter. English is used by default when the language is unknown. |
|
|
41
|
+
|
|
42
|
+
Return value: clicking "Yes" returns `True`; clicking "No" or pressing Esc returns `False`.
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
if dokibox.ynbox("Delete?"):
|
|
46
|
+
print("User clicked Yes")
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
### msgbox — Message Box
|
|
52
|
+
|
|
53
|
+
<img width="2560" height="1392" alt="image" src="https://github.com/user-attachments/assets/da5064d3-8d90-47ec-9b9e-20e87faf0fdc" />
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
dokibox.msgbox(msg="Operation successful!", tooltip=False) → True
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
| Parameter | Type | Default | Description |
|
|
60
|
+
|-----------|------|---------|-------------|
|
|
61
|
+
| `msg` | str | `""` | Message text |
|
|
62
|
+
| `tooltip` | bool | `False` | Tooltip text |
|
|
63
|
+
|
|
64
|
+
Single OK button. Closes on click, Enter, or Esc; returns `True`.
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
dokibox.msgbox("Saved successfully!")
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
### choicebox — Multiple Choice Dialog
|
|
73
|
+
|
|
74
|
+
<img width="2560" height="1392" alt="image" src="https://github.com/user-attachments/assets/a33088b1-363b-48f5-8509-44a5d3ef5f4d" />
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
dokibox.choicebox(msg="", choices=None, tooltip=False, force=None) → str | None
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
| Parameter | Type | Default | Description |
|
|
81
|
+
|-----------|------|---------|-------------|
|
|
82
|
+
| `msg` | str | `""` | Prompt text; hidden when empty |
|
|
83
|
+
| `choices` | list | `None` | List of choices |
|
|
84
|
+
| `tooltip` | bool | `False` | Tooltip text |
|
|
85
|
+
| `force` | int | `None` | Force-select an index (0-based); cursor moves to the center of that choice |
|
|
86
|
+
|
|
87
|
+
Return value: the selected text content; Esc returns `None`.
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
char = dokibox.choicebox("Choose a character", ["Sayori", "Yuri", "Natsuki"], force=1)
|
|
91
|
+
print(char) # "Yuri"
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
### dialogbox — Bottom Dialog Box
|
|
97
|
+
|
|
98
|
+
<img width="2560" height="1392" alt="image" src="https://github.com/user-attachments/assets/3817a140-66eb-496d-988d-7b728289a1ca" />
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
dokibox.dialogbox(msg="", w=None, h=220, name=None, typewriter=True, speed=50, bold=False)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
| Parameter | Type | Default | Description |
|
|
105
|
+
|-----------|------|---------|-------------|
|
|
106
|
+
| `msg` | str | `""` | Message text |
|
|
107
|
+
| `w` | int | `None` | Width (default 60% of screen width) |
|
|
108
|
+
| `h` | int | `220` | Height |
|
|
109
|
+
| `name` | str | `None` | Character name tag (white rounded label protruding above the dialog) |
|
|
110
|
+
| `typewriter` | bool | `True` | Typewriter mode |
|
|
111
|
+
| `chardelay` | int | `50` | Typewriter interval per character (ms) |
|
|
112
|
+
| `bold` | bool | `False` | Bold black stroke on text |
|
|
113
|
+
| `overflow_mode` | string | `wrap` | Has three parameters: `wrap`, `overflow`, and `hide`. `wrap` enables automatic line wrapping, `overflow` causes content to overflow the screen, and `hide` hides the content that goes off-screen.|
|
|
114
|
+
|
|
115
|
+
In typewriter mode:
|
|
116
|
+
- Text appears character by character
|
|
117
|
+
- First click → reveals full text instantly
|
|
118
|
+
- Second click → closes
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
dokibox.dialogbox("You're actually Administrator, right?", name="Monika")
|
|
122
|
+
dokibox.dialogbox("Slower...", speed=80, bold=True)
|
|
123
|
+
dokibox.dialogbox("All at once", typewriter=False)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Finally
|
|
127
|
+
|
|
128
|
+
This project is licensed under the `MIT` license. When using it, please **strictly** comply with Team Salvato's relevant fan work IP creation guidelines.
|
|
129
|
+
|
|
130
|
+
> Sayori is my favorite!🎀
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""dokibox -- DDLC-style dialog library made by 99"""
|
|
3
|
+
from dokibox.ynbox import ynbox
|
|
4
|
+
from dokibox.choicebox import choicebox
|
|
5
|
+
from dokibox.msgbox import msgbox
|
|
6
|
+
from dokibox.dialogbox import dialogbox
|
|
7
|
+
from dokibox.garbled import garbled
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""dokibox internal base class -- window / gradient border / stroked text / dragging"""
|
|
3
|
+
import tkinter as tk
|
|
4
|
+
import math
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# --- default colors ---
|
|
8
|
+
BORDER_COLOR = "#FFBBE3"
|
|
9
|
+
BODY_COLOR = "#FEE6F4"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _get_dpi_scale():
|
|
13
|
+
try:
|
|
14
|
+
import ctypes
|
|
15
|
+
hdc = ctypes.windll.user32.GetDC(0)
|
|
16
|
+
dpi = ctypes.windll.gdi32.GetDeviceCaps(hdc, 88)
|
|
17
|
+
ctypes.windll.user32.ReleaseDC(0, hdc)
|
|
18
|
+
return dpi / 96.0
|
|
19
|
+
except Exception:
|
|
20
|
+
return 1.0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# --- shared Tk root (NEVER destroyed, for Python 3.9 compat) ---
|
|
24
|
+
_root_instance = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _get_root():
|
|
28
|
+
global _root_instance
|
|
29
|
+
if _root_instance is None:
|
|
30
|
+
_root_instance = tk.Tk()
|
|
31
|
+
_root_instance.withdraw()
|
|
32
|
+
return _root_instance
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _DokiBase:
|
|
36
|
+
"""Dialog base class. Subclasses only need to implement _calc_size / _draw_content / _on_click"""
|
|
37
|
+
|
|
38
|
+
BORDER_W = 12
|
|
39
|
+
|
|
40
|
+
def __init__(self, msg, title="", pinned=True):
|
|
41
|
+
self.result = None
|
|
42
|
+
self._px = 0
|
|
43
|
+
self._py = 0
|
|
44
|
+
self._ox = 0
|
|
45
|
+
self._oy = 0
|
|
46
|
+
|
|
47
|
+
self.root = tk.Toplevel(_get_root())
|
|
48
|
+
self.root.overrideredirect(True)
|
|
49
|
+
self.root.attributes('-topmost', pinned)
|
|
50
|
+
|
|
51
|
+
self.w, self.h = self._calc_size(msg)
|
|
52
|
+
sw = self.root.winfo_screenwidth()
|
|
53
|
+
sh = self.root.winfo_screenheight()
|
|
54
|
+
x = (sw - self.w) // 2
|
|
55
|
+
y = (sh - self.h) // 2
|
|
56
|
+
self.root.geometry(f"{self.w}x{self.h}+{x}+{y}")
|
|
57
|
+
|
|
58
|
+
self.cv = tk.Canvas(
|
|
59
|
+
self.root, width=self.w, height=self.h,
|
|
60
|
+
bg=BODY_COLOR, highlightthickness=0
|
|
61
|
+
)
|
|
62
|
+
self.cv.pack()
|
|
63
|
+
|
|
64
|
+
self._draw_gradient_border()
|
|
65
|
+
self._draw_content(msg)
|
|
66
|
+
|
|
67
|
+
self.root.bind("<Escape>", lambda e: self._done(False))
|
|
68
|
+
self._make_draggable()
|
|
69
|
+
self.root.focus_force()
|
|
70
|
+
|
|
71
|
+
# ========== subclass must implement ==========
|
|
72
|
+
|
|
73
|
+
def _calc_size(self, msg):
|
|
74
|
+
raise NotImplementedError
|
|
75
|
+
|
|
76
|
+
def _draw_content(self, msg):
|
|
77
|
+
raise NotImplementedError
|
|
78
|
+
|
|
79
|
+
def _on_click(self, event):
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
# ========== shared drawing utilities ==========
|
|
83
|
+
|
|
84
|
+
@staticmethod
|
|
85
|
+
def _hex_to_rgb(hex_color):
|
|
86
|
+
h = hex_color.lstrip('#')
|
|
87
|
+
return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))
|
|
88
|
+
|
|
89
|
+
def _draw_gradient_border(self):
|
|
90
|
+
br, bg, bb = self._hex_to_rgb(BORDER_COLOR)
|
|
91
|
+
er, eg, eb = self._hex_to_rgb(BODY_COLOR)
|
|
92
|
+
bw = self.BORDER_W
|
|
93
|
+
for i in range(bw):
|
|
94
|
+
t = (i / max(bw - 1, 1)) ** 3
|
|
95
|
+
r = int(br + (er - br) * t)
|
|
96
|
+
g = int(bg + (eg - bg) * t)
|
|
97
|
+
b = int(bb + (eb - bb) * t)
|
|
98
|
+
color = f'#{r:02x}{g:02x}{b:02x}'
|
|
99
|
+
self.cv.create_rectangle(i, i, self.w - i, self.h - i,
|
|
100
|
+
outline=color, width=1)
|
|
101
|
+
|
|
102
|
+
def _draw_stroked_text(self, x, y, text, font, fill, stroke, stroke_w):
|
|
103
|
+
for step in range(24):
|
|
104
|
+
angle = 2 * math.pi * step / 24
|
|
105
|
+
dx = stroke_w * math.cos(angle)
|
|
106
|
+
dy = stroke_w * math.sin(angle)
|
|
107
|
+
self.cv.create_text(x + dx, y + dy, text=text,
|
|
108
|
+
font=font, fill=stroke, anchor="center")
|
|
109
|
+
self.cv.create_text(x, y, text=text, font=font,
|
|
110
|
+
fill=fill, anchor="center")
|
|
111
|
+
|
|
112
|
+
# ========== dragging ==========
|
|
113
|
+
|
|
114
|
+
def _make_draggable(self):
|
|
115
|
+
self.cv.bind("<ButtonPress-1>", self._on_press, add='+')
|
|
116
|
+
self.cv.bind("<B1-Motion>", self._on_motion, add='+')
|
|
117
|
+
self.cv.bind("<ButtonRelease-1>", self._on_release, add='+')
|
|
118
|
+
|
|
119
|
+
def _on_press(self, event):
|
|
120
|
+
self._px = event.x_root
|
|
121
|
+
self._py = event.y_root
|
|
122
|
+
self._ox = event.x_root - self.root.winfo_x()
|
|
123
|
+
self._oy = event.y_root - self.root.winfo_y()
|
|
124
|
+
|
|
125
|
+
def _on_motion(self, event):
|
|
126
|
+
new_x = event.x_root - self._ox
|
|
127
|
+
new_y = event.y_root - self._oy
|
|
128
|
+
self.root.geometry(f"+{new_x}+{new_y}")
|
|
129
|
+
|
|
130
|
+
def _on_release(self, event):
|
|
131
|
+
dx = abs(event.x_root - self._px)
|
|
132
|
+
dy = abs(event.y_root - self._py)
|
|
133
|
+
if dx < 5 and dy < 5:
|
|
134
|
+
self._on_click(event)
|
|
135
|
+
|
|
136
|
+
# ========== lifecycle ==========
|
|
137
|
+
|
|
138
|
+
def _done(self, value):
|
|
139
|
+
self.result = value
|
|
140
|
+
try:
|
|
141
|
+
self.root.destroy()
|
|
142
|
+
_get_root().quit()
|
|
143
|
+
except tk.TclError:
|
|
144
|
+
pass
|
|
145
|
+
|
|
146
|
+
@classmethod
|
|
147
|
+
def show(cls, *args, **kwargs):
|
|
148
|
+
dialog = cls(*args, **kwargs)
|
|
149
|
+
_get_root().mainloop()
|
|
150
|
+
return dialog.result
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""dokibox.choicebox -- DDLC-style multi-choice dialog (floating windows per option)"""
|
|
3
|
+
import tkinter as tk
|
|
4
|
+
import tkinter.font as tkfont
|
|
5
|
+
from typing import Optional, List
|
|
6
|
+
from dokibox._base import _get_root
|
|
7
|
+
|
|
8
|
+
BORDER_COLOR = "#FFBBE3"
|
|
9
|
+
BODY_COLOR = "#FEE6F4"
|
|
10
|
+
OPT_FILL_COLOR = "#000000"
|
|
11
|
+
OPT_HOVER_COLOR = "#999999"
|
|
12
|
+
|
|
13
|
+
BORDER_W = 12
|
|
14
|
+
OPT_FONT_SIZE = 24
|
|
15
|
+
OPT_PAD_X = 80
|
|
16
|
+
OPT_PAD_Y = 4
|
|
17
|
+
OPT_GAP = 40
|
|
18
|
+
UNIFIED_MIN_W = 600
|
|
19
|
+
MSG_FONT_SIZE = 20
|
|
20
|
+
MSG_PAD_Y = 16
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _hex_to_rgb(hex_color):
|
|
24
|
+
h = hex_color.lstrip('#')
|
|
25
|
+
return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _Panel:
|
|
29
|
+
|
|
30
|
+
def __init__(self, master, text, index, pw, on_select, tooltip=False, pinned=True):
|
|
31
|
+
self.index = index
|
|
32
|
+
self.text = text
|
|
33
|
+
self._on_select = on_select
|
|
34
|
+
self._tooltip = tooltip
|
|
35
|
+
|
|
36
|
+
self.win = tk.Toplevel(master)
|
|
37
|
+
self.win.overrideredirect(True)
|
|
38
|
+
self.win.attributes('-topmost', pinned)
|
|
39
|
+
|
|
40
|
+
f_opt = tkfont.Font(family="Microsoft YaHei", size=OPT_FONT_SIZE, weight="normal")
|
|
41
|
+
th = f_opt.metrics('linespace')
|
|
42
|
+
self._font = ("Microsoft YaHei", OPT_FONT_SIZE, "normal")
|
|
43
|
+
|
|
44
|
+
self.pw = int(pw)
|
|
45
|
+
self.ph = int(th + OPT_PAD_Y * 2 + BORDER_W * 2)
|
|
46
|
+
|
|
47
|
+
self.win.geometry(f"{self.pw}x{self.ph}")
|
|
48
|
+
|
|
49
|
+
self.cv = tk.Canvas(self.win, width=self.pw, height=self.ph,
|
|
50
|
+
bg=BODY_COLOR, highlightthickness=0)
|
|
51
|
+
self.cv.pack()
|
|
52
|
+
|
|
53
|
+
self._draw_gradient_border()
|
|
54
|
+
self._draw_option(text)
|
|
55
|
+
|
|
56
|
+
self.cv.bind("<Enter>", lambda e: self._set_hover(True))
|
|
57
|
+
self.cv.bind("<Leave>", lambda e: self._set_hover(False))
|
|
58
|
+
self.cv.bind("<Button-1>", lambda e: self._on_select(self.index))
|
|
59
|
+
|
|
60
|
+
if self._tooltip:
|
|
61
|
+
self._add_tooltip()
|
|
62
|
+
|
|
63
|
+
def _add_tooltip(self):
|
|
64
|
+
tip = [None]
|
|
65
|
+
|
|
66
|
+
def show(event):
|
|
67
|
+
if tip[0]:
|
|
68
|
+
return
|
|
69
|
+
tw = tk.Toplevel(self.win)
|
|
70
|
+
tw.overrideredirect(True)
|
|
71
|
+
tw.attributes('-topmost', True)
|
|
72
|
+
label = tk.Label(tw, text=self.text, bg=BODY_COLOR, fg='#000000',
|
|
73
|
+
font=("Microsoft YaHei", 12),
|
|
74
|
+
relief='solid', bd=1, padx=6, pady=2)
|
|
75
|
+
label.pack()
|
|
76
|
+
x = event.x_root + 15
|
|
77
|
+
y = event.y_root + 15
|
|
78
|
+
tw.geometry(f"+{x}+{y}")
|
|
79
|
+
tip[0] = tw
|
|
80
|
+
|
|
81
|
+
def hide(event):
|
|
82
|
+
if tip[0]:
|
|
83
|
+
tip[0].destroy()
|
|
84
|
+
tip[0] = None
|
|
85
|
+
|
|
86
|
+
self.cv.bind("<Enter>", show, add='+')
|
|
87
|
+
self.cv.bind("<Leave>", hide, add='+')
|
|
88
|
+
|
|
89
|
+
def _draw_gradient_border(self):
|
|
90
|
+
br, bg, bb = _hex_to_rgb(BORDER_COLOR)
|
|
91
|
+
er, eg, eb = _hex_to_rgb(BODY_COLOR)
|
|
92
|
+
bw = BORDER_W
|
|
93
|
+
for i in range(bw):
|
|
94
|
+
t = (i / max(bw - 1, 1)) ** 3
|
|
95
|
+
r = int(br + (er - br) * t)
|
|
96
|
+
g = int(bg + (eg - bg) * t)
|
|
97
|
+
b = int(bb + (eb - bb) * t)
|
|
98
|
+
color = f'#{r:02x}{g:02x}{b:02x}'
|
|
99
|
+
self.cv.create_rectangle(i, i, self.pw - i, self.ph - i,
|
|
100
|
+
outline=color, width=1)
|
|
101
|
+
|
|
102
|
+
def _draw_option(self, text):
|
|
103
|
+
cx = self.pw // 2
|
|
104
|
+
cy = self.ph // 2
|
|
105
|
+
self.cv.create_text(cx, cy, text=text, font=self._font,
|
|
106
|
+
fill=OPT_FILL_COLOR, anchor="center",
|
|
107
|
+
tags=("opt", "opt_fill"))
|
|
108
|
+
|
|
109
|
+
def _set_hover(self, hover):
|
|
110
|
+
items = self.cv.find_withtag("opt_fill")
|
|
111
|
+
color = OPT_HOVER_COLOR if hover else OPT_FILL_COLOR
|
|
112
|
+
for item in items:
|
|
113
|
+
self.cv.itemconfig(item, fill=color)
|
|
114
|
+
|
|
115
|
+
def set_position(self, x, y):
|
|
116
|
+
self.win.geometry(f"+{x}+{y}")
|
|
117
|
+
|
|
118
|
+
def destroy(self):
|
|
119
|
+
try:
|
|
120
|
+
self.win.destroy()
|
|
121
|
+
except tk.TclError:
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class _ChoiceManager:
|
|
126
|
+
|
|
127
|
+
def __init__(self, msg, choices, title, tooltip=False, force=None, pinned=True):
|
|
128
|
+
self.result = None
|
|
129
|
+
self._tooltip = tooltip
|
|
130
|
+
self._force = force
|
|
131
|
+
self._pinned = pinned
|
|
132
|
+
self.root = _get_root()
|
|
133
|
+
|
|
134
|
+
f_opt = tkfont.Font(family="Microsoft YaHei", size=OPT_FONT_SIZE, weight="normal")
|
|
135
|
+
opt_widths = [f_opt.measure(c) for c in choices]
|
|
136
|
+
max_opt_w = max(opt_widths) if opt_widths else 0
|
|
137
|
+
unified_w = max(int(max_opt_w + OPT_PAD_X * 2 + BORDER_W * 2), UNIFIED_MIN_W)
|
|
138
|
+
screen_w = self.root.winfo_screenwidth()
|
|
139
|
+
unified_w = min(unified_w, screen_w - BORDER_W * 2)
|
|
140
|
+
self._unified_w = unified_w
|
|
141
|
+
|
|
142
|
+
self._panels = []
|
|
143
|
+
for i, choice in enumerate(choices):
|
|
144
|
+
panel = _Panel(self.root, choice, i, unified_w, self._on_select, self._tooltip, pinned=pinned)
|
|
145
|
+
self._panels.append(panel)
|
|
146
|
+
|
|
147
|
+
if msg.strip():
|
|
148
|
+
self._create_msg_label(msg)
|
|
149
|
+
|
|
150
|
+
self._layout(msg)
|
|
151
|
+
|
|
152
|
+
if force is not None and 0 <= force < len(choices):
|
|
153
|
+
self._force_index = force
|
|
154
|
+
p = self._panels[force]
|
|
155
|
+
cx = p.win.winfo_x() + p.pw // 2
|
|
156
|
+
cy = p.win.winfo_y() + p.ph // 2
|
|
157
|
+
self.root.after(50, lambda: p.win.event_generate(
|
|
158
|
+
'<Motion>', warp=True, x=p.pw // 2, y=p.ph // 2))
|
|
159
|
+
|
|
160
|
+
def _on_select(self, index):
|
|
161
|
+
self.result = index
|
|
162
|
+
for p in self._panels:
|
|
163
|
+
p.destroy()
|
|
164
|
+
if hasattr(self, '_msg_win'):
|
|
165
|
+
try:
|
|
166
|
+
self._msg_win.destroy()
|
|
167
|
+
except tk.TclError:
|
|
168
|
+
pass
|
|
169
|
+
try:
|
|
170
|
+
_get_root().quit()
|
|
171
|
+
except tk.TclError:
|
|
172
|
+
pass
|
|
173
|
+
|
|
174
|
+
def _create_msg_label(self, msg):
|
|
175
|
+
f = tkfont.Font(family="Microsoft YaHei", size=MSG_FONT_SIZE, weight="normal")
|
|
176
|
+
max_lbl_w = max(self._unified_w - 40, 200)
|
|
177
|
+
|
|
178
|
+
raw_lines = msg.split('\n')
|
|
179
|
+
wrapped_lines = []
|
|
180
|
+
for line in raw_lines:
|
|
181
|
+
if f.measure(line) <= max_lbl_w:
|
|
182
|
+
wrapped_lines.append(line)
|
|
183
|
+
else:
|
|
184
|
+
current = ""
|
|
185
|
+
for ch in line:
|
|
186
|
+
test = current + ch
|
|
187
|
+
if f.measure(test) <= max_lbl_w:
|
|
188
|
+
current = test
|
|
189
|
+
else:
|
|
190
|
+
if current:
|
|
191
|
+
wrapped_lines.append(current)
|
|
192
|
+
current = ch
|
|
193
|
+
if current:
|
|
194
|
+
wrapped_lines.append(current)
|
|
195
|
+
|
|
196
|
+
line_h = f.metrics('linespace')
|
|
197
|
+
total_h = line_h * len(wrapped_lines) + MSG_PAD_Y * 2
|
|
198
|
+
text_w = max(f.measure(line) for line in wrapped_lines)
|
|
199
|
+
|
|
200
|
+
lbl = tk.Toplevel(self.root)
|
|
201
|
+
lbl.overrideredirect(True)
|
|
202
|
+
lbl.attributes('-topmost', self._pinned)
|
|
203
|
+
self._msg_win = lbl
|
|
204
|
+
self._msg_w = max(int(text_w + 40), self._unified_w)
|
|
205
|
+
self._msg_h = int(total_h)
|
|
206
|
+
lbl.geometry(f"{self._msg_w}x{self._msg_h}")
|
|
207
|
+
|
|
208
|
+
cv = tk.Canvas(lbl, width=self._msg_w, height=self._msg_h,
|
|
209
|
+
bg=BODY_COLOR, highlightthickness=0)
|
|
210
|
+
cv.pack()
|
|
211
|
+
cv.create_rectangle(0, 0, self._msg_w, self._msg_h,
|
|
212
|
+
outline=BORDER_COLOR, width=4)
|
|
213
|
+
for j, line in enumerate(wrapped_lines):
|
|
214
|
+
y = MSG_PAD_Y + line_h // 2 + j * line_h
|
|
215
|
+
cv.create_text(self._msg_w // 2, y, text=line,
|
|
216
|
+
font=("Microsoft YaHei", MSG_FONT_SIZE, "normal"),
|
|
217
|
+
fill="#000000", anchor="center")
|
|
218
|
+
|
|
219
|
+
def _layout(self, msg):
|
|
220
|
+
sw = self.root.winfo_screenwidth()
|
|
221
|
+
sh = self.root.winfo_screenheight()
|
|
222
|
+
|
|
223
|
+
if not self._panels:
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
total_h = sum(p.ph for p in self._panels) + OPT_GAP * (len(self._panels) - 1)
|
|
227
|
+
if hasattr(self, '_msg_win'):
|
|
228
|
+
total_h += self._msg_h + OPT_GAP
|
|
229
|
+
|
|
230
|
+
start_y = (sh - total_h) // 2
|
|
231
|
+
|
|
232
|
+
if hasattr(self, '_msg_win'):
|
|
233
|
+
msg_x = (sw - self._msg_w) // 2
|
|
234
|
+
self._msg_win.geometry(f"+{msg_x}+{start_y}")
|
|
235
|
+
start_y += self._msg_h + OPT_GAP
|
|
236
|
+
|
|
237
|
+
for panel in self._panels:
|
|
238
|
+
px = (sw - panel.pw) // 2
|
|
239
|
+
panel.set_position(px, start_y)
|
|
240
|
+
start_y += panel.ph + OPT_GAP
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def choicebox(msg: str = "", choices: Optional[List[str]] = None, title: str = "", tooltip: bool = False, force: Optional[int] = None, pinned: bool = True) -> Optional[str]:
|
|
244
|
+
"""DDLC-style multi-choice dialog. Each option is a floating window. Returns the selected text, or None if cancelled.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
msg: prompt text displayed above the options. No label shown if empty.
|
|
248
|
+
choices: list of option strings to display.
|
|
249
|
+
title: window title (unused in borderless mode).
|
|
250
|
+
tooltip: show a floating tooltip when hovering over an option.
|
|
251
|
+
force: pre-select an option by index (0-based). The mouse warps to its center.
|
|
252
|
+
pinned: keep the windows always on top of other windows.
|
|
253
|
+
|
|
254
|
+
Usage:
|
|
255
|
+
import dokibox
|
|
256
|
+
text = dokibox.choicebox("Choose a character", ["Sayori", "Yuri", "Natsuki"], force=1)
|
|
257
|
+
"""
|
|
258
|
+
from dokibox.dialogbox import _destroy_box
|
|
259
|
+
_destroy_box()
|
|
260
|
+
if not choices:
|
|
261
|
+
return None
|
|
262
|
+
mgr = _ChoiceManager(msg, choices, title, tooltip, force, pinned=pinned)
|
|
263
|
+
_get_root().mainloop()
|
|
264
|
+
return choices[mgr.result] if mgr.result is not None else None
|