diff --git a/pkgs/clan-cli/clan_cli/clan_uri.py b/pkgs/clan-cli/clan_cli/clan_uri.py index 21c438de4..a28c29793 100644 --- a/pkgs/clan-cli/clan_cli/clan_uri.py +++ b/pkgs/clan-cli/clan_cli/clan_uri.py @@ -117,6 +117,11 @@ class ClanURI: def get_full_uri(self) -> str: return self._full_uri + # TODO(@Qubasa): return a comparable id e.g. f"{url}#{attr}" + # This should be our standard. + def get_id(self) -> str: + return f"{self._components.path}#{self._components.fragment}" + @classmethod def from_path( cls, # noqa diff --git a/pkgs/clan-vm-manager/clan_vm_manager/__init__.py b/pkgs/clan-vm-manager/clan_vm_manager/__init__.py index 9c9fdfa75..337fd79a1 100644 --- a/pkgs/clan-vm-manager/clan_vm_manager/__init__.py +++ b/pkgs/clan-vm-manager/clan_vm_manager/__init__.py @@ -28,7 +28,7 @@ def main() -> None: def show_join(args: argparse.Namespace) -> None: app = MainApplication( - config=ClanConfig(url=args.clan_uri, initial_view="join.trust"), + config=ClanConfig(url=args.clan_uri, initial_view="list"), ) return app.run() diff --git a/pkgs/clan-vm-manager/clan_vm_manager/app.py b/pkgs/clan-vm-manager/clan_vm_manager/app.py index 76a36f0ff..4a4e10582 100644 --- a/pkgs/clan-vm-manager/clan_vm_manager/app.py +++ b/pkgs/clan-vm-manager/clan_vm_manager/app.py @@ -3,6 +3,8 @@ from pathlib import Path import gi +from clan_vm_manager.models.use_join import Join + gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") @@ -23,6 +25,9 @@ class MainApplication(Adw.Application): self.config = config self.connect("shutdown", self.on_shutdown) + if config.url: + Join.use().push(config.url) + def on_shutdown(self, app: Gtk.Application) -> None: print("Shutting down") VMS.use().kill_all() diff --git a/pkgs/clan-vm-manager/clan_vm_manager/models/interfaces.py b/pkgs/clan-vm-manager/clan_vm_manager/models/interfaces.py index 12c54bdd6..64e993a08 100644 --- a/pkgs/clan-vm-manager/clan_vm_manager/models/interfaces.py +++ b/pkgs/clan-vm-manager/clan_vm_manager/models/interfaces.py @@ -7,11 +7,6 @@ from clan_cli.clan_uri import ClanURI gi.require_version("Gtk", "4.0") -@dataclass -class InitialJoinValues: - url: ClanURI | None - - @dataclass class ClanConfig: initial_view: str diff --git a/pkgs/clan-vm-manager/clan_vm_manager/models/use_join.py b/pkgs/clan-vm-manager/clan_vm_manager/models/use_join.py new file mode 100644 index 000000000..f96dd79e3 --- /dev/null +++ b/pkgs/clan-vm-manager/clan_vm_manager/models/use_join.py @@ -0,0 +1,73 @@ +from collections.abc import Callable +from typing import Any + +import gi +from clan_cli import ClanError +from clan_cli.clan_uri import ClanURI +from clan_cli.history.add import HistoryEntry, add_history + +from clan_vm_manager.errors.show_error import show_error_dialog + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") +from gi.repository import Gio, GObject + + +class JoinValue(GObject.Object): + # TODO: custom signals for async join + # __gsignals__: ClassVar = {} + + url: ClanURI + + def __init__(self, url: ClanURI) -> None: + super().__init__() + self.url = url + + +class Join: + """ + This is a singleton. + It is initialized with the first call of use() + """ + + _instance: "None | Join" = None + list_store: Gio.ListStore + + # Make sure the VMS class is used as a singleton + def __init__(self) -> None: + raise RuntimeError("Call use() instead") + + @classmethod + def use(cls: Any) -> "Join": + if cls._instance is None: + print("Creating new instance") + cls._instance = cls.__new__(cls) + cls.list_store = Gio.ListStore.new(JoinValue) + + return cls._instance + + def push(self, url: ClanURI) -> None: + """ + Add a join request. + This method can add multiple join requests if called subsequently for each request. + """ + self.list_store.append(JoinValue(url)) + + def join(self, item: JoinValue, cb: Callable[[list[HistoryEntry]], None]) -> None: + # TODO: remove the item that was accepted join from this list + # and call the success function. (The caller is responsible for handling the success) + try: + print(f"trying to join: {item.url}") + + history = add_history(item.url) + cb(history) + self.discard(item) + + except ClanError as e: + show_error_dialog(e) + pass + + def discard(self, item: JoinValue) -> None: + (has, idx) = self.list_store.find(item) + if has: + self.list_store.remove(idx) diff --git a/pkgs/clan-vm-manager/clan_vm_manager/models/use_vms.py b/pkgs/clan-vm-manager/clan_vm_manager/models/use_vms.py index d2caa2877..33647cd68 100644 --- a/pkgs/clan-vm-manager/clan_vm_manager/models/use_vms.py +++ b/pkgs/clan-vm-manager/clan_vm_manager/models/use_vms.py @@ -142,6 +142,11 @@ class VMS: for vm in self.get_running_vms(): vm.stop() + def refresh(self) -> None: + self.list_store.remove_all() + for vm in get_initial_vms(): + self.list_store.append(vm) + def get_initial_vms() -> list[VM]: vm_list = [] diff --git a/pkgs/clan-vm-manager/clan_vm_manager/style.css b/pkgs/clan-vm-manager/clan_vm_manager/style.css index e91505303..7413c62a0 100644 --- a/pkgs/clan-vm-manager/clan_vm_manager/style.css +++ b/pkgs/clan-vm-manager/clan_vm_manager/style.css @@ -12,5 +12,20 @@ avatar { } .trust { - padding: 25px; -} \ No newline at end of file + padding-top: 25px; + padding-bottom: 25px; +} + +.vm-list { + margin-top: 25px; + margin-bottom: 25px; +} + +.no-shadow { + box-shadow: none; +} + +/* TODO: Disable shadow for empty lists */ +/* list:empty { + box-shadow: none; +} */ \ No newline at end of file diff --git a/pkgs/clan-vm-manager/clan_vm_manager/views/list.py b/pkgs/clan-vm-manager/clan_vm_manager/views/list.py index cd2132c3d..28f37a532 100644 --- a/pkgs/clan-vm-manager/clan_vm_manager/views/list.py +++ b/pkgs/clan-vm-manager/clan_vm_manager/views/list.py @@ -1,13 +1,29 @@ +from collections.abc import Callable from functools import partial import gi +from clan_cli.history.add import HistoryEntry + +from clan_vm_manager.models.use_join import Join, JoinValue gi.require_version("Adw", "1") -from gi.repository import Adw, Gdk, Gtk +from gi.repository import Adw, Gdk, Gio, GObject, Gtk from clan_vm_manager.models.use_vms import VM, VMS +def create_boxed_list( + model: Gio.ListStore, render_row: Callable[[Gtk.ListBox, GObject], Gtk.Widget] +) -> Gtk.ListBox: + boxed_list = Gtk.ListBox() + boxed_list.set_selection_mode(Gtk.SelectionMode.NONE) + boxed_list.add_css_class("boxed-list") + boxed_list.add_css_class("no-shadow") + + boxed_list.bind_model(model, create_widget_func=partial(render_row, boxed_list)) + return boxed_list + + class ClanList(Gtk.Box): """ The ClanList @@ -25,52 +41,96 @@ class ClanList(Gtk.Box): def __init__(self) -> None: super().__init__(orientation=Gtk.Orientation.VERTICAL) - boxed_list = Gtk.ListBox() - boxed_list.set_selection_mode(Gtk.SelectionMode.NONE) - boxed_list.add_css_class("boxed-list") - - def create_widget(item: VM) -> Gtk.Widget: - flake = item.data.flake - row = Adw.ActionRow() - - print("Creating", item.data.flake.flake_attr) - # Title - row.set_title(flake.clan_name) - row.set_title_lines(1) - row.set_title_selectable(True) - - # Subtitle - row.set_subtitle(flake.flake_attr) - row.set_subtitle_lines(1) - - # Avatar - avatar = Adw.Avatar() - avatar.set_custom_image(Gdk.Texture.new_from_filename(flake.icon)) - avatar.set_text(flake.clan_name + " " + flake.flake_attr) - avatar.set_show_initials(True) - avatar.set_size(50) - row.add_prefix(avatar) - - # Switch - switch = Gtk.Switch() - box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - box.set_valign(Gtk.Align.CENTER) - box.append(switch) - - switch.connect("notify::active", partial(self.on_row_toggle, item)) - row.add_suffix(box) - - return row - vms = VMS.use() + join = Join.use() # TODO: Move this up to create_widget and connect every VM signal to its corresponding switch vms.handle_vm_stopped(self.stopped_vm) vms.handle_vm_started(self.started_vm) - boxed_list.bind_model(vms.list_store, create_widget_func=create_widget) + self.join_boxed_list = create_boxed_list( + model=join.list_store, render_row=self.render_join_row + ) - self.append(boxed_list) + self.vm_boxed_list = create_boxed_list( + model=vms.list_store, render_row=self.render_vm_row + ) + self.vm_boxed_list.add_css_class("vm-list") + + self.append(self.join_boxed_list) + self.append(self.vm_boxed_list) + + def render_vm_row(self, boxed_list: Gtk.ListBox, item: VM) -> Gtk.Widget: + if boxed_list.has_css_class("no-shadow"): + boxed_list.remove_css_class("no-shadow") + flake = item.data.flake + row = Adw.ActionRow() + + print("Creating", item.data.flake.flake_attr) + # Title + row.set_title(flake.clan_name) + + row.set_title_lines(1) + row.set_title_selectable(True) + + # Subtitle + row.set_subtitle(flake.flake_attr) + row.set_subtitle_lines(1) + + # Avatar + avatar = Adw.Avatar() + avatar.set_custom_image(Gdk.Texture.new_from_filename(flake.icon)) + avatar.set_text(flake.clan_name + " " + flake.flake_attr) + avatar.set_show_initials(True) + avatar.set_size(50) + row.add_prefix(avatar) + + # Switch + switch = Gtk.Switch() + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + box.set_valign(Gtk.Align.CENTER) + box.append(switch) + + switch.connect("notify::active", partial(self.on_row_toggle, item)) + row.add_suffix(box) + + return row + + def render_join_row(self, boxed_list: Gtk.ListBox, item: JoinValue) -> Gtk.Widget: + if boxed_list.has_css_class("no-shadow"): + boxed_list.remove_css_class("no-shadow") + + row = Adw.ActionRow() + + row.set_title(str(item.url)) + row.add_css_class("trust") + + # TODO: figure out how to detect that + if True: + row.set_subtitle("Clan already exists. Joining again will update it") + + avatar = Adw.Avatar() + avatar.set_text(str(item.url)) + avatar.set_show_initials(True) + avatar.set_size(50) + row.add_prefix(avatar) + + cancel_button = Gtk.Button(label="Cancel") + cancel_button.add_css_class("error") + cancel_button.connect("clicked", partial(self.on_discard_clicked, item)) + + trust_button = Gtk.Button(label="Join") + trust_button.add_css_class("success") + trust_button.connect("clicked", partial(self.on_trust_clicked, item)) + + box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) + box.set_valign(Gtk.Align.CENTER) + box.append(cancel_button) + box.append(trust_button) + + row.add_suffix(box) + + return row def started_vm(self, vm: VM, _vm: VM) -> None: print("VM started", vm.data.flake.flake_attr) @@ -89,6 +149,21 @@ class ClanList(Gtk.Box): dialog.run() dialog.destroy() + def on_trust_clicked(self, item: JoinValue, widget: Gtk.Widget) -> None: + def on_join(_history: list[HistoryEntry]) -> None: + VMS.use().refresh() + + Join.use().join(item, cb=on_join) + + # If the join request list is empty disable the shadow artefact + if not Join.use().list_store.get_n_items(): + self.join_boxed_list.add_css_class("no-shadow") + + def on_discard_clicked(self, item: JoinValue, widget: Gtk.Widget) -> None: + Join.use().discard(item) + if not Join.use().list_store.get_n_items(): + self.join_boxed_list.add_css_class("no-shadow") + def on_row_toggle(self, vm: VM, row: Adw.SwitchRow, state: bool) -> None: print("Toggled", vm.data.flake.flake_attr, "active:", row.get_active()) diff --git a/pkgs/clan-vm-manager/clan_vm_manager/views/trust_join.py b/pkgs/clan-vm-manager/clan_vm_manager/views/trust_join.py index 3e22252ef..d0b69d277 100644 --- a/pkgs/clan-vm-manager/clan_vm_manager/views/trust_join.py +++ b/pkgs/clan-vm-manager/clan_vm_manager/views/trust_join.py @@ -1,12 +1,11 @@ from functools import partial import gi -from clan_cli.clan_uri import ClanURI from clan_cli.errors import ClanError from clan_cli.history.add import add_history from clan_vm_manager.errors.show_error import show_error_dialog -from clan_vm_manager.models.interfaces import InitialJoinValues +from clan_vm_manager.models.use_join import JoinValue gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") @@ -16,9 +15,9 @@ from gi.repository import Adw, Gio, GObject, Gtk class TrustValues(GObject.Object): - data: InitialJoinValues + data: JoinValue - def __init__(self, data: InitialJoinValues) -> None: + def __init__(self, data: JoinValue) -> None: super().__init__() print("TrustValues", data) self.data = data @@ -27,13 +26,11 @@ class TrustValues(GObject.Object): class Trust(Gtk.Box): def __init__( self, - *, - initial_values: InitialJoinValues, ) -> None: super().__init__(orientation=Gtk.Orientation.VERTICAL) # self.on_trust = on_trust - self.url: ClanURI | None = initial_values.url + # self.url: ClanURI | None = Join.use(). def render(item: TrustValues) -> Gtk.Widget: row = Adw.ActionRow() @@ -49,7 +46,7 @@ class Trust(Gtk.Box): cancel_button = Gtk.Button(label="Cancel") cancel_button.add_css_class("error") - trust_button = Gtk.Button(label="Trust") + trust_button = Gtk.Button(label="Join") trust_button.add_css_class("success") trust_button.connect("clicked", partial(self.on_trust_clicked, item.data)) @@ -68,7 +65,7 @@ class Trust(Gtk.Box): boxed_list.add_css_class("boxed-list") list_store = Gio.ListStore.new(TrustValues) - list_store.append(TrustValues(data=initial_values)) + # list_store.append(TrustValues(data=initial_values)) # icon = Gtk.Image.new_from_pixbuf( # GdkPixbuf.Pixbuf.new_from_file_at_scale( @@ -117,7 +114,7 @@ class Trust(Gtk.Box): # trust_button.connect("clicked", self.on_trust_clicked) # layout.append(trust_button) - def on_trust_clicked(self, item: InitialJoinValues, widget: Gtk.Widget) -> None: + def on_trust_clicked(self, item: JoinValue, widget: Gtk.Widget) -> None: try: uri = item.url # or ClanURI(self.entry.get_text()) diff --git a/pkgs/clan-vm-manager/clan_vm_manager/windows/main_window.py b/pkgs/clan-vm-manager/clan_vm_manager/windows/main_window.py index dd951ee6a..4cfd07b8d 100644 --- a/pkgs/clan-vm-manager/clan_vm_manager/windows/main_window.py +++ b/pkgs/clan-vm-manager/clan_vm_manager/windows/main_window.py @@ -1,9 +1,8 @@ import gi -from clan_vm_manager.models.interfaces import ClanConfig, InitialJoinValues +from clan_vm_manager.models.interfaces import ClanConfig from clan_vm_manager.models.use_views import Views from clan_vm_manager.views.list import ClanList -from clan_vm_manager.views.trust_join import Trust gi.require_version("Adw", "1") @@ -25,9 +24,6 @@ class MainWindow(Adw.ApplicationWindow): # Initialize all views stack_view = Views.use().view stack_view.add_named(ClanList(), "list") - stack_view.add_named( - Trust(initial_values=InitialJoinValues(url=config.url)), "join.trust" - ) stack_view.set_visible_child_name(config.initial_view)