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"""
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
11import bleach
13from gi.repository import GLib, GObject, Gtk, Pango
15from . import ConfigurationStore
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.
25 The :class:`helptext` parameter is optional detailed text that will be shown in a
26 help bubble corresponding to the field.
28 The :class:`type` must be one of the following:
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.
38 The :class:`advanced` parameter specifies whether the setting should be behind an
39 "Advanced" expander.
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.
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).
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.
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 """
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
69class ConfigureServerForm(Gtk.Box):
70 __gsignals__ = {
71 "config-valid-changed": (
72 GObject.SignalFlags.RUN_FIRST,
73 GObject.TYPE_NONE,
74 (bool,),
75 ),
76 }
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.
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.
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
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)
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)
134 entry.connect(
135 "changed",
136 lambda e: self._on_config_change(key, e.get_text(), secret=is_password),
137 )
138 return entry
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
148 def create_int_input(key: str) -> Gtk.SpinButton:
149 raise NotImplementedError()
151 def create_option_input(key: str) -> Gtk.ComboBox:
152 raise NotImplementedError()
154 def create_path_input(key: str) -> Gtk.FileChooser:
155 raise NotImplementedError()
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)
165 label = Gtk.Label(label=cpd.description, halign=Gtk.Align.END)
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])
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)
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
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)
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
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 )
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 )
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)
229 advanced_expander_button.add(advanced_expander_button_box)
230 advanced_expander_button.connect("clicked", toggle_expander)
231 advanced_component.add(advanced_expander_button)
233 advanced_expander.add(advanced_grid)
234 advanced_component.add(advanced_expander)
236 content_grid.attach(advanced_component, 0, content_grid_i, 2, 1)
237 content_grid_i += 1
239 content_grid.attach(
240 Gtk.Separator(name="config-verification-separator"), 0, content_grid_i, 2, 1
241 )
242 content_grid_i += 1
244 self.config_verification_box = Gtk.Box(spacing=10)
245 content_grid.attach(self.config_verification_box, 0, content_grid_i, 2, 1)
247 self.pack_start(content_grid, False, False, 10)
248 self._verification_status_ratchet = 0
249 self._verify_config(self._verification_status_ratchet)
251 had_all_required_keys = False
252 verifying_in_progress = False
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)
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)
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)
294 self.config_verification_box.show_all()
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)
304 def _verify_config(self, ratchet: int):
305 self.emit("config-valid-changed", False)
307 from sublime_music.adapters import Result
309 if self.required_config_parameter_keys.issubset(set(self.config_store.keys())):
310 if self._verification_status_ratchet != ratchet:
311 return
313 self._set_verification_status(True)
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)
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
335 def on_verify_result(verification_errors: Dict[str, Optional[str]]):
336 if self._verification_status_ratchet != ratchet:
337 return
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
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)
354 self._set_verification_status(
355 False, error_text=verification_errors.get("__ping__")
356 )
358 def verify_with_delay() -> Dict[str, Optional[str]]:
359 sleep(0.75)
360 if self._verification_status_ratchet != ratchet:
361 return {}
363 return self.verify_configuration()
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 )