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

1import json 

2 

3 

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.""" 

9 

10 def cast(x): 

11 try: 

12 return None if x is None else ptype(x) 

13 except (ValueError, TypeError): 

14 return None 

15 

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 

20 

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 

26 

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 

33 

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 

40 

41 return d 

42 

43 

44# ===================================================== 

45# 2. Widget Factory 

46# ===================================================== 

47class WidgetFactory: 

48 """Factory for creating UI widgets or no-UI representations.""" 

49 

50 WIDGET_STYLE = {"description_width": "initial"} 

51 

52 def __init__(self, frontend="ipywidgets"): 

53 self.frontend = frontend 

54 self._import_ui_libs() 

55 

56 def _import_ui_libs(self): 

57 """Lazily import UI libraries.""" 

58 if self.frontend == "st": 

59 import streamlit as st 

60 

61 self.st = st 

62 elif self.frontend == "ipywidgets": 

63 import ipywidgets as widgets 

64 

65 self.widgets = widgets 

66 

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) 

75 

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 ) 

96 

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" 

102 

103 def cast(x): 

104 return None if x is None else ptype(x) 

105 

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) 

118 

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 ) 

131 

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 } 

143 

144 return self._dispatch( 

145 { 

146 "st": _st_numeric, 

147 "ipywidgets": _ipy_numeric, 

148 "no_ui": _noui_numeric, 

149 } 

150 ) 

151 

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] 

159 

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 ) 

181 

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 ) 

206 

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 )