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 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!🎀
@@ -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