Coverage for tadkit / utils / ui.py: 95%
75 statements
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-03 15:41 +0000
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-03 15:41 +0000
1import json
4# =====================================================
5# 1. Value Sanitization Utility
6# =====================================================
7def sanitize_default(default, min_val, max_val, ptype, closed="both", allow_none=False):
8 """Clamp and adjust defaults based on type and bounds."""
10 def cast(x):
11 try:
12 return None if x is None else ptype(x)
13 except (ValueError, TypeError):
14 return None
16 d, lo, hi = map(cast, (default, min_val, max_val))
17 if allow_none and d is None:
18 return None
19 eps = 1e-12 if ptype is float else 1
21 if d is None:
22 # no default, fall back to min or type zero
23 if lo is not None:
24 return lo + eps if closed in ("right", "neither") else lo
25 return 0 if ptype is int else 0.0
27 # Clamp lower bound
28 if lo is not None:
29 if closed in ("right", "neither") and d <= lo:
30 d = lo + eps
31 elif closed in ("both", "left") and d < lo:
32 d = lo
34 # Clamp upper bound
35 if hi is not None:
36 if closed in ("left", "neither") and d >= hi:
37 d = hi - eps
38 elif closed in ("both", "right") and d > hi:
39 d = hi
41 return d
44# =====================================================
45# 2. Widget Factory
46# =====================================================
47class WidgetFactory:
48 """Factory for creating UI widgets or no-UI representations."""
50 WIDGET_STYLE = {"description_width": "initial"}
52 def __init__(self, frontend="ipywidgets"):
53 self.frontend = frontend
54 self._import_ui_libs()
56 def _import_ui_libs(self):
57 """Lazily import UI libraries."""
58 if self.frontend == "st":
59 import streamlit as st
61 self.st = st
62 elif self.frontend == "ipywidgets":
63 import ipywidgets as widgets
65 self.widgets = widgets
67 # -------------------------------------------------
68 # Generic dispatcher for UI elements
69 # -------------------------------------------------
70 def _dispatch(self, mapping, **kwargs):
71 func = mapping.get(self.frontend)
72 if func is None:
73 raise ValueError(f"Unsupported frontend: {self.frontend}")
74 return func(**kwargs)
76 # -------------------------------------------------
77 # Numeric input
78 # -------------------------------------------------
79 def make_numeric(
80 self,
81 label,
82 default,
83 min_val,
84 max_val,
85 ptype,
86 description,
87 closed="both",
88 allow_none=False,
89 ):
90 """Create a numeric input widget."""
91 # Sanitize default
92 if not (allow_none and default is None):
93 default = sanitize_default(
94 default, min_val, max_val, ptype, closed, allow_none
95 )
97 # Streamlit
98 def _st_numeric(**kw):
99 st = self.st
100 step = 1 if ptype is int else 0.01
101 fmt = "%d" if ptype is int else "%.6f"
103 def cast(x):
104 return None if x is None else ptype(x)
106 args = {
107 "label": label,
108 "value": cast(default),
109 "step": cast(step),
110 "format": fmt,
111 "help": description,
112 }
113 if min_val is not None:
114 args["min_value"] = cast(min_val)
115 if max_val is not None:
116 args["max_value"] = cast(max_val)
117 return st.number_input(**args)
119 # ipywidgets
120 def _ipy_numeric(**kw):
121 w = self.widgets
122 cls = w.BoundedIntText if ptype is int else w.BoundedFloatText
123 return cls(
124 value=default,
125 min=min_val if min_val is not None else -1e6,
126 max=max_val if max_val is not None else 1e6,
127 description=label,
128 style=self.WIDGET_STYLE,
129 tooltip=description,
130 )
132 # No UI
133 def _noui_numeric(**kw):
134 return {
135 "type": "number",
136 "label": label,
137 "default": default,
138 "min": min_val,
139 "max": max_val,
140 "description": description,
141 "nullable": allow_none,
142 }
144 return self._dispatch(
145 {
146 "st": _st_numeric,
147 "ipywidgets": _ipy_numeric,
148 "no_ui": _noui_numeric,
149 }
150 )
152 # -------------------------------------------------
153 # Dropdown
154 # -------------------------------------------------
155 def make_dropdown(self, label, options, default, description):
156 """Create a dropdown input."""
157 options = options or ["(none)"]
158 default_val = default if default in options else options[0]
160 return self._dispatch(
161 {
162 "st": lambda **kw: self.st.selectbox(
163 label, options, index=options.index(default_val), help=description
164 ),
165 "ipywidgets": lambda **kw: self.widgets.Dropdown(
166 options=options,
167 value=default_val,
168 description=label,
169 style=self.WIDGET_STYLE,
170 tooltip=description,
171 ),
172 "no_ui": lambda **kw: {
173 "type": "dropdown",
174 "label": label,
175 "options": options,
176 "default": default_val,
177 "description": description,
178 },
179 }
180 )
182 # -------------------------------------------------
183 # Text input
184 # -------------------------------------------------
185 def make_text(self, label, default, description):
186 value = str(default or "")
187 return self._dispatch(
188 {
189 "st": lambda **kw: self.st.text_input(
190 label, value=value, help=description
191 ),
192 "ipywidgets": lambda **kw: self.widgets.Text(
193 value=value,
194 description=label,
195 style=self.WIDGET_STYLE,
196 tooltip=description,
197 ),
198 "no_ui": lambda **kw: {
199 "type": "text",
200 "label": label,
201 "default": default,
202 "description": description,
203 },
204 }
205 )
207 # -------------------------------------------------
208 # Dictionary / JSON input
209 # -------------------------------------------------
210 def make_dict(self, label, default, description):
211 json_str = json.dumps(default or {}, indent=2)
212 return self._dispatch(
213 {
214 "st": lambda **kw: self.st.text_area(
215 label, value=json_str, help=description, height=100
216 ),
217 "ipywidgets": lambda **kw: self.widgets.Textarea(
218 value=json_str,
219 description=label,
220 layout=self.widgets.Layout(width="100%", height="80px"),
221 tooltip=description,
222 ),
223 "no_ui": lambda **kw: {
224 "type": "dict",
225 "label": label,
226 "default": default,
227 "description": description,
228 },
229 }
230 )