Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

1""" 

2This file contains all of the classes related for a shared server configuration form. 

3""" 

4 

5from dataclasses import dataclass 

6from functools import partial 

7from pathlib import Path 

8from time import sleep 

9from typing import Any, Callable, cast, Dict, Iterable, Optional, Tuple, Type, Union 

10 

11import bleach 

12 

13from gi.repository import GLib, GObject, Gtk, Pango 

14 

15from . import ConfigurationStore 

16 

17 

18@dataclass 

19class ConfigParamDescriptor: 

20 """ 

21 Describes a parameter that can be used to configure an adapter. The 

22 :class:`description`, :class:`required` and :class:`default:` should be self-evident 

23 as to what they do. 

24 

25 The :class:`helptext` parameter is optional detailed text that will be shown in a 

26 help bubble corresponding to the field. 

27 

28 The :class:`type` must be one of the following: 

29 

30 * The literal type ``str``: corresponds to a freeform text entry field in the UI. 

31 * The literal type ``bool``: corresponds to a toggle in the UI. 

32 * The literal type ``int``: corresponds to a numeric input in the UI. 

33 * The literal string ``"password"``: corresponds to a password entry field in the 

34 UI. 

35 * The literal string ``"option"``: corresponds to dropdown in the UI. 

36 * The literal type ``Path``: corresponds to a file picker in the UI. 

37 

38 The :class:`advanced` parameter specifies whether the setting should be behind an 

39 "Advanced" expander. 

40 

41 The :class:`numeric_bounds` parameter only has an effect if the :class:`type` is 

42 `int`. It specifies the min and max values that the UI control can have. 

43 

44 The :class:`numeric_step` parameter only has an effect if the :class:`type` is 

45 `int`. It specifies the step that will be taken using the "+" and "-" buttons on the 

46 UI control (if supported). 

47 

48 The :class:`options` parameter only has an effect if the :class:`type` is 

49 ``"option"``. It specifies the list of options that will be available in the 

50 dropdown in the UI. 

51 

52 The :class:`pathtype` parameter only has an effect if the :class:`type` is 

53 ``Path``. It can be either ``"file"`` or ``"directory"`` corresponding to a file 

54 picker and a directory picker, respectively. 

55 """ 

56 

57 type: Union[Type, str] 

58 description: str 

59 required: bool = True 

60 helptext: Optional[str] = None 

61 advanced: Optional[bool] = None 

62 default: Any = None 

63 numeric_bounds: Optional[Tuple[int, int]] = None 

64 numeric_step: Optional[int] = None 

65 options: Optional[Iterable[str]] = None 

66 pathtype: Optional[str] = None 

67 

68 

69class ConfigureServerForm(Gtk.Box): 

70 __gsignals__ = { 

71 "config-valid-changed": ( 

72 GObject.SignalFlags.RUN_FIRST, 

73 GObject.TYPE_NONE, 

74 (bool,), 

75 ), 

76 } 

77 

78 def __init__( 

79 self, 

80 config_store: ConfigurationStore, 

81 config_parameters: Dict[str, ConfigParamDescriptor], 

82 verify_configuration: Callable[[], Dict[str, Optional[str]]], 

83 is_networked: bool = True, 

84 ): 

85 """ 

86 Inititialize a :class:`ConfigureServerForm` with the given configuration 

87 parameters. 

88 

89 :param config_store: The :class:`ConfigurationStore` to use to store 

90 configuration values for this adapter. 

91 :param config_parameters: An dictionary where the keys are the name of the 

92 configuration paramter and the values are the :class:`ConfigParamDescriptor` 

93 object corresponding to that configuration parameter. The order of the keys 

94 in the dictionary correspond to the order that the configuration parameters 

95 will be shown in the UI. 

96 :param verify_configuration: A function that verifies whether or not the 

97 current state of the ``config_store`` is valid. The output should be a 

98 dictionary containing verification errors. The keys of the returned 

99 dictionary should be the same as the keys passed in via the 

100 ``config_parameters`` parameter. The values should be strings describing 

101 why the corresponding value in the ``config_store`` is invalid. 

102 

103 If the adapter ``is_networked``, and the special ``"__ping__"`` key is 

104 returned, then the error will be shown below all of the other settings in 

105 the ping status box. 

106 :param is_networked: whether or not the adapter is networked. 

107 """ 

108 Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL) 

109 self.config_store = config_store 

110 self.required_config_parameter_keys = set() 

111 self.verify_configuration = verify_configuration 

112 self.entries = {} 

113 self.is_networked = is_networked 

114 

115 content_grid = Gtk.Grid( 

116 column_spacing=10, 

117 row_spacing=5, 

118 margin_left=10, 

119 margin_right=10, 

120 ) 

121 advanced_grid = Gtk.Grid(column_spacing=10, row_spacing=10) 

122 

123 def create_string_input(is_password: bool, key: str) -> Gtk.Entry: 

124 entry = Gtk.Entry( 

125 text=cast( 

126 Callable[[str], None], 

127 (config_store.get_secret if is_password else config_store.get), 

128 )(key), 

129 hexpand=True, 

130 ) 

131 if is_password: 

132 entry.set_visibility(False) 

133 

134 entry.connect( 

135 "changed", 

136 lambda e: self._on_config_change(key, e.get_text(), secret=is_password), 

137 ) 

138 return entry 

139 

140 def create_bool_input(key: str) -> Gtk.Switch: 

141 switch = Gtk.Switch(active=config_store.get(key), halign=Gtk.Align.START) 

142 switch.connect( 

143 "notify::active", 

144 lambda s, _: self._on_config_change(key, s.get_active()), 

145 ) 

146 return switch 

147 

148 def create_int_input(key: str) -> Gtk.SpinButton: 

149 raise NotImplementedError() 

150 

151 def create_option_input(key: str) -> Gtk.ComboBox: 

152 raise NotImplementedError() 

153 

154 def create_path_input(key: str) -> Gtk.FileChooser: 

155 raise NotImplementedError() 

156 

157 content_grid_i = 0 

158 advanced_grid_i = 0 

159 for key, cpd in config_parameters.items(): 

160 if cpd.required: 

161 self.required_config_parameter_keys.add(key) 

162 if cpd.default is not None: 

163 config_store[key] = config_store.get(key, cpd.default) 

164 

165 label = Gtk.Label(label=cpd.description, halign=Gtk.Align.END) 

166 

167 input_el_box = Gtk.Box() 

168 self.entries[key] = cast( 

169 Callable[[str], Gtk.Widget], 

170 { 

171 str: partial(create_string_input, False), 

172 "password": partial(create_string_input, True), 

173 bool: create_bool_input, 

174 int: create_int_input, 

175 "option": create_option_input, 

176 Path: create_path_input, 

177 }[cpd.type], 

178 )(key) 

179 input_el_box.add(self.entries[key]) 

180 

181 if cpd.helptext: 

182 help_icon = Gtk.Image.new_from_icon_name( 

183 "help-about", 

184 Gtk.IconSize.BUTTON, 

185 ) 

186 help_icon.get_style_context().add_class("configure-form-help-icon") 

187 help_icon.set_tooltip_markup(cpd.helptext) 

188 input_el_box.add(help_icon) 

189 

190 if not cpd.advanced: 

191 content_grid.attach(label, 0, content_grid_i, 1, 1) 

192 content_grid.attach(input_el_box, 1, content_grid_i, 1, 1) 

193 content_grid_i += 1 

194 else: 

195 advanced_grid.attach(label, 0, advanced_grid_i, 1, 1) 

196 advanced_grid.attach(input_el_box, 1, advanced_grid_i, 1, 1) 

197 advanced_grid_i += 1 

198 

199 # Add a button and revealer for the advanced section of the configuration. 

200 if advanced_grid_i > 0: 

201 advanced_component = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) 

202 

203 advanced_expander = Gtk.Revealer() 

204 advanced_expander_icon = Gtk.Image.new_from_icon_name( 

205 "go-down-symbolic", Gtk.IconSize.BUTTON 

206 ) 

207 revealed = False 

208 

209 def toggle_expander(*args): 

210 nonlocal revealed 

211 revealed = not revealed 

212 advanced_expander.set_reveal_child(revealed) 

213 icon_dir = "up" if revealed else "down" 

214 advanced_expander_icon.set_from_icon_name( 

215 f"go-{icon_dir}-symbolic", Gtk.IconSize.BUTTON 

216 ) 

217 

218 advanced_expander_button = Gtk.Button(relief=Gtk.ReliefStyle.NONE) 

219 advanced_expander_button_box = Gtk.Box( 

220 orientation=Gtk.Orientation.HORIZONTAL, spacing=10 

221 ) 

222 

223 advanced_label = Gtk.Label( 

224 label="<b>Advanced Settings</b>", use_markup=True 

225 ) 

226 advanced_expander_button_box.add(advanced_label) 

227 advanced_expander_button_box.add(advanced_expander_icon) 

228 

229 advanced_expander_button.add(advanced_expander_button_box) 

230 advanced_expander_button.connect("clicked", toggle_expander) 

231 advanced_component.add(advanced_expander_button) 

232 

233 advanced_expander.add(advanced_grid) 

234 advanced_component.add(advanced_expander) 

235 

236 content_grid.attach(advanced_component, 0, content_grid_i, 2, 1) 

237 content_grid_i += 1 

238 

239 content_grid.attach( 

240 Gtk.Separator(name="config-verification-separator"), 0, content_grid_i, 2, 1 

241 ) 

242 content_grid_i += 1 

243 

244 self.config_verification_box = Gtk.Box(spacing=10) 

245 content_grid.attach(self.config_verification_box, 0, content_grid_i, 2, 1) 

246 

247 self.pack_start(content_grid, False, False, 10) 

248 self._verification_status_ratchet = 0 

249 self._verify_config(self._verification_status_ratchet) 

250 

251 had_all_required_keys = False 

252 verifying_in_progress = False 

253 

254 def _set_verification_status( 

255 self, verifying: bool, is_valid: bool = False, error_text: str = None 

256 ): 

257 if verifying: 

258 if not self.verifying_in_progress: 

259 for c in self.config_verification_box.get_children(): 

260 self.config_verification_box.remove(c) 

261 self.config_verification_box.add( 

262 Gtk.Spinner(active=True, name="verify-config-spinner") 

263 ) 

264 self.config_verification_box.add( 

265 Gtk.Label( 

266 label="<b>Verifying configuration...</b>", use_markup=True 

267 ) 

268 ) 

269 self.verifying_in_progress = True 

270 else: 

271 self.verifying_in_progress = False 

272 for c in self.config_verification_box.get_children(): 

273 self.config_verification_box.remove(c) 

274 

275 def set_icon_and_label(icon_name: str, label_text: str): 

276 self.config_verification_box.add( 

277 Gtk.Image.new_from_icon_name(icon_name, Gtk.IconSize.DND) 

278 ) 

279 label = Gtk.Label( 

280 label=label_text, 

281 use_markup=True, 

282 ellipsize=Pango.EllipsizeMode.END, 

283 ) 

284 label.set_tooltip_markup(label_text) 

285 self.config_verification_box.add(label) 

286 

287 if is_valid: 

288 set_icon_and_label( 

289 "config-ok-symbolic", "<b>Configuration is valid</b>" 

290 ) 

291 elif escaped := bleach.clean(error_text or ""): 

292 set_icon_and_label("config-error-symbolic", escaped) 

293 

294 self.config_verification_box.show_all() 

295 

296 def _on_config_change(self, key: str, value: Any, secret: bool = False): 

297 if secret: 

298 self.config_store.set_secret(key, value) 

299 else: 

300 self.config_store[key] = value 

301 self._verification_status_ratchet += 1 

302 self._verify_config(self._verification_status_ratchet) 

303 

304 def _verify_config(self, ratchet: int): 

305 self.emit("config-valid-changed", False) 

306 

307 from sublime_music.adapters import Result 

308 

309 if self.required_config_parameter_keys.issubset(set(self.config_store.keys())): 

310 if self._verification_status_ratchet != ratchet: 

311 return 

312 

313 self._set_verification_status(True) 

314 

315 has_empty = False 

316 if self.had_all_required_keys: 

317 for key in self.required_config_parameter_keys: 

318 if self.config_store.get(key) == "": 

319 self.entries[key].get_style_context().add_class("invalid") 

320 self.entries[key].set_tooltip_markup("This field is required") 

321 has_empty = True 

322 else: 

323 self.entries[key].get_style_context().remove_class("invalid") 

324 self.entries[key].set_tooltip_markup(None) 

325 

326 self.had_all_required_keys = True 

327 if has_empty: 

328 self._set_verification_status( 

329 False, 

330 error_text="<b>There are missing fields</b>\n" 

331 "Please fill out all required fields.", 

332 ) 

333 return 

334 

335 def on_verify_result(verification_errors: Dict[str, Optional[str]]): 

336 if self._verification_status_ratchet != ratchet: 

337 return 

338 

339 if len(verification_errors) == 0: 

340 self.emit("config-valid-changed", True) 

341 for entry in self.entries.values(): 

342 entry.get_style_context().remove_class("invalid") 

343 self._set_verification_status(False, is_valid=True) 

344 return 

345 

346 for key, entry in self.entries.items(): 

347 if error_text := verification_errors.get(key): 

348 entry.get_style_context().add_class("invalid") 

349 entry.set_tooltip_markup(error_text) 

350 else: 

351 entry.get_style_context().remove_class("invalid") 

352 entry.set_tooltip_markup(None) 

353 

354 self._set_verification_status( 

355 False, error_text=verification_errors.get("__ping__") 

356 ) 

357 

358 def verify_with_delay() -> Dict[str, Optional[str]]: 

359 sleep(0.75) 

360 if self._verification_status_ratchet != ratchet: 

361 return {} 

362 

363 return self.verify_configuration() 

364 

365 errors_result: Result[Dict[str, Optional[str]]] = Result(verify_with_delay) 

366 errors_result.add_done_callback( 

367 lambda f: GLib.idle_add(on_verify_result, f.result()) 

368 )