dokibox 1.0.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.
dokibox/__init__.py ADDED
@@ -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
dokibox/_base.py ADDED
@@ -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
dokibox/choicebox.py ADDED
@@ -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