WIP
This commit is contained in:
@@ -6,8 +6,8 @@ from .adapter_base import (
|
|||||||
ConfigurationStore,
|
ConfigurationStore,
|
||||||
SongCacheStatus,
|
SongCacheStatus,
|
||||||
UIInfo,
|
UIInfo,
|
||||||
|
ConfigParamDescriptor,
|
||||||
)
|
)
|
||||||
from .configure_server_form import ConfigParamDescriptor, ConfigureServerForm
|
|
||||||
from .manager import AdapterManager, DownloadProgress, Result, SearchResult
|
from .manager import AdapterManager, DownloadProgress, Result, SearchResult
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
@@ -18,7 +18,6 @@ __all__ = (
|
|||||||
"CachingAdapter",
|
"CachingAdapter",
|
||||||
"ConfigParamDescriptor",
|
"ConfigParamDescriptor",
|
||||||
"ConfigurationStore",
|
"ConfigurationStore",
|
||||||
"ConfigureServerForm",
|
|
||||||
"DownloadProgress",
|
"DownloadProgress",
|
||||||
"Result",
|
"Result",
|
||||||
"SearchResult",
|
"SearchResult",
|
||||||
|
@@ -16,13 +16,11 @@ from typing import (
|
|||||||
Sequence,
|
Sequence,
|
||||||
Set,
|
Set,
|
||||||
Tuple,
|
Tuple,
|
||||||
|
Union,
|
||||||
|
Type,
|
||||||
|
Callable,
|
||||||
)
|
)
|
||||||
|
|
||||||
import gi
|
|
||||||
|
|
||||||
gi.require_version("Gtk", "3.0")
|
|
||||||
from gi.repository import Gtk
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import keyring
|
import keyring
|
||||||
|
|
||||||
@@ -255,6 +253,57 @@ class UIInfo:
|
|||||||
return f"{self.icon_basename}-{status.lower()}-symbolic"
|
return f"{self.icon_basename}-{status.lower()}-symbolic"
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
|
|
||||||
class Adapter(abc.ABC):
|
class Adapter(abc.ABC):
|
||||||
"""
|
"""
|
||||||
Defines the interface for a Sublime Music Adapter.
|
Defines the interface for a Sublime Music Adapter.
|
||||||
@@ -277,7 +326,7 @@ class Adapter(abc.ABC):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
def get_configuration_form(config_store: ConfigurationStore) -> Gtk.Box:
|
def get_configuration_form(config_store: ConfigurationStore) -> Tuple[Dict[str, ConfigParamDescriptor], Callable[[ConfigurationStore], Dict[str, Optional[str]]]]:
|
||||||
"""
|
"""
|
||||||
This function should return a :class:`Gtk.Box` that gets any inputs required
|
This function should return a :class:`Gtk.Box` that gets any inputs required
|
||||||
from the user and uses the given ``config_store`` to store the configuration
|
from the user and uses the given ``config_store`` to store the configuration
|
||||||
|
@@ -1,368 +0,0 @@
|
|||||||
"""
|
|
||||||
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, cast, Dict, Iterable, Optional, Tuple, Type, Union
|
|
||||||
|
|
||||||
import bleach
|
|
||||||
|
|
||||||
from gi.repository import GLib, GObject, Gtk, Pango
|
|
||||||
|
|
||||||
from . import ConfigurationStore
|
|
||||||
|
|
||||||
|
|
||||||
@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
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigureServerForm(Gtk.Box):
|
|
||||||
__gsignals__ = {
|
|
||||||
"config-valid-changed": (
|
|
||||||
GObject.SignalFlags.RUN_FIRST,
|
|
||||||
GObject.TYPE_NONE,
|
|
||||||
(bool,),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
):
|
|
||||||
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())
|
|
||||||
)
|
|
@@ -18,7 +18,6 @@ from .. import (
|
|||||||
CachingAdapter,
|
CachingAdapter,
|
||||||
ConfigParamDescriptor,
|
ConfigParamDescriptor,
|
||||||
ConfigurationStore,
|
ConfigurationStore,
|
||||||
ConfigureServerForm,
|
|
||||||
SongCacheStatus,
|
SongCacheStatus,
|
||||||
UIInfo,
|
UIInfo,
|
||||||
)
|
)
|
||||||
@@ -42,19 +41,16 @@ class FilesystemAdapter(CachingAdapter):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_configuration_form(config_store: ConfigurationStore) -> Gtk.Box:
|
def get_configuration_form() -> Gtk.Box:
|
||||||
def verify_config_store() -> Dict[str, Optional[str]]:
|
configs = {
|
||||||
|
"directory": ConfigParamDescriptor(
|
||||||
|
type=Path, description="Music Directory", pathtype="directory"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
def verify_config_store(config_store: ConfigurationStore) -> Dict[str, Optional[str]]:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
return ConfigureServerForm(
|
return configs, verify_config_store
|
||||||
config_store,
|
|
||||||
{
|
|
||||||
"directory": ConfigParamDescriptor(
|
|
||||||
type=Path, description="Music Directory", pathtype="directory"
|
|
||||||
)
|
|
||||||
},
|
|
||||||
verify_config_store,
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def migrate_configuration(config_store: ConfigurationStore):
|
def migrate_configuration(config_store: ConfigurationStore):
|
||||||
|
@@ -38,7 +38,6 @@ from .. import (
|
|||||||
api_objects as API,
|
api_objects as API,
|
||||||
ConfigParamDescriptor,
|
ConfigParamDescriptor,
|
||||||
ConfigurationStore,
|
ConfigurationStore,
|
||||||
ConfigureServerForm,
|
|
||||||
UIInfo,
|
UIInfo,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -90,7 +89,7 @@ class SubsonicAdapter(Adapter):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_configuration_form(config_store: ConfigurationStore) -> Gtk.Box:
|
def get_configuration_form():
|
||||||
configs = {
|
configs = {
|
||||||
"server_address": ConfigParamDescriptor(str, "Server Address"),
|
"server_address": ConfigParamDescriptor(str, "Server Address"),
|
||||||
"username": ConfigParamDescriptor(str, "Username"),
|
"username": ConfigParamDescriptor(str, "Username"),
|
||||||
@@ -145,7 +144,7 @@ class SubsonicAdapter(Adapter):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
def verify_configuration() -> Dict[str, Optional[str]]:
|
def verify_configuration(config_store: ConfigurationStore) -> Dict[str, Optional[str]]:
|
||||||
errors: Dict[str, Optional[str]] = {}
|
errors: Dict[str, Optional[str]] = {}
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir_name:
|
with tempfile.TemporaryDirectory() as tmp_dir_name:
|
||||||
@@ -206,7 +205,7 @@ class SubsonicAdapter(Adapter):
|
|||||||
|
|
||||||
return errors
|
return errors
|
||||||
|
|
||||||
return ConfigureServerForm(config_store, configs, verify_configuration)
|
return configs, verify_configuration
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def migrate_configuration(config_store: ConfigurationStore):
|
def migrate_configuration(config_store: ConfigurationStore):
|
||||||
|
@@ -50,12 +50,13 @@ from .adapters import (
|
|||||||
DownloadProgress,
|
DownloadProgress,
|
||||||
Result,
|
Result,
|
||||||
SongCacheStatus,
|
SongCacheStatus,
|
||||||
|
ConfigurationStore,
|
||||||
)
|
)
|
||||||
|
from .adapters.filesystem import FilesystemAdapter
|
||||||
from .adapters.api_objects import Playlist, PlayQueue, Song
|
from .adapters.api_objects import Playlist, PlayQueue, Song
|
||||||
from .config import AppConfiguration, ProviderConfiguration
|
from .config import AppConfiguration, ProviderConfiguration
|
||||||
from .dbus import dbus_propagate, DBusManager
|
from .dbus import dbus_propagate, DBusManager
|
||||||
from .players import PlayerDeviceEvent, PlayerEvent, PlayerManager
|
from .players import PlayerDeviceEvent, PlayerEvent, PlayerManager
|
||||||
from .ui.configure_provider import ConfigureProviderDialog
|
|
||||||
from .ui.main import MainWindow
|
from .ui.main import MainWindow
|
||||||
from .ui.state import RepeatType, UIState
|
from .ui.state import RepeatType, UIState
|
||||||
from .ui.actions import register_action, register_dataclass_actions
|
from .ui.actions import register_action, register_dataclass_actions
|
||||||
@@ -92,13 +93,9 @@ class SublimeMusicApp(Gtk.Application):
|
|||||||
action.connect("activate", fn)
|
action.connect("activate", fn)
|
||||||
self.add_action(action)
|
self.add_action(action)
|
||||||
|
|
||||||
register_action(self, self.change_tab)
|
register_action(self, self.quit, types=tuple())
|
||||||
|
|
||||||
# Music provider actions
|
register_action(self, self.change_tab)
|
||||||
register_action(self, self.add_new_music_provider)
|
|
||||||
register_action(self, self.edit_current_music_provider)
|
|
||||||
register_action(self, self.switch_music_provider)
|
|
||||||
register_action(self, self.remove_music_provider)
|
|
||||||
|
|
||||||
# Connect after we know there's a server configured.
|
# Connect after we know there's a server configured.
|
||||||
# self.window.connect("notification-closed", self.on_notification_closed)
|
# self.window.connect("notification-closed", self.on_notification_closed)
|
||||||
@@ -146,7 +143,9 @@ class SublimeMusicApp(Gtk.Application):
|
|||||||
self.set_accels_for_action('app.play-pause', ["space"])
|
self.set_accels_for_action('app.play-pause', ["space"])
|
||||||
self.set_accels_for_action('app.prev-track', ["Home"])
|
self.set_accels_for_action('app.prev-track', ["Home"])
|
||||||
self.set_accels_for_action('app.next-track', ["End"])
|
self.set_accels_for_action('app.next-track', ["End"])
|
||||||
# self.set_accels_for_action('app.quit', ["space"])
|
self.set_accels_for_action('app.quit', ["<Ctrl>q"])
|
||||||
|
self.set_accels_for_action('app.quit', ["<Ctrl>w"])
|
||||||
|
|
||||||
|
|
||||||
def do_activate(self):
|
def do_activate(self):
|
||||||
# We only allow a single window and raise any existing ones
|
# We only allow a single window and raise any existing ones
|
||||||
@@ -184,6 +183,12 @@ class SublimeMusicApp(Gtk.Application):
|
|||||||
register_action(players, self.players_set_option, name='set-int-option', types=(str, str, int))
|
register_action(players, self.players_set_option, name='set-int-option', types=(str, str, int))
|
||||||
self.window.insert_action_group('players', players)
|
self.window.insert_action_group('players', players)
|
||||||
|
|
||||||
|
providers = Gio.SimpleActionGroup()
|
||||||
|
register_action(providers, self.providers_set_config, name='set-config')
|
||||||
|
register_action(providers, self.providers_switch, name='switch')
|
||||||
|
register_action(providers, self.providers_remove, name='remove')
|
||||||
|
self.window.insert_action_group('providers', providers)
|
||||||
|
|
||||||
# Configure the CSS provider so that we can style elements on the
|
# Configure the CSS provider so that we can style elements on the
|
||||||
# window.
|
# window.
|
||||||
css_provider = Gtk.CssProvider()
|
css_provider = Gtk.CssProvider()
|
||||||
@@ -204,15 +209,9 @@ class SublimeMusicApp(Gtk.Application):
|
|||||||
# configured, and if none are configured, then show the dialog to create a new
|
# configured, and if none are configured, then show the dialog to create a new
|
||||||
# one.
|
# one.
|
||||||
if self.app_config.provider is None:
|
if self.app_config.provider is None:
|
||||||
if len(self.app_config.providers) == 0:
|
self.window.show_providers_window()
|
||||||
self.show_configure_servers_dialog()
|
else:
|
||||||
|
AdapterManager.reset(self.app_config, self.on_song_download_progress)
|
||||||
# If they didn't add one with the dialog, close the window.
|
|
||||||
if len(self.app_config.providers) == 0:
|
|
||||||
self.window.close()
|
|
||||||
return
|
|
||||||
|
|
||||||
AdapterManager.reset(self.app_config, self.on_song_download_progress)
|
|
||||||
|
|
||||||
# Configure the players
|
# Configure the players
|
||||||
self.last_play_queue_update = timedelta(0)
|
self.last_play_queue_update = timedelta(0)
|
||||||
@@ -355,20 +354,21 @@ class SublimeMusicApp(Gtk.Application):
|
|||||||
GLib.timeout_add(10000, check_if_connected)
|
GLib.timeout_add(10000, check_if_connected)
|
||||||
|
|
||||||
# Update after Adapter Initial Sync
|
# Update after Adapter Initial Sync
|
||||||
def after_initial_sync(_):
|
if self.app_config.provider:
|
||||||
self.update_window()
|
def after_initial_sync(_):
|
||||||
|
self.update_window()
|
||||||
|
|
||||||
# Prompt to load the play queue from the server.
|
# Prompt to load the play queue from the server.
|
||||||
if AdapterManager.can_get_play_queue():
|
if AdapterManager.can_get_play_queue():
|
||||||
self.update_play_state_from_server(prompt_confirm=True)
|
self.update_play_state_from_server(prompt_confirm=True)
|
||||||
|
|
||||||
# Get the playlists, just so that we don't have tons of cache misses from
|
# Get the playlists, just so that we don't have tons of cache misses from
|
||||||
# DBus trying to get the playlists.
|
# DBus trying to get the playlists.
|
||||||
if AdapterManager.can_get_playlists():
|
if AdapterManager.can_get_playlists():
|
||||||
AdapterManager.get_playlists()
|
AdapterManager.get_playlists()
|
||||||
|
|
||||||
inital_sync_result = AdapterManager.initial_sync()
|
inital_sync_result = AdapterManager.initial_sync()
|
||||||
inital_sync_result.add_done_callback(after_initial_sync)
|
inital_sync_result.add_done_callback(after_initial_sync)
|
||||||
|
|
||||||
# Send out to the bus that we exist.
|
# Send out to the bus that we exist.
|
||||||
if self.dbus_manager:
|
if self.dbus_manager:
|
||||||
@@ -622,7 +622,7 @@ class SublimeMusicApp(Gtk.Application):
|
|||||||
# setattr(self.app_config.state, k, v)
|
# setattr(self.app_config.state, k, v)
|
||||||
# self.update_window(force=force)
|
# self.update_window(force=force)
|
||||||
|
|
||||||
@dbus_propagate()
|
# @dbus_propagate()
|
||||||
def refresh(self):
|
def refresh(self):
|
||||||
self.update_window(force=False)
|
self.update_window(force=False)
|
||||||
|
|
||||||
@@ -638,45 +638,6 @@ class SublimeMusicApp(Gtk.Application):
|
|||||||
self.app_config.state.current_notification = None
|
self.app_config.state.current_notification = None
|
||||||
self.update_window()
|
self.update_window()
|
||||||
|
|
||||||
def add_new_music_provider(self):
|
|
||||||
self.show_configure_servers_dialog()
|
|
||||||
|
|
||||||
def edit_current_music_provider(self):
|
|
||||||
self.show_configure_servers_dialog(self.app_config.provider.clone())
|
|
||||||
|
|
||||||
def switch_music_provider(self, provider_id: str):
|
|
||||||
if self.app_config.state.playing:
|
|
||||||
self.play_pause()
|
|
||||||
self.app_config.save()
|
|
||||||
self.app_config.current_provider_id = provider_id
|
|
||||||
self.reset_state()
|
|
||||||
self.app_config.save()
|
|
||||||
|
|
||||||
def remove_music_provider(self, provider_id: str):
|
|
||||||
provider = self.app_config.providers[provider_id]
|
|
||||||
confirm_dialog = Gtk.MessageDialog(
|
|
||||||
transient_for=self.window,
|
|
||||||
message_type=Gtk.MessageType.WARNING,
|
|
||||||
buttons=(
|
|
||||||
Gtk.STOCK_CANCEL,
|
|
||||||
Gtk.ResponseType.CANCEL,
|
|
||||||
Gtk.STOCK_DELETE,
|
|
||||||
Gtk.ResponseType.YES,
|
|
||||||
),
|
|
||||||
text=f"Are you sure you want to delete the {provider.name} music provider?",
|
|
||||||
)
|
|
||||||
confirm_dialog.format_secondary_markup(
|
|
||||||
"Deleting this music provider will delete all cached songs and metadata "
|
|
||||||
"associated with this provider."
|
|
||||||
)
|
|
||||||
if confirm_dialog.run() == Gtk.ResponseType.YES:
|
|
||||||
assert self.app_config.cache_location
|
|
||||||
provider_dir = self.app_config.cache_location.joinpath(provider.id)
|
|
||||||
shutil.rmtree(str(provider_dir), ignore_errors=True)
|
|
||||||
del self.app_config.providers[provider.id]
|
|
||||||
|
|
||||||
confirm_dialog.destroy()
|
|
||||||
|
|
||||||
_inhibit_cookie = None
|
_inhibit_cookie = None
|
||||||
|
|
||||||
@dbus_propagate()
|
@dbus_propagate()
|
||||||
@@ -1039,34 +1000,93 @@ class SublimeMusicApp(Gtk.Application):
|
|||||||
self.app_config.save()
|
self.app_config.save()
|
||||||
self.update_window()
|
self.update_window()
|
||||||
|
|
||||||
|
def providers_set_config(self, id: str, name: str, adapter_name: str, config: Dict[str, Any]):
|
||||||
|
adapter_type = None
|
||||||
|
for adapter in AdapterManager.available_adapters:
|
||||||
|
if adapter.get_ui_info().name == adapter_name:
|
||||||
|
adapter_type = adapter
|
||||||
|
break
|
||||||
|
assert adapter_type is not None
|
||||||
|
|
||||||
|
provider_config = ProviderConfiguration(
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
adapter_type,
|
||||||
|
ConfigurationStore(**config))
|
||||||
|
|
||||||
|
if adapter_type.can_be_cached:
|
||||||
|
provider_config.caching_adapter_type = FilesystemAdapter
|
||||||
|
provider_config.caching_adapter_config = ConfigurationStore()
|
||||||
|
|
||||||
|
provider_config.persist_secrets()
|
||||||
|
self.app_config.providers[provider_config.id] = provider_config
|
||||||
|
self.app_config.current_provider_id = provider_config.id
|
||||||
|
self.reset_state()
|
||||||
|
self.app_config.save()
|
||||||
|
self.update_window(force=True)
|
||||||
|
|
||||||
|
def providers_switch(self, provider_id: str):
|
||||||
|
if self.app_config.state.playing:
|
||||||
|
self.play_pause()
|
||||||
|
self.app_config.save()
|
||||||
|
self.app_config.current_provider_id = provider_id
|
||||||
|
self.reset_state()
|
||||||
|
self.app_config.save()
|
||||||
|
|
||||||
|
def providers_remove(self, provider_id: str):
|
||||||
|
provider = self.app_config.providers[provider_id]
|
||||||
|
confirm_dialog = Gtk.MessageDialog(
|
||||||
|
transient_for=self.window,
|
||||||
|
message_type=Gtk.MessageType.WARNING,
|
||||||
|
buttons=(
|
||||||
|
Gtk.STOCK_CANCEL,
|
||||||
|
Gtk.ResponseType.CANCEL,
|
||||||
|
Gtk.STOCK_DELETE,
|
||||||
|
Gtk.ResponseType.YES,
|
||||||
|
),
|
||||||
|
text=f"Are you sure you want to delete the {provider.name} music provider?",
|
||||||
|
)
|
||||||
|
confirm_dialog.format_secondary_markup(
|
||||||
|
"Deleting this music provider will delete all cached songs and metadata "
|
||||||
|
"associated with this provider."
|
||||||
|
)
|
||||||
|
if confirm_dialog.run() == Gtk.ResponseType.YES:
|
||||||
|
assert self.app_config.cache_location
|
||||||
|
provider_dir = self.app_config.cache_location.joinpath(provider.id)
|
||||||
|
shutil.rmtree(str(provider_dir), ignore_errors=True)
|
||||||
|
del self.app_config.providers[provider.id]
|
||||||
|
self.update_window()
|
||||||
|
|
||||||
|
confirm_dialog.destroy()
|
||||||
|
|
||||||
|
|
||||||
# ########## HELPER METHODS ########## #
|
# ########## HELPER METHODS ########## #
|
||||||
def show_configure_servers_dialog(
|
# def show_configure_servers_dialog(
|
||||||
self,
|
# self,
|
||||||
provider_config: Optional[ProviderConfiguration] = None,
|
# provider_config: Optional[ProviderConfiguration] = None,
|
||||||
):
|
# ):
|
||||||
"""Show the Connect to Server dialog."""
|
# """Show the Connect to Server dialog."""
|
||||||
dialog = ConfigureProviderDialog(self.window, provider_config)
|
# dialog = ConfigureProviderDialog(self.window, provider_config)
|
||||||
result = dialog.run()
|
# result = dialog.run()
|
||||||
if result == Gtk.ResponseType.APPLY:
|
# if result == Gtk.ResponseType.APPLY:
|
||||||
assert dialog.provider_config is not None
|
# assert dialog.provider_config is not None
|
||||||
provider_id = dialog.provider_config.id
|
# provider_id = dialog.provider_config.id
|
||||||
dialog.provider_config.persist_secrets()
|
# dialog.provider_config.persist_secrets()
|
||||||
self.app_config.providers[provider_id] = dialog.provider_config
|
# self.app_config.providers[provider_id] = dialog.provider_config
|
||||||
self.app_config.save()
|
# self.app_config.save()
|
||||||
|
|
||||||
if provider_id == self.app_config.current_provider_id:
|
# if provider_id == self.app_config.current_provider_id:
|
||||||
# Just update the window.
|
# # Just update the window.
|
||||||
self.update_window()
|
# self.update_window()
|
||||||
else:
|
# else:
|
||||||
# Switch to the new provider.
|
# # Switch to the new provider.
|
||||||
if self.app_config.state.playing:
|
# if self.app_config.state.playing:
|
||||||
self.play_pause()
|
# self.play_pause()
|
||||||
self.app_config.current_provider_id = provider_id
|
# self.app_config.current_provider_id = provider_id
|
||||||
self.app_config.save()
|
# self.app_config.save()
|
||||||
self.update_window(force=True)
|
# self.update_window(force=True)
|
||||||
|
|
||||||
dialog.destroy()
|
# dialog.destroy()
|
||||||
|
|
||||||
def update_window(self, force: bool = False):
|
def update_window(self, force: bool = False):
|
||||||
if not self.window:
|
if not self.window:
|
||||||
|
@@ -54,11 +54,11 @@ def register_action(group, fn: Callable, name: Optional[str] = None, types: Tupl
|
|||||||
name = fn.__name__.replace('_', '-')
|
name = fn.__name__.replace('_', '-')
|
||||||
|
|
||||||
# Determine the type from the signature
|
# Determine the type from the signature
|
||||||
signature = inspect.signature(fn)
|
|
||||||
if types is None:
|
if types is None:
|
||||||
|
signature = inspect.signature(fn)
|
||||||
types = tuple(p.annotation for p in signature.parameters.values())
|
types = tuple(p.annotation for p in signature.parameters.values())
|
||||||
|
|
||||||
if signature.parameters:
|
if types:
|
||||||
if inspect.Parameter.empty in types:
|
if inspect.Parameter.empty in types:
|
||||||
raise ValueError('Missing parameter annotation for action ' + name)
|
raise ValueError('Missing parameter annotation for action ' + name)
|
||||||
|
|
||||||
@@ -238,6 +238,8 @@ _VARIANT_CONSTRUCTORS = {
|
|||||||
from gi._gi import variant_type_from_string
|
from gi._gi import variant_type_from_string
|
||||||
|
|
||||||
def _create_variant(type_str, value):
|
def _create_variant(type_str, value):
|
||||||
|
assert type_str
|
||||||
|
|
||||||
if isinstance(value, enum.Enum):
|
if isinstance(value, enum.Enum):
|
||||||
value = value.value
|
value = value.value
|
||||||
elif isinstance(value, pathlib.PurePath):
|
elif isinstance(value, pathlib.PurePath):
|
||||||
@@ -248,7 +250,7 @@ def _create_variant(type_str, value):
|
|||||||
|
|
||||||
vtype = GLib.VariantType(type_str)
|
vtype = GLib.VariantType(type_str)
|
||||||
|
|
||||||
if vtype.is_basic():
|
if type_str in _VARIANT_CONSTRUCTORS:
|
||||||
return _VARIANT_CONSTRUCTORS[type_str](value)
|
return _VARIANT_CONSTRUCTORS[type_str](value)
|
||||||
|
|
||||||
builder = GLib.VariantBuilder.new(vtype)
|
builder = GLib.VariantBuilder.new(vtype)
|
||||||
|
@@ -337,8 +337,9 @@ class AlbumsPanel(Handy.Leaflet):
|
|||||||
# Has to be last because it resets self.updating_query
|
# Has to be last because it resets self.updating_query
|
||||||
self.populate_genre_combo(app_config, force=force)
|
self.populate_genre_combo(app_config, force=force)
|
||||||
|
|
||||||
selected_album = self.albums_by_id.get(app_config.state.selected_album_id, None) or self.albums[0]
|
selected_album = self.albums_by_id.get(app_config.state.selected_album_id, None) or (self.albums and self.albums[0])
|
||||||
self.album_with_songs.update(selected_album, app_config, force=force)
|
if selected_album is not None:
|
||||||
|
self.album_with_songs.update(selected_album, app_config, force=force)
|
||||||
|
|
||||||
def _albums_loaded(self, result: Result[Iterable[API.Album]]):
|
def _albums_loaded(self, result: Result[Iterable[API.Album]]):
|
||||||
self.current_albums_result = None
|
self.current_albums_result = None
|
||||||
|
@@ -1,228 +0,0 @@
|
|||||||
import uuid
|
|
||||||
from enum import Enum
|
|
||||||
from typing import Any, Optional, Type
|
|
||||||
|
|
||||||
from gi.repository import Gio, GObject, Gtk, Pango
|
|
||||||
|
|
||||||
from ..adapters import AdapterManager, UIInfo
|
|
||||||
from ..adapters.filesystem import FilesystemAdapter
|
|
||||||
from ..config import ConfigurationStore, ProviderConfiguration
|
|
||||||
|
|
||||||
|
|
||||||
class AdapterTypeModel(GObject.GObject):
|
|
||||||
adapter_type = GObject.Property(type=object)
|
|
||||||
|
|
||||||
def __init__(self, adapter_type: Type):
|
|
||||||
GObject.GObject.__init__(self)
|
|
||||||
self.adapter_type = adapter_type
|
|
||||||
|
|
||||||
|
|
||||||
class DialogStage(Enum):
|
|
||||||
SELECT_ADAPTER = "select"
|
|
||||||
CONFIGURE_ADAPTER = "configure"
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigureProviderDialog(Gtk.Dialog):
|
|
||||||
_current_index = -1
|
|
||||||
stage = DialogStage.SELECT_ADAPTER
|
|
||||||
|
|
||||||
def set_title(self, editing: bool, provider_config: ProviderConfiguration = None):
|
|
||||||
if editing:
|
|
||||||
assert provider_config is not None
|
|
||||||
title = f"Edit {provider_config.name}"
|
|
||||||
else:
|
|
||||||
title = "Add New Music Source"
|
|
||||||
|
|
||||||
self.header.props.title = title
|
|
||||||
|
|
||||||
def __init__(self, parent: Any, provider_config: Optional[ProviderConfiguration]):
|
|
||||||
Gtk.Dialog.__init__(self, transient_for=parent, flags=Gtk.DialogFlags.MODAL)
|
|
||||||
self.provider_config = provider_config
|
|
||||||
self.editing = provider_config is not None
|
|
||||||
self.set_default_size(400, 350)
|
|
||||||
|
|
||||||
# HEADER
|
|
||||||
self.header = Gtk.HeaderBar()
|
|
||||||
self.set_title(self.editing, provider_config)
|
|
||||||
|
|
||||||
self.cancel_back_button = Gtk.Button(label="Cancel")
|
|
||||||
self.cancel_back_button.connect("clicked", self._on_cancel_back_clicked)
|
|
||||||
self.header.pack_start(self.cancel_back_button)
|
|
||||||
|
|
||||||
self.next_add_button = Gtk.Button(label="Edit" if self.editing else "Next")
|
|
||||||
self.next_add_button.get_style_context().add_class("suggested-action")
|
|
||||||
self.next_add_button.connect("clicked", self._on_next_add_clicked)
|
|
||||||
self.header.pack_end(self.next_add_button)
|
|
||||||
|
|
||||||
self.set_titlebar(self.header)
|
|
||||||
|
|
||||||
content_area = self.get_content_area()
|
|
||||||
|
|
||||||
self.stack = Gtk.Stack()
|
|
||||||
self.stack.set_transition_type(Gtk.StackTransitionType.SLIDE_LEFT_RIGHT)
|
|
||||||
|
|
||||||
# ADAPTER TYPE OPTIONS
|
|
||||||
adapter_type_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
|
||||||
self.adapter_type_store = Gio.ListStore()
|
|
||||||
self.adapter_options_list = Gtk.ListBox(
|
|
||||||
name="ground-truth-adapter-options-list", activate_on_single_click=False
|
|
||||||
)
|
|
||||||
self.adapter_options_list.connect("row-activated", self._on_next_add_clicked)
|
|
||||||
|
|
||||||
def create_row(model: AdapterTypeModel) -> Gtk.ListBoxRow:
|
|
||||||
ui_info: UIInfo = model.adapter_type.get_ui_info()
|
|
||||||
row = Gtk.ListBoxRow()
|
|
||||||
rowbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
|
||||||
rowbox.pack_start(
|
|
||||||
Gtk.Image.new_from_icon_name(ui_info.icon_name(), Gtk.IconSize.DND),
|
|
||||||
False,
|
|
||||||
False,
|
|
||||||
5,
|
|
||||||
)
|
|
||||||
rowbox.add(
|
|
||||||
Gtk.Label(
|
|
||||||
label=f"<b>{ui_info.name}</b>\n{ui_info.description}",
|
|
||||||
use_markup=True,
|
|
||||||
margin=8,
|
|
||||||
halign=Gtk.Align.START,
|
|
||||||
ellipsize=Pango.EllipsizeMode.END,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
row.add(rowbox)
|
|
||||||
row.show_all()
|
|
||||||
return row
|
|
||||||
|
|
||||||
self.adapter_options_list.bind_model(self.adapter_type_store, create_row)
|
|
||||||
|
|
||||||
available_ground_truth_adapters = filter(
|
|
||||||
lambda a: a.can_be_ground_truth, AdapterManager.available_adapters
|
|
||||||
)
|
|
||||||
for adapter_type in sorted(
|
|
||||||
available_ground_truth_adapters, key=lambda a: a.get_ui_info().name
|
|
||||||
):
|
|
||||||
self.adapter_type_store.append(AdapterTypeModel(adapter_type))
|
|
||||||
|
|
||||||
adapter_type_box.pack_start(self.adapter_options_list, True, True, 10)
|
|
||||||
self.stack.add_named(adapter_type_box, "select")
|
|
||||||
|
|
||||||
self.configure_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
|
||||||
self.stack.add_named(self.configure_box, "configure")
|
|
||||||
|
|
||||||
content_area.pack_start(self.stack, True, True, 0)
|
|
||||||
|
|
||||||
self.show_all()
|
|
||||||
|
|
||||||
if self.editing:
|
|
||||||
assert self.provider_config
|
|
||||||
for i, adapter_type in enumerate(self.adapter_type_store):
|
|
||||||
if (
|
|
||||||
adapter_type.adapter_type
|
|
||||||
== self.provider_config.ground_truth_adapter_type
|
|
||||||
):
|
|
||||||
row = self.adapter_options_list.get_row_at_index(i)
|
|
||||||
self.adapter_options_list.select_row(row)
|
|
||||||
break
|
|
||||||
self._name_is_valid = True
|
|
||||||
self._on_next_add_clicked()
|
|
||||||
|
|
||||||
def _on_cancel_back_clicked(self, _):
|
|
||||||
if self.stage == DialogStage.SELECT_ADAPTER:
|
|
||||||
self.close()
|
|
||||||
else:
|
|
||||||
self.stack.set_visible_child_name("select")
|
|
||||||
self.stage = DialogStage.SELECT_ADAPTER
|
|
||||||
self.cancel_back_button.set_label("Cancel")
|
|
||||||
self.next_add_button.set_label("Next")
|
|
||||||
self.next_add_button.set_sensitive(True)
|
|
||||||
|
|
||||||
def _on_next_add_clicked(self, *args):
|
|
||||||
if self.stage == DialogStage.SELECT_ADAPTER:
|
|
||||||
index = self.adapter_options_list.get_selected_row().get_index()
|
|
||||||
if index != self._current_index:
|
|
||||||
for c in self.configure_box.get_children():
|
|
||||||
self.configure_box.remove(c)
|
|
||||||
|
|
||||||
name_entry_grid = Gtk.Grid(
|
|
||||||
column_spacing=10,
|
|
||||||
row_spacing=5,
|
|
||||||
margin_left=10,
|
|
||||||
margin_right=10,
|
|
||||||
name="music-source-config-name-entry-grid",
|
|
||||||
)
|
|
||||||
name_label = Gtk.Label(label="Music Source Name:")
|
|
||||||
name_entry_grid.attach(name_label, 0, 0, 1, 1)
|
|
||||||
self.name_field = Gtk.Entry(
|
|
||||||
text=self.provider_config.name if self.provider_config else "",
|
|
||||||
hexpand=True,
|
|
||||||
)
|
|
||||||
self.name_field.connect("changed", self._on_name_change)
|
|
||||||
name_entry_grid.attach(self.name_field, 1, 0, 1, 1)
|
|
||||||
self.configure_box.add(name_entry_grid)
|
|
||||||
|
|
||||||
self.configure_box.add(Gtk.Separator())
|
|
||||||
|
|
||||||
self.adapter_type = self.adapter_type_store[index].adapter_type
|
|
||||||
self.config_store = (
|
|
||||||
self.provider_config.ground_truth_adapter_config
|
|
||||||
if self.provider_config
|
|
||||||
else ConfigurationStore()
|
|
||||||
)
|
|
||||||
form = self.adapter_type.get_configuration_form(self.config_store)
|
|
||||||
form.connect("config-valid-changed", self._on_config_form_valid_changed)
|
|
||||||
self.configure_box.pack_start(form, True, True, 0)
|
|
||||||
self.configure_box.show_all()
|
|
||||||
self._adapter_config_is_valid = False
|
|
||||||
|
|
||||||
self.stack.set_visible_child_name("configure")
|
|
||||||
self.stage = DialogStage.CONFIGURE_ADAPTER
|
|
||||||
self.cancel_back_button.set_label("Change Type" if self.editing else "Back")
|
|
||||||
self.next_add_button.set_label("Edit" if self.editing else "Add")
|
|
||||||
self.next_add_button.set_sensitive(
|
|
||||||
index == self._current_index and self._adapter_config_is_valid
|
|
||||||
)
|
|
||||||
self._current_index = index
|
|
||||||
else:
|
|
||||||
if self.provider_config is None:
|
|
||||||
self.provider_config = ProviderConfiguration(
|
|
||||||
str(uuid.uuid4()),
|
|
||||||
self.name_field.get_text(),
|
|
||||||
self.adapter_type,
|
|
||||||
self.config_store,
|
|
||||||
)
|
|
||||||
if self.adapter_type.can_be_cached:
|
|
||||||
self.provider_config.caching_adapter_type = FilesystemAdapter
|
|
||||||
self.provider_config.caching_adapter_config = ConfigurationStore()
|
|
||||||
else:
|
|
||||||
self.provider_config.name = self.name_field.get_text()
|
|
||||||
self.provider_config.ground_truth_adapter_config = self.config_store
|
|
||||||
|
|
||||||
self.response(Gtk.ResponseType.APPLY)
|
|
||||||
|
|
||||||
_name_is_valid = False
|
|
||||||
_adapter_config_is_valid = False
|
|
||||||
|
|
||||||
def _update_add_button_sensitive(self):
|
|
||||||
self.next_add_button.set_sensitive(
|
|
||||||
self._name_is_valid and self._adapter_config_is_valid
|
|
||||||
)
|
|
||||||
|
|
||||||
def _on_name_change(self, entry: Gtk.Entry):
|
|
||||||
if entry.get_text():
|
|
||||||
self._name_is_valid = True
|
|
||||||
entry.get_style_context().remove_class("invalid")
|
|
||||||
entry.set_tooltip_markup(None)
|
|
||||||
|
|
||||||
if self.editing:
|
|
||||||
assert self.provider_config
|
|
||||||
self.provider_config.name = entry.get_text()
|
|
||||||
self.set_title(self.editing, self.provider_config)
|
|
||||||
else:
|
|
||||||
self._name_is_valid = False
|
|
||||||
entry.get_style_context().add_class("invalid")
|
|
||||||
entry.set_tooltip_markup("This field is required")
|
|
||||||
self._update_add_button_sensitive()
|
|
||||||
|
|
||||||
def _on_config_form_valid_changed(self, _, valid: bool):
|
|
||||||
self._adapter_config_is_valid = valid
|
|
||||||
self._update_add_button_sensitive()
|
|
File diff suppressed because it is too large
Load Diff
612
sublime_music/ui/providers.py
Normal file
612
sublime_music/ui/providers.py
Normal file
@@ -0,0 +1,612 @@
|
|||||||
|
import uuid
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Callable, Dict, List, Any
|
||||||
|
|
||||||
|
import bleach
|
||||||
|
|
||||||
|
from gi.repository import Gdk, Gtk, Handy, GLib, Pango
|
||||||
|
|
||||||
|
from ..adapters import (
|
||||||
|
AdapterManager,
|
||||||
|
api_objects as API,
|
||||||
|
DownloadProgress,
|
||||||
|
Result,
|
||||||
|
ConfigurationStore,
|
||||||
|
ConfigParamDescriptor,
|
||||||
|
)
|
||||||
|
from ..config import AppConfiguration, ProviderConfiguration
|
||||||
|
from ..players import PlayerManager
|
||||||
|
from .actions import run_action, variant_type_from_python
|
||||||
|
from .common import IconButton
|
||||||
|
|
||||||
|
|
||||||
|
class ProvidersWindow(Handy.Window):
|
||||||
|
is_initial = True
|
||||||
|
|
||||||
|
def __init__(self, main_window):
|
||||||
|
Handy.Window.__init__(self,
|
||||||
|
modal=True,
|
||||||
|
window_position=Gtk.WindowPosition.CENTER_ON_PARENT,
|
||||||
|
destroy_with_parent=True,
|
||||||
|
type_hint=Gdk.WindowTypeHint.DIALOG,
|
||||||
|
default_width=640,
|
||||||
|
default_height=576)
|
||||||
|
self.main_window = main_window
|
||||||
|
|
||||||
|
# Don't die when closed
|
||||||
|
def hide_not_destroy(*_):
|
||||||
|
if self.is_initial:
|
||||||
|
run_action(self.main_window, 'app.quit')
|
||||||
|
return True
|
||||||
|
|
||||||
|
self.hide()
|
||||||
|
return True
|
||||||
|
self.connect('delete-event', hide_not_destroy)
|
||||||
|
|
||||||
|
self.stack = Gtk.Stack()
|
||||||
|
|
||||||
|
self.create_page = CreateProviderPage(self)
|
||||||
|
self.stack.add(self.create_page)
|
||||||
|
|
||||||
|
self.configure_page = ConfigureProviderPage(self)
|
||||||
|
self.stack.add(self.configure_page)
|
||||||
|
|
||||||
|
self.status_page = ProviderStatusPage(self)
|
||||||
|
self.stack.add(self.status_page)
|
||||||
|
|
||||||
|
self.add(self.stack)
|
||||||
|
|
||||||
|
def update(self, app_config: AppConfiguration, player_manager: PlayerManager):
|
||||||
|
self.is_initial = app_config.current_provider_id is None
|
||||||
|
|
||||||
|
self.status_page.update(app_config, player_manager)
|
||||||
|
|
||||||
|
def _set_transition(self, going_back: bool):
|
||||||
|
if not self.is_visible():
|
||||||
|
self.stack.set_transition_type(Gtk.StackTransitionType.NONE)
|
||||||
|
return
|
||||||
|
|
||||||
|
if going_back:
|
||||||
|
self.stack.set_transition_type(Gtk.StackTransitionType.SLIDE_RIGHT)
|
||||||
|
else:
|
||||||
|
self.stack.set_transition_type(Gtk.StackTransitionType.SLIDE_LEFT)
|
||||||
|
|
||||||
|
def open_create_page(self, go_back: Optional[Callable] = None, going_back=False):
|
||||||
|
self._set_transition(going_back)
|
||||||
|
|
||||||
|
self.create_page.setup(go_back)
|
||||||
|
self.create_page.show()
|
||||||
|
self.stack.set_visible_child(self.create_page)
|
||||||
|
|
||||||
|
def open_configure_page(self, id, adapter, config, go_back: Callable):
|
||||||
|
self._set_transition(False)
|
||||||
|
|
||||||
|
self.configure_page.setup(id, adapter, config, go_back)
|
||||||
|
self.configure_page.show()
|
||||||
|
self.stack.set_visible_child(self.configure_page)
|
||||||
|
|
||||||
|
def open_status_page(self):
|
||||||
|
self._set_transition(True)
|
||||||
|
|
||||||
|
self.status_page.show()
|
||||||
|
self.stack.set_visible_child(self.status_page)
|
||||||
|
|
||||||
|
|
||||||
|
class CreateProviderPage(Gtk.Box):
|
||||||
|
def __init__(self, window):
|
||||||
|
Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL)
|
||||||
|
self.window = window
|
||||||
|
|
||||||
|
header_bar = Handy.HeaderBar(title="Add Music Source")
|
||||||
|
|
||||||
|
self.cancel_button = Gtk.Button(label='Quit')
|
||||||
|
self.cancel_button.connect('clicked', self._on_cancel_clicked)
|
||||||
|
header_bar.pack_start(self.cancel_button)
|
||||||
|
|
||||||
|
self.add(header_bar)
|
||||||
|
|
||||||
|
scrolled_window = Gtk.ScrolledWindow()
|
||||||
|
scrolled_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||||
|
|
||||||
|
list_box = Gtk.ListBox(expand=True, selection_mode=Gtk.SelectionMode.NONE)
|
||||||
|
|
||||||
|
adapters = AdapterManager.available_adapters
|
||||||
|
adapters = sorted(
|
||||||
|
filter(lambda a: a.can_be_ground_truth, adapters),
|
||||||
|
key=lambda a: a.get_ui_info().name)
|
||||||
|
|
||||||
|
for adapter in adapters:
|
||||||
|
ui_info: UIInfo = adapter.get_ui_info()
|
||||||
|
|
||||||
|
row = Handy.ActionRow(
|
||||||
|
title=ui_info.name,
|
||||||
|
icon_name=ui_info.icon_name(),
|
||||||
|
subtitle=ui_info.description,
|
||||||
|
subtitle_lines=4,
|
||||||
|
use_underline=True,
|
||||||
|
activatable=True)
|
||||||
|
|
||||||
|
def activated(*_, adapter=adapter):
|
||||||
|
self.window.open_configure_page(
|
||||||
|
None,
|
||||||
|
adapter,
|
||||||
|
ConfigurationStore(),
|
||||||
|
lambda: self.window.open_create_page(self._go_back, going_back=True))
|
||||||
|
row.connect('activated', activated)
|
||||||
|
|
||||||
|
row.add(Gtk.Image(icon_name='go-next-symbolic'))
|
||||||
|
|
||||||
|
list_box.add(row)
|
||||||
|
|
||||||
|
scrolled_box.add(list_box)
|
||||||
|
|
||||||
|
scrolled_window.add(scrolled_box)
|
||||||
|
self.pack_start(scrolled_window, True, True, 0)
|
||||||
|
|
||||||
|
def setup(self, go_back: Optional[Callable]):
|
||||||
|
self._go_back = go_back
|
||||||
|
|
||||||
|
if go_back:
|
||||||
|
self.cancel_button.set_label("Cancel")
|
||||||
|
else:
|
||||||
|
self.cancel_button.set_label("Quit")
|
||||||
|
|
||||||
|
def _on_cancel_clicked(self, *_):
|
||||||
|
if self._go_back:
|
||||||
|
self._go_back()
|
||||||
|
else:
|
||||||
|
self.window.close()
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigureProviderPage(Gtk.Box):
|
||||||
|
_id = None
|
||||||
|
_adapter = None
|
||||||
|
_go_back = None
|
||||||
|
_config_store = None
|
||||||
|
_config_widgets = {}
|
||||||
|
_config_updates = {}
|
||||||
|
_required_fields: Dict[str, Callable[[Any], Optional[str]]] = {}
|
||||||
|
|
||||||
|
_errors = {}
|
||||||
|
_validation_ratchet = 0
|
||||||
|
_had_all_required = False
|
||||||
|
|
||||||
|
def __init__(self, window):
|
||||||
|
Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL)
|
||||||
|
self.window = window
|
||||||
|
|
||||||
|
self.header_bar = Handy.HeaderBar(show_close_button=False, title="Configuration")
|
||||||
|
|
||||||
|
back_button = Gtk.Button(label="Back")
|
||||||
|
back_button.connect('clicked', lambda *_: self._go_back())
|
||||||
|
self.header_bar.pack_start(back_button)
|
||||||
|
|
||||||
|
self.create_button = Gtk.Button(label="Create", sensitive=False)
|
||||||
|
self.create_button.get_style_context().add_class('suggested-action')
|
||||||
|
self.create_button.connect('clicked', self._on_create_clicked)
|
||||||
|
self.header_bar.pack_end(self.create_button)
|
||||||
|
|
||||||
|
self.add(self.header_bar)
|
||||||
|
|
||||||
|
self.status_revealer = Gtk.Revealer(reveal_child=False)
|
||||||
|
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10, margin=10)
|
||||||
|
|
||||||
|
self.status_stack = Gtk.Stack()
|
||||||
|
|
||||||
|
self.status_spinner = Gtk.Spinner()
|
||||||
|
self.status_stack.add(self.status_spinner)
|
||||||
|
|
||||||
|
self.status_ok_image = Gtk.Image(icon_name="config-ok-symbolic")
|
||||||
|
self.status_stack.add(self.status_ok_image)
|
||||||
|
|
||||||
|
self.status_error_image = Gtk.Image(icon_name="config-error-symbolic")
|
||||||
|
self.status_stack.add(self.status_error_image)
|
||||||
|
|
||||||
|
box.pack_start(self.status_stack, False, True, 0)
|
||||||
|
|
||||||
|
self.status_label = Gtk.Label(ellipsize=Pango.EllipsizeMode.END, lines=4, justify=Gtk.Justification.LEFT, wrap=True)
|
||||||
|
box.pack_start(self.status_label, False, True, 0)
|
||||||
|
|
||||||
|
self.status_revealer.add(box)
|
||||||
|
|
||||||
|
self.pack_start(self.status_revealer, False, True, 0)
|
||||||
|
|
||||||
|
scrolled_window = Gtk.ScrolledWindow()
|
||||||
|
|
||||||
|
clamp = Handy.Clamp(margin=12)
|
||||||
|
|
||||||
|
self.settings_list = Gtk.ListBox()
|
||||||
|
self.settings_list.get_style_context().add_class('content')
|
||||||
|
clamp.add(self.settings_list)
|
||||||
|
|
||||||
|
scrolled_window.add(clamp)
|
||||||
|
|
||||||
|
self.pack_start(scrolled_window, True, True, 0)
|
||||||
|
|
||||||
|
def setup(self, id_, adapter, config_store, go_back):
|
||||||
|
self._id = id_
|
||||||
|
self._adapter = adapter
|
||||||
|
self._go_back = go_back
|
||||||
|
|
||||||
|
# Reset
|
||||||
|
self._config_store = config_store
|
||||||
|
self._config_updates = {}
|
||||||
|
self._required_fields = {}
|
||||||
|
|
||||||
|
self._errors = {}
|
||||||
|
self._validation_ratchet = 0
|
||||||
|
self._had_all_required = False
|
||||||
|
|
||||||
|
self.create_button.set_sensitive(False)
|
||||||
|
|
||||||
|
if self._id is not None:
|
||||||
|
self.create_button.set_label('Save')
|
||||||
|
else:
|
||||||
|
self.create_button.set_label('Create')
|
||||||
|
|
||||||
|
for child in self.settings_list.get_children():
|
||||||
|
self.settings_list.remove(child)
|
||||||
|
|
||||||
|
name = adapter.get_ui_info().name
|
||||||
|
self.header_bar.set_title(f'{name} Configuration')
|
||||||
|
|
||||||
|
# Reset status revealer
|
||||||
|
self.status_revealer.set_reveal_child(False)
|
||||||
|
|
||||||
|
# First row is always the name
|
||||||
|
name_row = self._create_entry_row('name', ConfigParamDescriptor(str, 'Name'))
|
||||||
|
|
||||||
|
if 'name' in self._config_store:
|
||||||
|
self._config_updates['name'](self._config_store['name'])
|
||||||
|
|
||||||
|
self.settings_list.add(name_row)
|
||||||
|
|
||||||
|
# Collected advanced settings in an expander row
|
||||||
|
advanced_row = Handy.ExpanderRow(title="Advanced", expanded=False)
|
||||||
|
|
||||||
|
self._params, self._validate = adapter.get_configuration_form()
|
||||||
|
|
||||||
|
for name, config in self._params.items():
|
||||||
|
row = {
|
||||||
|
str: self._create_entry_row,
|
||||||
|
bool: self._create_switch_row,
|
||||||
|
int: self._create_spin_button_row,
|
||||||
|
'password': lambda *a: self._create_entry_row(*a, is_password=True),
|
||||||
|
'option': self._create_option_row,
|
||||||
|
Path: self._create_path_row,
|
||||||
|
}[config.type](name, config)
|
||||||
|
|
||||||
|
# Set the initial value from the config store
|
||||||
|
if name in self._config_store:
|
||||||
|
self._config_updates[name](self._config_store[name])
|
||||||
|
elif config.default is not None:
|
||||||
|
self._config_store[name] = config.default
|
||||||
|
|
||||||
|
if config.advanced:
|
||||||
|
advanced_row.add(row)
|
||||||
|
else:
|
||||||
|
self.settings_list.add(row)
|
||||||
|
|
||||||
|
|
||||||
|
self.settings_list.add(advanced_row)
|
||||||
|
|
||||||
|
self.show_all()
|
||||||
|
|
||||||
|
def _create_entry_row(self, name, config, is_password=False):
|
||||||
|
row = Handy.ActionRow(title=config.description, subtitle=config.helptext, subtitle_lines=4, can_focus=False, selectable=False)
|
||||||
|
entry = Gtk.Entry(
|
||||||
|
valign=Gtk.Align.CENTER,
|
||||||
|
visibility=not is_password,
|
||||||
|
input_purpose=Gtk.InputPurpose.PASSWORD if is_password else Gtk.InputPurpose.FREE_FORM)
|
||||||
|
|
||||||
|
if config.default is not None:
|
||||||
|
if is_password:
|
||||||
|
entry.set_text(config.default[1])
|
||||||
|
else:
|
||||||
|
entry.set_text(config.default)
|
||||||
|
|
||||||
|
if config.required:
|
||||||
|
if is_password:
|
||||||
|
self._required_fields[name] = lambda v: len(v[1]) > 0
|
||||||
|
else:
|
||||||
|
self._required_fields[name] = lambda v: len(v) > 0
|
||||||
|
|
||||||
|
entry.connect('notify::text', lambda *_: self._update_config(name, entry.get_text(), is_password))
|
||||||
|
|
||||||
|
def set_value(value):
|
||||||
|
if is_password:
|
||||||
|
if value[0] != 'plaintext':
|
||||||
|
return
|
||||||
|
|
||||||
|
value = value[1]
|
||||||
|
entry.set_text(value)
|
||||||
|
self._config_updates[name] = set_value
|
||||||
|
self._config_widgets[name] = entry
|
||||||
|
|
||||||
|
row.add(entry)
|
||||||
|
return row
|
||||||
|
|
||||||
|
def _create_switch_row(self, name, config):
|
||||||
|
row = Handy.ActionRow(title=config.description, subtitle=config.helptext, subtitle_lines=4, can_focus=False, selectable=False)
|
||||||
|
switch = Gtk.Switch(valign=Gtk.Align.CENTER)
|
||||||
|
|
||||||
|
assert config.default is not None
|
||||||
|
switch.set_active(config.default)
|
||||||
|
|
||||||
|
switch.connect('notify::active', lambda *_: self._update_config(name, switch.get_active()))
|
||||||
|
self._config_updates[name] = switch.set_active
|
||||||
|
self._config_widgets[name] = switch
|
||||||
|
|
||||||
|
row.add(switch)
|
||||||
|
return row
|
||||||
|
|
||||||
|
def _create_spin_button_row(self, name, config):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _create_option_row(self, name, config):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _create_path_row(self, name, config):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _verify_required(self):
|
||||||
|
errors = {}
|
||||||
|
for name, verify in self._required_fields.items():
|
||||||
|
if name not in self._config_store or not verify(self._config_store[name]):
|
||||||
|
errors[name] = 'Missing field'
|
||||||
|
return errors
|
||||||
|
|
||||||
|
def _update_config(self, name, value, is_secret=False):
|
||||||
|
if is_secret:
|
||||||
|
self._config_store.set_secret(name, value)
|
||||||
|
else:
|
||||||
|
self._config_store[name] = value
|
||||||
|
|
||||||
|
# Reset errors
|
||||||
|
for name in self._errors.keys():
|
||||||
|
widget = self._config_widgets[name]
|
||||||
|
widget.get_style_context().remove_class('error')
|
||||||
|
widget.set_tooltip_markup(None)
|
||||||
|
|
||||||
|
self._errors = {}
|
||||||
|
self.create_button.set_sensitive(False)
|
||||||
|
|
||||||
|
if not self._had_all_required and self._verify_required():
|
||||||
|
return
|
||||||
|
|
||||||
|
self.status_revealer.set_reveal_child(True)
|
||||||
|
self._update_status(True, '')
|
||||||
|
|
||||||
|
self._had_all_required = True
|
||||||
|
|
||||||
|
self._validation_ratchet += 1
|
||||||
|
ratchet = self._validation_ratchet
|
||||||
|
|
||||||
|
def on_verify_result(errors: Optional[Dict[str, str]]):
|
||||||
|
if errors is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
errors.update(self._verify_required())
|
||||||
|
|
||||||
|
# Update controls after validation, as fields may have changed
|
||||||
|
for name, update in self._config_updates.items():
|
||||||
|
if name in self._config_store:
|
||||||
|
update(self._config_store[name])
|
||||||
|
|
||||||
|
self._errors = errors
|
||||||
|
|
||||||
|
self.create_button.set_sensitive(len(errors) == 0)
|
||||||
|
|
||||||
|
if '__ping__' in self._errors:
|
||||||
|
ping_error = self._errors.pop('__ping__')
|
||||||
|
else:
|
||||||
|
ping_error = None
|
||||||
|
|
||||||
|
for name, error in self._errors.items():
|
||||||
|
widget = self._config_widgets[name]
|
||||||
|
widget.get_style_context().add_class('error')
|
||||||
|
widget.set_tooltip_markup(error)
|
||||||
|
|
||||||
|
self._update_status(False, ping_error)
|
||||||
|
|
||||||
|
def validate():
|
||||||
|
time.sleep(0.75)
|
||||||
|
if self._validation_ratchet != ratchet:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return self._validate(self._config_store)
|
||||||
|
|
||||||
|
result = Result(validate)
|
||||||
|
result.add_done_callback(
|
||||||
|
lambda f: GLib.idle_add(on_verify_result, f.result())
|
||||||
|
)
|
||||||
|
|
||||||
|
def _update_status(self, verifying: bool, error: Optional[str]):
|
||||||
|
if verifying:
|
||||||
|
self.status_spinner.start()
|
||||||
|
self.status_stack.set_visible_child(self.status_spinner)
|
||||||
|
self.status_label.set_markup('<b>Verifying Connection...</b>')
|
||||||
|
elif error:
|
||||||
|
self.status_stack.set_visible_child(self.status_error_image)
|
||||||
|
self.status_label.set_markup(bleach.clean(error))
|
||||||
|
else:
|
||||||
|
self.status_stack.set_visible_child(self.status_ok_image)
|
||||||
|
self.status_label.set_markup('<b>Connected Successfully</b>')
|
||||||
|
|
||||||
|
if not verifying:
|
||||||
|
self.status_spinner.stop()
|
||||||
|
|
||||||
|
def _on_create_clicked(self, *_):
|
||||||
|
id = self._id or str(uuid.uuid4())
|
||||||
|
name = self._config_store.pop('name')
|
||||||
|
|
||||||
|
config = {}
|
||||||
|
for key, item in self._config_store.items():
|
||||||
|
type = self._params[key].type
|
||||||
|
if type == 'password':
|
||||||
|
type = List[str]
|
||||||
|
elif type == 'option':
|
||||||
|
type = str
|
||||||
|
|
||||||
|
config[key] = GLib.Variant(variant_type_from_python(type), item)
|
||||||
|
|
||||||
|
adapter_name = self._adapter.get_ui_info().name
|
||||||
|
run_action(
|
||||||
|
self.window.main_window,
|
||||||
|
'providers.set-config',
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
adapter_name,
|
||||||
|
config)
|
||||||
|
|
||||||
|
GLib.idle_add(lambda *_: self.window.close())
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderStatusPage(Gtk.Box):
|
||||||
|
_current_provider = None
|
||||||
|
|
||||||
|
def __init__(self, window):
|
||||||
|
Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL)
|
||||||
|
self.window = window
|
||||||
|
|
||||||
|
header_bar = Handy.HeaderBar(show_close_button=True, title="Connection Status")
|
||||||
|
self.add(header_bar)
|
||||||
|
|
||||||
|
scrolled_window = Gtk.ScrolledWindow()
|
||||||
|
|
||||||
|
clamp = Handy.Clamp(margin=12)
|
||||||
|
|
||||||
|
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
|
||||||
|
|
||||||
|
self.title = Gtk.Label(wrap=True, justify=Gtk.Justification.LEFT)
|
||||||
|
self.title.get_style_context().add_class('title')
|
||||||
|
self.title.get_style_context().add_class('large-title')
|
||||||
|
box.add(self.title)
|
||||||
|
|
||||||
|
self.status_stack = Gtk.Stack()
|
||||||
|
|
||||||
|
self.status_offline = self._create_status_box('offline', 'Offline')
|
||||||
|
self.status_stack.add(self.status_offline)
|
||||||
|
|
||||||
|
self.status_connected = self._create_status_box('connected', 'Connected')
|
||||||
|
self.status_stack.add(self.status_connected)
|
||||||
|
|
||||||
|
self.status_error = self._create_status_box('error', 'Error Connecting to Server')
|
||||||
|
self.status_stack.add(self.status_error)
|
||||||
|
|
||||||
|
box.add(self.status_stack)
|
||||||
|
|
||||||
|
list_box = Gtk.ListBox()
|
||||||
|
list_box.get_style_context().add_class('content')
|
||||||
|
|
||||||
|
row = Handy.ActionRow(title='Offline Mode', can_focus=False, selectable=False)
|
||||||
|
self.offline_switch = Gtk.Switch(valign=Gtk.Align.CENTER)
|
||||||
|
|
||||||
|
row.add(self.offline_switch)
|
||||||
|
|
||||||
|
list_box.add(row)
|
||||||
|
|
||||||
|
row = Handy.ActionRow(
|
||||||
|
title='Edit Configuration...',
|
||||||
|
use_underline=True,
|
||||||
|
activatable=True)
|
||||||
|
|
||||||
|
def activated(*_):
|
||||||
|
config = self._current_provider.ground_truth_adapter_config.clone()
|
||||||
|
config['name'] = self._current_provider.name
|
||||||
|
|
||||||
|
self.window.open_configure_page(
|
||||||
|
self._current_provider.id,
|
||||||
|
self._current_provider.ground_truth_adapter_type,
|
||||||
|
config,
|
||||||
|
lambda: self.window.open_status_page())
|
||||||
|
row.connect('activated', activated)
|
||||||
|
|
||||||
|
row.add(Gtk.Image(icon_name='go-next-symbolic'))
|
||||||
|
|
||||||
|
list_box.add(row)
|
||||||
|
|
||||||
|
self.provider_list = Handy.ExpanderRow(title="Other Providers", expanded=False)
|
||||||
|
|
||||||
|
add_button = IconButton(icon_name='list-add-symbolic', valign=Gtk.Align.CENTER, relief=True)
|
||||||
|
|
||||||
|
def add_clicked(*_):
|
||||||
|
self.window.open_create_page(lambda: self.window.open_status_page())
|
||||||
|
add_button.connect('clicked', add_clicked)
|
||||||
|
|
||||||
|
self.provider_list.add_action(add_button)
|
||||||
|
|
||||||
|
list_box.add(self.provider_list)
|
||||||
|
|
||||||
|
box.pack_start(list_box, False, True, 10)
|
||||||
|
|
||||||
|
clamp.add(box)
|
||||||
|
|
||||||
|
scrolled_window.add(clamp)
|
||||||
|
|
||||||
|
self.pack_start(scrolled_window, True, True, 0)
|
||||||
|
|
||||||
|
def _create_status_box(self, icon: str, label: str):
|
||||||
|
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10, halign=Gtk.Align.CENTER)
|
||||||
|
|
||||||
|
icon = Gtk.Image(icon_name=f"server-{icon}-symbolic")
|
||||||
|
box.add(icon)
|
||||||
|
|
||||||
|
label = Gtk.Label(label=label)
|
||||||
|
box.add(label)
|
||||||
|
|
||||||
|
return box
|
||||||
|
|
||||||
|
_other_providers_cache = None
|
||||||
|
_other_providers = []
|
||||||
|
|
||||||
|
def update(self, app_config: AppConfiguration, player_manager: PlayerManager):
|
||||||
|
assert AdapterManager.ground_truth_adapter_is_networked
|
||||||
|
|
||||||
|
self._current_provider = app_config.provider
|
||||||
|
|
||||||
|
self.title.set_label(app_config.provider.name)
|
||||||
|
|
||||||
|
if app_config.offline_mode:
|
||||||
|
self.status_stack.set_visible_child(self.status_offline)
|
||||||
|
elif AdapterManager.get_ping_status():
|
||||||
|
self.status_stack.set_visible_child(self.status_connected)
|
||||||
|
else:
|
||||||
|
self.status_stack.set_visible_child(self.status_error)
|
||||||
|
|
||||||
|
other_providers = [id for id in app_config.providers.keys() if id != app_config.current_provider_id]
|
||||||
|
|
||||||
|
if self._other_providers_cache is None or self._other_providers_cache != other_providers:
|
||||||
|
self._other_providers_cache = other_providers
|
||||||
|
|
||||||
|
for child in self._other_providers:
|
||||||
|
self.provider_list.remove(child)
|
||||||
|
self._other_providers = []
|
||||||
|
|
||||||
|
self.provider_list.set_enable_expansion(len(other_providers) > 0)
|
||||||
|
|
||||||
|
for id in other_providers:
|
||||||
|
provider = app_config.providers[id]
|
||||||
|
|
||||||
|
row = Handy.ActionRow(title=provider.name, can_focus=False, selectable=False)
|
||||||
|
|
||||||
|
button = Gtk.Button(label="Switch", valign=Gtk.Align.CENTER)
|
||||||
|
def on_clicked(*_, id=id):
|
||||||
|
run_action(self.window.main_window, 'providers.switch', id)
|
||||||
|
button.connect('clicked', on_clicked)
|
||||||
|
|
||||||
|
row.add(button)
|
||||||
|
|
||||||
|
button = IconButton(icon_name='user-trash-symbolic', valign=Gtk.Align.CENTER, relief=True)
|
||||||
|
button.get_style_context().add_class('destructive-action')
|
||||||
|
def on_clicked(*_, id=id):
|
||||||
|
run_action(self.window.main_window, 'providers.remove', id)
|
||||||
|
button.connect('clicked', on_clicked)
|
||||||
|
|
||||||
|
row.add(button)
|
||||||
|
|
||||||
|
self.provider_list.add(row)
|
||||||
|
self._other_providers.append(row)
|
||||||
|
|
||||||
|
self.provider_list.show_all()
|
Reference in New Issue
Block a user