Source code for sublime_music.adapters.configure_server_form

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

from dataclasses import dataclass
from functools import partial
from pathlib import Path
from time import sleep
from typing import Any, Callable, Dict, Iterable, Optional, Tuple, Type, Union, cast

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

from . import ConfigurationStore


[docs]@dataclass class ConfigParamDescriptor: """ Describes a parameter that can be used to configure an adapter. The :class:`description`, :class:`required` and :class:`default:` should be self-evident as to what they do. The :class:`helptext` parameter is optional detailed text that will be shown in a help bubble corresponding to the field. The :class:`type` must be one of the following: * The literal type ``str``: corresponds to a freeform text entry field in the UI. * The literal type ``bool``: corresponds to a toggle in the UI. * The literal type ``int``: corresponds to a numeric input in the UI. * The literal string ``"password"``: corresponds to a password entry field in the UI. * The literal string ``"option"``: corresponds to dropdown in the UI. * The literal type ``Path``: corresponds to a file picker in the UI. The :class:`advanced` parameter specifies whether the setting should be behind an "Advanced" expander. The :class:`numeric_bounds` parameter only has an effect if the :class:`type` is `int`. It specifies the min and max values that the UI control can have. The :class:`numeric_step` parameter only has an effect if the :class:`type` is `int`. It specifies the step that will be taken using the "+" and "-" buttons on the UI control (if supported). The :class:`options` parameter only has an effect if the :class:`type` is ``"option"``. It specifies the list of options that will be available in the dropdown in the UI. The :class:`pathtype` parameter only has an effect if the :class:`type` is ``Path``. It can be either ``"file"`` or ``"directory"`` corresponding to a file picker and a directory picker, respectively. """ type: Union[Type, str] description: str required: bool = True helptext: Optional[str] = None advanced: Optional[bool] = None default: Any = None numeric_bounds: Optional[Tuple[int, int]] = None numeric_step: Optional[int] = None options: Optional[Iterable[str]] = None pathtype: Optional[str] = None
[docs]class ConfigureServerForm(Gtk.Box): __gsignals__ = { "config-valid-changed": ( GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (bool,), ), }
[docs] def __init__( self, config_store: ConfigurationStore, config_parameters: Dict[str, ConfigParamDescriptor], verify_configuration: Callable[[], Dict[str, Optional[str]]], is_networked: bool = True, ): """ Inititialize a :class:`ConfigureServerForm` with the given configuration parameters. :param config_store: The :class:`ConfigurationStore` to use to store configuration values for this adapter. :param config_parameters: An dictionary where the keys are the name of the configuration paramter and the values are the :class:`ConfigParamDescriptor` object corresponding to that configuration parameter. The order of the keys in the dictionary correspond to the order that the configuration parameters will be shown in the UI. :param verify_configuration: A function that verifies whether or not the current state of the ``config_store`` is valid. The output should be a dictionary containing verification errors. The keys of the returned dictionary should be the same as the keys passed in via the ``config_parameters`` parameter. The values should be strings describing why the corresponding value in the ``config_store`` is invalid. If the adapter ``is_networked``, and the special ``"__ping__"`` key is returned, then the error will be shown below all of the other settings in the ping status box. :param is_networked: whether or not the adapter is networked. """ Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL) self.config_store = config_store self.required_config_parameter_keys = set() self.verify_configuration = verify_configuration self.entries = {} self.is_networked = is_networked content_grid = Gtk.Grid( column_spacing=10, row_spacing=5, margin_left=10, margin_right=10, ) advanced_grid = Gtk.Grid(column_spacing=10, row_spacing=10) def create_string_input(is_password: bool, key: str) -> Gtk.Entry: entry = Gtk.Entry( text=cast( Callable[[str], None], (config_store.get_secret if is_password else config_store.get), )(key), hexpand=True, ) if is_password: entry.set_visibility(False) entry.connect( "changed", lambda e: self._on_config_change(key, e.get_text(), secret=is_password), ) return entry def create_bool_input(key: str) -> Gtk.Switch: switch = Gtk.Switch(active=config_store.get(key), halign=Gtk.Align.START) switch.connect( "notify::active", lambda s, _: self._on_config_change(key, s.get_active()), ) return switch def create_int_input(key: str) -> Gtk.SpinButton: raise NotImplementedError() def create_option_input(key: str) -> Gtk.ComboBox: raise NotImplementedError() def create_path_input(key: str) -> Gtk.FileChooser: raise NotImplementedError() content_grid_i = 0 advanced_grid_i = 0 for key, cpd in config_parameters.items(): if cpd.required: self.required_config_parameter_keys.add(key) if cpd.default is not None: config_store[key] = config_store.get(key, cpd.default) label = Gtk.Label(label=cpd.description, halign=Gtk.Align.END) input_el_box = Gtk.Box() self.entries[key] = cast( Callable[[str], Gtk.Widget], { str: partial(create_string_input, False), "password": partial(create_string_input, True), bool: create_bool_input, int: create_int_input, "option": create_option_input, Path: create_path_input, }[cpd.type], )(key) input_el_box.add(self.entries[key]) if cpd.helptext: help_icon = Gtk.Image.new_from_icon_name( "help-about", Gtk.IconSize.BUTTON, ) help_icon.get_style_context().add_class("configure-form-help-icon") help_icon.set_tooltip_markup(cpd.helptext) input_el_box.add(help_icon) if not cpd.advanced: content_grid.attach(label, 0, content_grid_i, 1, 1) content_grid.attach(input_el_box, 1, content_grid_i, 1, 1) content_grid_i += 1 else: advanced_grid.attach(label, 0, advanced_grid_i, 1, 1) advanced_grid.attach(input_el_box, 1, advanced_grid_i, 1, 1) advanced_grid_i += 1 # Add a button and revealer for the advanced section of the configuration. if advanced_grid_i > 0: advanced_component = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) advanced_expander = Gtk.Revealer() advanced_expander_icon = Gtk.Image.new_from_icon_name( "go-down-symbolic", Gtk.IconSize.BUTTON ) revealed = False def toggle_expander(*args): nonlocal revealed revealed = not revealed advanced_expander.set_reveal_child(revealed) icon_dir = "up" if revealed else "down" advanced_expander_icon.set_from_icon_name( f"go-{icon_dir}-symbolic", Gtk.IconSize.BUTTON ) advanced_expander_button = Gtk.Button(relief=Gtk.ReliefStyle.NONE) advanced_expander_button_box = Gtk.Box( orientation=Gtk.Orientation.HORIZONTAL, spacing=10 ) advanced_label = Gtk.Label(label="<b>Advanced Settings</b>", use_markup=True) advanced_expander_button_box.add(advanced_label) advanced_expander_button_box.add(advanced_expander_icon) advanced_expander_button.add(advanced_expander_button_box) advanced_expander_button.connect("clicked", toggle_expander) advanced_component.add(advanced_expander_button) advanced_expander.add(advanced_grid) advanced_component.add(advanced_expander) content_grid.attach(advanced_component, 0, content_grid_i, 2, 1) content_grid_i += 1 content_grid.attach( Gtk.Separator(name="config-verification-separator"), 0, content_grid_i, 2, 1 ) content_grid_i += 1 self.config_verification_box = Gtk.Box(spacing=10) content_grid.attach(self.config_verification_box, 0, content_grid_i, 2, 1) self.pack_start(content_grid, False, False, 10) self._verification_status_ratchet = 0 self._verify_config(self._verification_status_ratchet)
had_all_required_keys = False verifying_in_progress = False def _set_verification_status( self, verifying: bool, is_valid: bool = False, error_text: str | None = None ): if verifying: if not self.verifying_in_progress: for c in self.config_verification_box.get_children(): self.config_verification_box.remove(c) self.config_verification_box.add( Gtk.Spinner(active=True, name="verify-config-spinner") ) self.config_verification_box.add( Gtk.Label(label="<b>Verifying configuration...</b>", use_markup=True) ) self.verifying_in_progress = True else: self.verifying_in_progress = False for c in self.config_verification_box.get_children(): self.config_verification_box.remove(c) def set_icon_and_label(icon_name: str, label_text: str): self.config_verification_box.add( Gtk.Image.new_from_icon_name(icon_name, Gtk.IconSize.DND) ) label = Gtk.Label( label=label_text, use_markup=True, ellipsize=Pango.EllipsizeMode.END, ) label.set_tooltip_markup(label_text) self.config_verification_box.add(label) if is_valid: set_icon_and_label("config-ok-symbolic", "<b>Configuration is valid</b>") elif escaped := bleach.clean(error_text or ""): set_icon_and_label("config-error-symbolic", escaped) self.config_verification_box.show_all() def _on_config_change(self, key: str, value: Any, secret: bool = False): if secret: self.config_store.set_secret(key, value) else: self.config_store[key] = value self._verification_status_ratchet += 1 self._verify_config(self._verification_status_ratchet) def _verify_config(self, ratchet: int): self.emit("config-valid-changed", False) from sublime_music.adapters import Result if self.required_config_parameter_keys.issubset(set(self.config_store.keys())): if self._verification_status_ratchet != ratchet: return self._set_verification_status(True) has_empty = False if self.had_all_required_keys: for key in self.required_config_parameter_keys: if self.config_store.get(key) == "": self.entries[key].get_style_context().add_class("invalid") self.entries[key].set_tooltip_markup("This field is required") has_empty = True else: self.entries[key].get_style_context().remove_class("invalid") self.entries[key].set_tooltip_markup(None) self.had_all_required_keys = True if has_empty: self._set_verification_status( False, error_text="<b>There are missing fields</b>\n" "Please fill out all required fields.", ) return def on_verify_result(verification_errors: Dict[str, Optional[str]]): if self._verification_status_ratchet != ratchet: return if len(verification_errors) == 0: self.emit("config-valid-changed", True) for entry in self.entries.values(): entry.get_style_context().remove_class("invalid") self._set_verification_status(False, is_valid=True) return for key, entry in self.entries.items(): if error_text := verification_errors.get(key): entry.get_style_context().add_class("invalid") entry.set_tooltip_markup(error_text) else: entry.get_style_context().remove_class("invalid") entry.set_tooltip_markup(None) self._set_verification_status( False, error_text=verification_errors.get("__ping__") ) def verify_with_delay() -> Dict[str, Optional[str]]: sleep(0.75) if self._verification_status_ratchet != ratchet: return {} return self.verify_configuration() errors_result: Result[Dict[str, Optional[str]]] = Result(verify_with_delay) errors_result.add_done_callback(lambda f: GLib.idle_add(on_verify_result, f.result()))