Gtk4 migration (#693)

Co-authored-by: Qubasa <consulting@qube.email>
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/693
Co-authored-by: Johannes Kirschbauer <hsjobeki@gmail.com>
Co-committed-by: Johannes Kirschbauer <hsjobeki@gmail.com>
This commit is contained in:
Johannes Kirschbauer
2024-01-17 12:11:49 +00:00
committed by Luis Hebendanz
parent c862aa342b
commit 00a03fc502
19 changed files with 458 additions and 970 deletions

View File

@@ -1,7 +1,6 @@
import argparse
import logging
import sys
import traceback
from collections.abc import Sequence
from pathlib import Path
from types import ModuleType
@@ -136,13 +135,15 @@ def main() -> None:
args.func(args)
except ClanError as e:
if args.debug:
traceback.print_exc()
log.exception(e)
sys.exit(1)
if isinstance(e, ClanCmdError):
if e.cmd.msg:
print(e.cmd.msg, file=sys.stderr)
else:
print(e, file=sys.stderr)
elif isinstance(e, ClanError):
print(e, file=sys.stderr)
sys.exit(1)

View File

@@ -4,151 +4,91 @@ from dataclasses import dataclass
from pathlib import Path
import gi
from clan_cli import vms
from clan_vm_manager.windows.flash import FlashUSBWindow
from clan_vm_manager.interfaces import InitialJoinValues
from clan_vm_manager.model.use_views import Views
from clan_vm_manager.views.list import ClanList
from clan_vm_manager.views.trust_join import Trust
gi.require_version("Gtk", "3.0")
import multiprocessing as mp
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from clan_cli.clan_uri import ClanURI
from gi.repository import Gio, Gtk
from gi.repository import Adw, Gdk, Gio, Gtk
from .constants import constants
from .errors.show_error import show_error_dialog
from .executor import ProcessManager
from .interfaces import Callbacks, InitialFlashValues, InitialJoinValues
from .windows.join import JoinWindow
from .windows.overview import OverviewWindow
@dataclass
class ClanWindows:
join: type[JoinWindow]
overview: type[OverviewWindow]
flash_usb: type[FlashUSBWindow]
from .model.use_vms import VMS
@dataclass
class ClanConfig:
initial_window: str
initial_view: str
url: ClanURI | None
# Will be executed in the context of the child process
def on_except(error: Exception, proc: mp.process.BaseProcess) -> None:
show_error_dialog(str(error))
class MainWindow(Adw.ApplicationWindow):
def __init__(self, config: ClanConfig) -> None:
super().__init__()
self.set_title("cLAN Manager")
self.set_default_size(980, 650)
view = Adw.ToolbarView()
self.set_content(view)
header = Adw.HeaderBar()
view.add_top_bar(header)
# 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)
clamp = Adw.Clamp()
clamp.set_child(stack_view)
clamp.set_maximum_size(1000)
view.set_content(clamp)
# Push the first page to the navigation view
class Application(Gtk.Application):
def __init__(self, windows: ClanWindows, config: ClanConfig) -> None:
class Application(Adw.Application):
def __init__(self, config: ClanConfig) -> None:
super().__init__(
application_id=constants["APPID"], flags=Gio.ApplicationFlags.FLAGS_NONE
)
self.init_style()
self.windows = windows
self.proc_manager = ProcessManager()
initial = windows.__dict__[config.initial_window]
self.cbs = Callbacks(
show_list=self.show_list,
show_join=self.show_join,
show_flash=self.show_flash,
spawn_vm=self.spawn_vm,
stop_vm=self.stop_vm,
running_vms=self.running_vms,
)
if issubclass(initial, JoinWindow):
# see JoinWindow constructor
self.window = initial(
initial_values=InitialJoinValues(url=config.url or ""),
cbs=self.cbs,
)
if issubclass(initial, OverviewWindow):
# see OverviewWindow constructor
self.window = initial(cbs=self.cbs)
# Connect to the shutdown signal
self.config = config
self.connect("shutdown", self.on_shutdown)
def on_shutdown(self, app: Gtk.Application) -> None:
print("Shutting down")
self.proc_manager.kill_all()
def spawn_vm(self, url: str, attr: str) -> None:
print(f"spawn_vm {url}")
# TODO: We should use VMConfig from the history file
vm = vms.run.inspect_vm(flake_url=url, flake_attr=attr)
log_path = Path(".")
# TODO: We only use the url as the ident. This is not unique as the flake_attr is missing.
# when we migrate everything to use the ClanURI class we can use the full url as the ident
self.proc_manager.spawn(
ident=url,
on_except=on_except,
log_path=log_path,
func=vms.run.run_vm,
vm=vm,
)
def stop_vm(self, url: str, attr: str) -> None:
self.proc_manager.kill(url)
def running_vms(self) -> list[str]:
return self.proc_manager.running_procs()
def show_list(self) -> None:
prev = self.window
self.window = self.windows.__dict__["overview"](cbs=self.cbs)
self.window.set_application(self)
prev.hide()
def show_join(self) -> None:
prev = self.window
self.window = self.windows.__dict__["join"](
cbs=self.cbs, initial_values=InitialJoinValues(url=None)
)
self.window.set_application(self)
prev.hide()
def show_flash(self) -> None:
prev = self.window
self.window = self.windows.__dict__["flash_usb"](
cbs=self.cbs, initial_values=InitialFlashValues(None)
)
self.window.set_application(self)
prev.hide()
def do_startup(self) -> None:
Gtk.Application.do_startup(self)
Gtk.init()
VMS.use().kill_all()
def do_activate(self) -> None:
win = self.props.active_window
if not win:
win = self.window
win.set_application(self)
win.present()
self.init_style()
window = MainWindow(config=self.config)
window.set_application(self)
window.present()
# TODO: For css styling
def init_style(self) -> None:
pass
# css_provider = Gtk.CssProvider()
# css_provider.load_from_resource(constants['RESOURCEID'] + '/style.css')
# screen = Gdk.Screen.get_default()
# style_context = Gtk.StyleContext()
# style_context.add_provider_for_screen(screen, css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
resource_path = Path(__file__).parent / "style.css"
css_provider = Gtk.CssProvider()
css_provider.load_from_path(str(resource_path))
Gtk.StyleContext.add_provider_for_display(
Gdk.Display.get_default(),
css_provider,
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
)
def show_join(args: argparse.Namespace) -> None:
print(f"Joining clan {args.clan_uri}")
app = Application(
windows=ClanWindows(
join=JoinWindow, overview=OverviewWindow, flash_usb=FlashUSBWindow
),
config=ClanConfig(url=args.clan_uri, initial_window="join"),
config=ClanConfig(url=args.clan_uri, initial_view="join.trust"),
)
return app.run()
@@ -160,17 +100,10 @@ def register_join_parser(parser: argparse.ArgumentParser) -> None:
def show_overview(args: argparse.Namespace) -> None:
app = Application(
windows=ClanWindows(
join=JoinWindow, overview=OverviewWindow, flash_usb=FlashUSBWindow
),
config=ClanConfig(url=None, initial_window="overview"),
config=ClanConfig(url=None, initial_view="list"),
)
return app.run()
def register_overview_parser(parser: argparse.ArgumentParser) -> None:
parser.set_defaults(func=show_overview)
# def register_run_parser(parser: argparse.ArgumentParser) -> None:
# parser.set_defaults(func=show_run_vm)

View File

@@ -4,19 +4,17 @@ from typing import Literal
import gi
gi.require_version("Gtk", "3.0")
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from clan_cli.errors import ClanError
from gi.repository import Gtk
from gi.repository import Adw
Severity = Literal["Error"] | Literal["Warning"] | Literal["Info"] | str
def show_error_dialog(error: ClanError, severity: Severity | None = "Error") -> None:
message = str(error)
dialog = Gtk.MessageDialog(
None, 0, Gtk.MessageType.ERROR, Gtk.ButtonsType.CLOSE, severity
)
dialog = Adw.MessageDialog(parent=None, heading=severity, body=message)
print("error:", message)
dialog.format_secondary_text(message)
dialog.run()
dialog.destroy()
dialog.add_response("ok", "ok")
dialog.choose()

View File

@@ -0,0 +1,37 @@
from typing import Any
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw
class Views:
"""
This is a singleton.
It is initialized with the first call of use()
Usage:
Views.use().set_visible()
Views.use() can also be called before the data is needed. e.g. to eliminate/reduce waiting time.
"""
_instance: "None | Views" = None
view: Adw.ViewStack
# Make sure the VMS class is used as a singleton
def __init__(self) -> None:
raise RuntimeError("Call use() instead")
@classmethod
def use(cls: Any) -> "Views":
if cls._instance is None:
print("Creating new instance")
cls._instance = cls.__new__(cls)
cls.view = Adw.ViewStack()
return cls._instance

View File

@@ -0,0 +1,53 @@
import multiprocessing as mp
from typing import Any
from clan_cli.errors import ClanError
from gi.repository import Gio
from clan_vm_manager.errors.show_error import show_error_dialog
from clan_vm_manager.models import VM, get_initial_vms
# https://amolenaar.pages.gitlab.gnome.org/pygobject-docs/Adw-1/class-ToolbarView.html
# Will be executed in the context of the child process
def on_except(error: Exception, proc: mp.process.BaseProcess) -> None:
show_error_dialog(ClanError(str(error)))
class VMS:
"""
This is a singleton.
It is initialized with the first call of use()
Usage:
VMS.use().get_running_vms()
VMS.use() can also be called before the data is needed. e.g. to eliminate/reduce waiting time.
"""
list_store: Gio.ListStore
_instance: "None | VMS" = None
# Make sure the VMS class is used as a singleton
def __init__(self) -> None:
raise RuntimeError("Call use() instead")
@classmethod
def use(cls: Any) -> "VMS":
if cls._instance is None:
print("Creating new instance")
cls._instance = cls.__new__(cls)
cls.list_store = Gio.ListStore.new(VM)
for vm in get_initial_vms():
cls.list_store.append(vm)
return cls._instance
def get_running_vms(self) -> list[VM]:
return list(filter(lambda vm: vm.is_running(), self.list_store))
def kill_all(self) -> None:
for vm in self.get_running_vms():
vm.stop()

View File

@@ -1,100 +1,95 @@
from collections import OrderedDict
from dataclasses import dataclass
import multiprocessing as mp
import sys
import weakref
from enum import StrEnum
from pathlib import Path
from typing import Any
import gi
from clan_cli.history.list import list_history
from .errors.show_error import show_error_dialog
gi.require_version("GdkPixbuf", "2.0")
from clan_cli import vms
from clan_cli.errors import ClanError
from gi.repository import GdkPixbuf
from clan_cli.history.add import HistoryEntry
from clan_cli.history.list import list_history
from gi.repository import GObject
from clan_vm_manager import assets
from .errors.show_error import show_error_dialog
from .executor import MPProcess, spawn
class VMStatus(StrEnum):
RUNNING = "Running"
STOPPED = "Stopped"
@dataclass(frozen=True)
class VMBase:
icon: Path | GdkPixbuf.Pixbuf
name: str
url: str
status: VMStatus
_flake_attr: str
def on_except(error: Exception, proc: mp.process.BaseProcess) -> None:
show_error_dialog(ClanError(str(error)))
@staticmethod
def name_to_type_map() -> OrderedDict[str, type]:
return OrderedDict(
{
"Icon": GdkPixbuf.Pixbuf,
"Name": str,
"URL": str,
"Status": str,
"_FlakeAttr": str,
}
class VM(GObject.Object):
def __init__(
self,
icon: Path,
status: VMStatus,
data: HistoryEntry,
process: MPProcess | None = None,
) -> None:
super().__init__()
self.data = data
self.process = process
self.status = status
self._finalizer = weakref.finalize(self, self.stop)
def start(self) -> None:
if self.process is not None:
show_error_dialog(ClanError("VM is already running"))
return
vm = vms.run.inspect_vm(
flake_url=self.data.flake.flake_url, flake_attr=self.data.flake.flake_attr
)
log_path = Path(".")
self.process = spawn(
on_except=on_except,
log_path=log_path,
func=vms.run.run_vm,
vm=vm,
)
@staticmethod
def to_idx(name: str) -> int:
return list(VMBase.name_to_type_map().keys()).index(name)
def is_running(self) -> bool:
if self.process is not None:
return self.process.proc.is_alive()
return False
def list_data(self) -> OrderedDict[str, Any]:
return OrderedDict(
{
"Icon": str(self.icon),
"Name": self.name,
"URL": self.url,
"Status": self.status,
"_FlakeAttr": self._flake_attr,
}
)
def get_id(self) -> str:
return self.data.flake.flake_url + self.data.flake.flake_attr
def stop(self) -> None:
if self.process is None:
print("VM is already stopped", file=sys.stderr)
return
self.process.kill_group()
self.process = None
@dataclass(frozen=True)
class VM:
# Inheritance is bad. Lets use composition
# Added attributes are separated from base attributes.
base: VMBase
autostart: bool = False
description: str | None = None
# TODO: How to handle incompatible / corrupted history file. Delete it?
# start/end indexes can be used optionally for pagination
def get_initial_vms(
running_vms: list[str], start: int = 0, end: int | None = None
) -> list[VM]:
def get_initial_vms() -> list[VM]:
vm_list = []
try:
# Execute `clan flakes add <path>` to democlan for this to work
for entry in list_history():
icon = assets.loc / "placeholder.jpeg"
if entry.flake.icon is not None:
if entry.flake.icon is None:
icon = assets.loc / "placeholder.jpeg"
else:
icon = entry.flake.icon
status = VMStatus.STOPPED
if entry.flake.flake_url in running_vms:
status = VMStatus.RUNNING
base = VMBase(
base = VM(
icon=icon,
name=entry.flake.clan_name,
url=entry.flake.flake_url,
status=status,
_flake_attr=entry.flake.flake_attr,
status=VMStatus.STOPPED,
data=entry,
)
vm_list.append(VM(base=base))
vm_list.append(base)
except ClanError as e:
show_error_dialog(e)
# start/end slices can be used for pagination
return vm_list[start:end]
return vm_list

View File

@@ -0,0 +1,16 @@
/* Insert custom styles here */
navigation-view {
padding: 5px;
/* padding-left: 5px;
padding-right: 5px;
padding-bottom: 5px; */
}
avatar {
margin: 2px;
}
.trust {
padding: 25px;
}

View File

@@ -1,26 +0,0 @@
#!/usr/bin/env python3
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk
class ClanJoinPage(Gtk.Box):
def __init__(self, *, stack: Gtk.Stack) -> None:
super().__init__()
self.page = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL, spacing=6, expand=True
)
self.set_border_width(10)
self.stack = stack
button = Gtk.Button(label="Back to list", margin_left=10)
button.connect("clicked", self.switch)
self.add(button)
self.add(Gtk.Label("Join cLan"))
def switch(self, widget: Gtk.Widget) -> None:
self.stack.set_visible_child_name("list")

View File

@@ -1,328 +0,0 @@
from collections.abc import Callable
from gi.repository import Gdk, GdkPixbuf, Gtk
from ..interfaces import Callbacks
from ..models import VMBase, VMStatus
from .context_menu import VmMenu
class ClanEditForm(Gtk.ListBox):
def __init__(self, *, selected: VMBase | None) -> None:
super().__init__()
self.page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, expand=True)
self.set_border_width(10)
self.selected = selected
self.set_selection_mode(0)
if self.selected:
row = Gtk.ListBoxRow()
row.add(Gtk.Label(f"\n {self.selected.name}"))
self.add(row)
# ---------- row 1 --------
row = Gtk.ListBoxRow()
row_layout = Gtk.Box(spacing=6, expand=True)
# Doc: pack_start/end takes alignment params Expand, Fill, Padding
row_layout.pack_start(Gtk.Label("Memory Size in MiB"), False, False, 5)
row_layout.pack_start(
Gtk.SpinButton.new_with_range(512, 4096, 256), True, True, 0
)
row.add(row_layout)
self.add(row)
# ----------- row 2 -------
row = Gtk.ListBoxRow()
row_layout = Gtk.Box(spacing=6, expand=True)
row_layout.pack_start(Gtk.Label("CPU Count"), False, False, 5)
row_layout.pack_end(Gtk.SpinButton.new_with_range(1, 5, 1), True, True, 0)
row.add(row_layout)
self.add(row)
def switch(self, widget: Gtk.Widget) -> None:
self.show_list()
class ClanEdit(Gtk.Box):
def __init__(
self, *, remount_list: Callable[[], None], selected_vm: VMBase | None
) -> None:
super().__init__(orientation=Gtk.Orientation.VERTICAL, expand=True)
self.show_list = remount_list
self.selected = selected_vm
self.toolbar = ClanEditToolbar(on_save_clicked=self.on_save)
self.add(self.toolbar)
self.add(ClanEditForm(selected=self.selected))
def on_save(self, widget: Gtk.Widget) -> None:
print("Save clicked saving values")
self.show_list()
class ClanList(Gtk.Box):
"""
The ClanList
Is the composition of
the ClanListToolbar
the clanListView
# ------------------------ #
# - Tools <Start> <Stop> < Edit> #
# ------------------------ #
# - List Items
# - <...>
# ------------------------#
"""
def __init__(
self,
*,
remount_list: Callable[[], None],
remount_edit: Callable[[], None],
set_selected: Callable[[VMBase | None], None],
cbs: Callbacks,
selected_vm: VMBase | None,
vms: list[VMBase],
) -> None:
super().__init__(orientation=Gtk.Orientation.VERTICAL, expand=True)
self.remount_edit_view = remount_edit
self.remount_list_view = remount_list
self.set_selected = set_selected
self.cbs = cbs
self.show_join = cbs.show_join
self.selected_vm: VMBase | None = selected_vm
self.toolbar = ClanListToolbar(
selected_vm=selected_vm,
on_start_clicked=self.on_start_clicked,
on_stop_clicked=self.on_stop_clicked,
on_edit_clicked=self.on_edit_clicked,
on_join_clan_clicked=self.on_join_clan_clicked,
on_flash_clicked=self.on_flash_clicked,
)
self.add(self.toolbar)
self.add(
ClanListView(
vms=vms,
on_select_row=self.on_select_vm,
selected_vm=selected_vm,
on_double_click=self.on_double_click,
)
)
def on_flash_clicked(self, widget: Gtk.Widget) -> None:
self.cbs.show_flash()
def on_double_click(self, vm: VMBase) -> None:
self.on_start_clicked(self)
def on_start_clicked(self, widget: Gtk.Widget) -> None:
if self.selected_vm:
self.cbs.spawn_vm(self.selected_vm.url, self.selected_vm._flake_attr)
# Call this to reload
self.remount_list_view()
def on_stop_clicked(self, widget: Gtk.Widget) -> None:
if self.selected_vm:
self.cbs.stop_vm(self.selected_vm.url, self.selected_vm._flake_attr)
self.remount_list_view()
def on_join_clan_clicked(self, widget: Gtk.Widget) -> None:
self.show_join()
def on_edit_clicked(self, widget: Gtk.Widget) -> None:
self.remount_edit_view()
def on_select_vm(self, vm: VMBase) -> None:
self.toolbar.set_selected_vm(vm)
self.set_selected(vm)
self.selected_vm = vm
class ClanListToolbar(Gtk.Toolbar):
def __init__(
self,
*,
selected_vm: VMBase | None,
on_start_clicked: Callable[[Gtk.Widget], None],
on_stop_clicked: Callable[[Gtk.Widget], None],
on_edit_clicked: Callable[[Gtk.Widget], None],
on_join_clan_clicked: Callable[[Gtk.Widget], None],
on_flash_clicked: Callable[[Gtk.Widget], None],
) -> None:
super().__init__(orientation=Gtk.Orientation.HORIZONTAL)
self.start_button = Gtk.ToolButton(label="Start")
self.start_button.connect("clicked", on_start_clicked)
self.add(self.start_button)
self.stop_button = Gtk.ToolButton(label="Stop")
self.stop_button.connect("clicked", on_stop_clicked)
self.add(self.stop_button)
self.edit_button = Gtk.ToolButton(label="Edit")
self.edit_button.connect("clicked", on_edit_clicked)
self.add(self.edit_button)
self.join_clan_button = Gtk.ToolButton(label="Join Clan")
self.join_clan_button.connect("clicked", on_join_clan_clicked)
self.add(self.join_clan_button)
self.flash_button = Gtk.ToolButton(label="Write to USB")
self.flash_button.connect("clicked", on_flash_clicked)
self.add(self.flash_button)
self.set_selected_vm(selected_vm)
def set_selected_vm(self, vm: VMBase | None) -> None:
if vm:
self.edit_button.set_sensitive(True)
self.start_button.set_sensitive(vm.status == VMStatus.STOPPED)
self.stop_button.set_sensitive(vm.status == VMStatus.RUNNING)
else:
self.edit_button.set_sensitive(False)
self.start_button.set_sensitive(False)
self.stop_button.set_sensitive(False)
class ClanEditToolbar(Gtk.Toolbar):
def __init__(
self,
*,
on_save_clicked: Callable[[Gtk.Widget], None],
) -> None:
super().__init__(orientation=Gtk.Orientation.HORIZONTAL)
# Icons See: https://specifications.freedesktop.org/icon-naming-spec/icon-naming-spec-latest.html
# Could not find a suitable one
self.save_button = Gtk.ToolButton(label="Save")
self.save_button.connect("clicked", on_save_clicked)
self.add(self.save_button)
class ClanListView(Gtk.Box):
def __init__(
self,
*,
on_select_row: Callable[[VMBase], None],
selected_vm: VMBase | None,
vms: list[VMBase],
on_double_click: Callable[[VMBase], None],
) -> None:
super().__init__(expand=True)
self.vms: list[VMBase] = vms
self.on_select_row = on_select_row
self.on_double_click = on_double_click
self.context_menu: VmMenu | None = None
store_types = VMBase.name_to_type_map().values()
self.list_store = Gtk.ListStore(*store_types)
self.tree_view = Gtk.TreeView(self.list_store, expand=True)
for vm in self.vms:
self.insertVM(vm)
setColRenderers(self.tree_view)
self.set_selected_vm(selected_vm)
selection = self.tree_view.get_selection()
selection.connect("changed", self._on_select_row)
self.tree_view.connect("row-activated", self._on_double_click)
self.tree_view.connect("button-press-event", self._on_button_pressed)
self.set_border_width(10)
self.add(self.tree_view)
def find_vm(self, vm: VMBase) -> int:
for idx, row in enumerate(self.list_store):
if row[VMBase.to_idx("Name")] == vm.name: # TODO: Change to path
return idx
return -1
def set_selected_vm(self, vm: VMBase | None) -> None:
if vm is None:
return
selection = self.tree_view.get_selection()
idx = self.find_vm(vm)
selection.select_path(idx)
def insertVM(self, vm: VMBase) -> None:
values = list(vm.list_data().values())
icon_idx = VMBase.to_idx("Icon")
values[icon_idx] = GdkPixbuf.Pixbuf.new_from_file_at_scale(
filename=values[icon_idx], width=64, height=64, preserve_aspect_ratio=True
)
self.list_store.append(values)
def _on_select_row(self, selection: Gtk.TreeSelection) -> None:
model, row = selection.get_selected()
if row is not None:
vm = VMBase(*model[row])
self.on_select_row(vm)
def _on_button_pressed(
self, tree_view: Gtk.TreeView, event: Gdk.EventButton
) -> None:
if self.context_menu:
self.context_menu.destroy()
self.context_menu = None
if event.button == 3:
path, column, x, y = tree_view.get_path_at_pos(event.x, event.y)
if path is not None:
vm = VMBase(*self.list_store[path[0]])
print(event)
print(f"Right click on {vm.url}")
self.context_menu = VmMenu(vm)
self.context_menu.popup_at_pointer(event)
def _on_double_click(
self, tree_view: Gtk.TreeView, path: Gtk.TreePath, column: Gtk.TreeViewColumn
) -> None:
# Get the selection object of the tree view
selection = tree_view.get_selection()
model, row = selection.get_selected()
if row is not None:
vm = VMBase(*model[row])
self.on_double_click(vm)
def setColRenderers(tree_view: Gtk.TreeView) -> None:
for idx, (key, gtype) in enumerate(VMBase.name_to_type_map().items()):
col: Gtk.TreeViewColumn = None
if key.startswith("_"):
continue
if issubclass(gtype, GdkPixbuf.Pixbuf):
renderer = Gtk.CellRendererPixbuf()
col = Gtk.TreeViewColumn(key, renderer, pixbuf=idx)
elif issubclass(gtype, bool):
renderer = Gtk.CellRendererToggle()
col = Gtk.TreeViewColumn(key, renderer, active=idx)
elif issubclass(gtype, str):
renderer = Gtk.CellRendererText()
col = Gtk.TreeViewColumn(key, renderer, text=idx)
else:
raise Exception(f"Unknown type: {gtype}")
# CommonSetup for all columns
if col:
col.set_resizable(True)
col.set_expand(True)
col.set_property("sizing", Gtk.TreeViewColumnSizing.AUTOSIZE)
col.set_property("alignment", 0.5)
col.set_sort_column_id(idx)
tree_view.append_column(col)

View File

@@ -1,39 +0,0 @@
import gi
gi.require_version("Gtk", "3.0")
from gi.repository import Gtk
from ..models import VMBase
class VmMenu(Gtk.Menu):
def __init__(self, vm: VMBase) -> None:
super().__init__()
self.vm = vm
self.menu_items = [
("Start", self.start_vm),
("Stop", self.stop_vm),
("Edit", self.edit_vm),
("Remove", self.remove_vm),
("Write to USB", self.write_to_usb),
]
for item in self.menu_items:
menu_item = Gtk.MenuItem(label=item[0])
menu_item.connect("activate", item[1])
self.append(menu_item)
self.show_all()
def start_vm(self, widget: Gtk.Widget) -> None:
print("start_vm")
def stop_vm(self, widget: Gtk.Widget) -> None:
print("stop_vm")
def edit_vm(self, widget: Gtk.Widget) -> None:
print("edit_vm")
def remove_vm(self, widget: Gtk.Widget) -> None:
print("remove_vm")
def write_to_usb(self, widget: Gtk.Widget) -> None:
print("write_to_usb")

View File

@@ -0,0 +1,80 @@
from functools import partial
import gi
from ..model.use_vms import VMS
gi.require_version("Adw", "1")
from gi.repository import Adw, Gdk, Gtk
from ..models import VM
class ClanList(Gtk.Box):
"""
The ClanList
Is the composition of
the ClanListToolbar
the clanListView
# ------------------------ #
# - Tools <Start> <Stop> < Edit> #
# ------------------------ #
# - List Items
# - <...>
# ------------------------#
"""
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
list_store = VMS.use().list_store
boxed_list.bind_model(list_store, create_widget_func=create_widget)
self.append(boxed_list)
def on_row_toggle(self, vm: VM, row: Adw.SwitchRow, state: bool) -> None:
print("Toggled", vm.data.flake.flake_attr, "active:", row.get_active())
if row.get_active():
vm.start()
if not row.get_active():
vm.stop()

View File

@@ -0,0 +1,139 @@
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 ..interfaces import InitialJoinValues
gi.require_version("Gtk", "4.0")
gi.require_version("Adw", "1")
from gi.repository import Adw, Gio, GObject, Gtk
class TrustValues(GObject.Object):
data: InitialJoinValues
def __init__(self, data: InitialJoinValues) -> None:
super().__init__()
print("TrustValues", data)
self.data = data
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
def render(item: TrustValues) -> Gtk.Widget:
row = Adw.ActionRow()
row.set_title(str(item.data.url))
row.add_css_class("trust")
avatar = Adw.Avatar()
avatar.set_text(str(item.data.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")
trust_button = Gtk.Button(label="Trust")
trust_button.add_css_class("success")
trust_button.connect("clicked", partial(self.on_trust_clicked, item.data))
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
box.set_valign(Gtk.Align.CENTER)
box.append(cancel_button)
box.append(trust_button)
# switch.connect("notify::active", partial(self.on_row_toggle, item.data))
row.add_suffix(box)
return row
boxed_list = Gtk.ListBox()
boxed_list.set_selection_mode(Gtk.SelectionMode.NONE)
boxed_list.add_css_class("boxed-list")
list_store = Gio.ListStore.new(TrustValues)
list_store.append(TrustValues(data=initial_values))
# icon = Gtk.Image.new_from_pixbuf(
# GdkPixbuf.Pixbuf.new_from_file_at_scale(
# filename=str(assets.loc / "placeholder.jpeg"),
# width=256,
# height=256,
# preserve_aspect_ratio=True,
# )
# )
boxed_list.bind_model(list_store, create_widget_func=render)
self.append(boxed_list)
# layout = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
# # layout.set_border_width(20)
# layout.set_spacing(20)
# if self.url is not None:
# self.entry = Gtk.Label(label=str(self.url))
# layout.append(icon)
# layout.append(Gtk.Label(label="Clan URL"))
# else:
# layout.append(Gtk.Label(label="Enter Clan URL"))
# self.entry = Gtk.Entry()
# # Autocomplete
# # TODO: provide intelligent suggestions
# completion_list = Gtk.ListStore(str)
# completion_list.append(["clan://"])
# completion = Gtk.EntryCompletion()
# completion.set_model(completion_list)
# completion.set_text_column(0)
# completion.set_popup_completion(False)
# completion.set_inline_completion(True)
# self.entry.set_completion(completion)
# self.entry.set_placeholder_text("clan://")
# layout.append(self.entry)
# if self.url is None:
# trust_button = Gtk.Button(label="Load cLAN-URL")
# else:
# trust_button = Gtk.Button(label="Trust cLAN-URL")
# trust_button.connect("clicked", self.on_trust_clicked)
# layout.append(trust_button)
def on_trust_clicked(self, item: InitialJoinValues, widget: Gtk.Widget) -> None:
try:
uri = item.url
# or ClanURI(self.entry.get_text())
print(f"trusted: {uri}")
if uri:
add_history(uri)
# history = list_history()
# found = filter(
# lambda item: item.flake.flake_url == uri.get_internal(), history
# )
# if found:
# [item] = found
# self.on_trust(uri.get_internal(), item.flake)
except ClanError as e:
pass
show_error_dialog(e)

View File

@@ -1,65 +0,0 @@
from typing import Any
import gi
from clan_cli.errors import ClanError
from clan_vm_manager.errors.show_error import show_error_dialog
from ..interfaces import Callbacks, InitialFlashValues
gi.require_version("Gtk", "3.0")
from gi.repository import Gio, Gtk
class Details(Gtk.Box):
def __init__(self, initial: InitialFlashValues, stack: Gtk.Stack) -> None:
super().__init__()
def on_confirm(self, widget: Gtk.Widget) -> None:
show_error_dialog(ClanError("Feature not ready yet."), "Info")
def on_cancel(self, widget: Gtk.Widget) -> None:
show_error_dialog(ClanError("Feature not ready yet."), "Info")
class FlashUSBWindow(Gtk.ApplicationWindow):
def __init__(self, cbs: Callbacks, initial_values: InitialFlashValues) -> None:
super().__init__()
# Initialize the main wincbsdow
# self.cbs = cbs
self.set_title("cLAN Manager")
self.connect("delete-event", self.on_quit)
self.set_default_size(800, 600)
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6, expand=True)
self.add(vbox)
button = Gtk.ToolButton()
button.set_icon_name("go-previous")
button.connect("clicked", self.switch)
toolbar = Gtk.Toolbar(orientation=Gtk.Orientation.HORIZONTAL)
toolbar.add(button)
vbox.add(toolbar)
self.stack = Gtk.Stack()
print("initial_values", initial_values)
self.stack.add_titled(
Details(initial_values, stack=self.stack),
"details",
"Details",
)
vbox.add(self.stack)
# Must be called AFTER all components were added
self.show_all()
def switch(self, widget: Gtk.Widget) -> None:
pass
# self.cbs.show_list()
def on_quit(self, *args: Any) -> None:
Gio.Application.quit(self.get_application())

View File

@@ -1,211 +0,0 @@
from collections.abc import Callable
from typing import Any
import gi
from clan_cli.clan_uri import ClanURI
from clan_cli.errors import ClanError
from clan_cli.flakes.inspect import FlakeConfig
from clan_cli.history.add import add_history, list_history
from clan_vm_manager import assets
from clan_vm_manager.errors.show_error import show_error_dialog
from ..interfaces import Callbacks, InitialJoinValues
gi.require_version("Gtk", "3.0")
from gi.repository import GdkPixbuf, Gio, Gtk
class Trust(Gtk.Box):
def __init__(
self,
initial_values: InitialJoinValues,
on_trust: Callable[[str, FlakeConfig], None],
) -> None:
super().__init__()
self.on_trust = on_trust
self.url: ClanURI | None = initial_values.url
icon = Gtk.Image.new_from_pixbuf(
GdkPixbuf.Pixbuf.new_from_file_at_scale(
filename=str(assets.loc / "placeholder.jpeg"),
width=256,
height=256,
preserve_aspect_ratio=True,
)
)
layout = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, expand=True)
layout.set_border_width(20)
layout.set_spacing(20)
if self.url is not None:
self.entry = Gtk.Label(label=str(self.url))
layout.add(icon)
layout.add(Gtk.Label(label="Clan URL"))
else:
layout.add(Gtk.Label(label="Enter Clan URL"))
self.entry = Gtk.Entry()
# Autocomplete
# TODO: provide intelligent suggestions
completion_list = Gtk.ListStore(str)
completion_list.append(["clan://"])
completion = Gtk.EntryCompletion()
completion.set_model(completion_list)
completion.set_text_column(0)
completion.set_popup_completion(False)
completion.set_inline_completion(True)
self.entry.set_completion(completion)
self.entry.set_placeholder_text("clan://")
layout.add(self.entry)
if self.url is None:
trust_button = Gtk.Button(label="Load cLAN-URL")
else:
trust_button = Gtk.Button(label="Trust cLAN-URL")
trust_button.connect("clicked", self.on_trust_clicked)
layout.add(trust_button)
self.set_center_widget(layout)
def on_trust_clicked(self, widget: Gtk.Widget) -> None:
try:
uri = self.url or ClanURI(self.entry.get_text())
print(f"trusted: {uri}")
add_history(uri)
history = list_history()
found = filter(
lambda item: item.flake.flake_url == uri.get_internal(), history
)
if found:
[item] = found
self.on_trust(uri.get_internal(), item.flake)
except ClanError as e:
show_error_dialog(e)
class Details(Gtk.Box):
def __init__(self, url: str, flake: FlakeConfig) -> None:
super().__init__()
self.flake = flake
icon = Gtk.Image.new_from_pixbuf(
GdkPixbuf.Pixbuf.new_from_file_at_scale(
filename=str(flake.icon),
width=256,
height=256,
preserve_aspect_ratio=True,
)
)
layout = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, expand=True)
layout.set_border_width(20)
upper = Gtk.Box(orientation="vertical")
upper.set_spacing(20)
upper.add(Gtk.Label(label="Clan URL"))
upper.add(icon)
label = Gtk.Label(label=str(url))
upper.add(label)
description_label = Gtk.Label(label=flake.description)
upper.add(description_label)
lower = Gtk.Box(orientation="horizontal", expand=True)
lower.set_spacing(20)
join_button = Gtk.Button(label="Join")
join_button.connect("clicked", self.on_join)
join_action_area = Gtk.Box(orientation="horizontal", expand=False)
join_button_area = Gtk.Box(orientation="vertical", expand=False)
join_action_area.pack_end(join_button_area, expand=False, fill=False, padding=0)
join_button_area.pack_end(join_button, expand=False, fill=False, padding=0)
join_details = Gtk.Label(label="Info")
join_details_area = Gtk.Box(orientation="horizontal", expand=False)
join_label_area = Gtk.Box(orientation="vertical", expand=False)
for info in [
f"Memory: {flake.clan_name}",
"CPU: 2 Cores",
"Storage: 64 GiB",
]:
details_label = Gtk.Label(label=info)
details_label.set_justify(Gtk.Justification.LEFT)
join_label_area.pack_end(details_label, expand=False, fill=False, padding=0)
join_label_area.pack_end(join_details, expand=False, fill=False, padding=0)
join_details_area.pack_start(
join_label_area, expand=False, fill=False, padding=0
)
lower.pack_start(join_details_area, expand=True, fill=True, padding=0)
lower.pack_end(join_action_area, expand=True, fill=True, padding=0)
layout.pack_start(upper, expand=False, fill=False, padding=0)
layout.add(lower)
self.add(layout)
def on_join(self, widget: Gtk.Widget) -> None:
# TODO: @Qubasa
show_error_dialog(ClanError("Feature not ready yet."), "Info")
class JoinWindow(Gtk.ApplicationWindow):
def __init__(self, initial_values: InitialJoinValues, cbs: Callbacks) -> None:
super().__init__()
# Initialize the main wincbsdow
self.cbs = cbs
self.set_title("cLAN Manager")
self.connect("delete-event", self.on_quit)
self.set_default_size(800, 600)
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6, expand=True)
self.add(vbox)
button = Gtk.ToolButton()
button.set_icon_name("go-previous")
button.connect("clicked", self.switch)
toolbar = Gtk.Toolbar(orientation=Gtk.Orientation.HORIZONTAL)
toolbar.add(button)
vbox.add(toolbar)
self.stack = Gtk.Stack()
print("initial_values", initial_values)
self.stack.add_titled(
Trust(initial_values, on_trust=self.on_trust),
"trust",
"Trust",
)
vbox.add(self.stack)
# vbox.add(Gtk.Entry(text=str(initial_values.url)))
# Must be called AFTER all components were added
self.show_all()
def on_trust(self, url: str, flake: FlakeConfig) -> None:
self.stack.add_titled(
Details(url=url, flake=flake),
"details",
"Details",
)
self.show_all()
self.stack.set_visible_child_name("details")
def switch(self, widget: Gtk.Widget) -> None:
self.cbs.show_list()
def on_quit(self, *args: Any) -> None:
Gio.Application.quit(self.get_application())

View File

@@ -1,95 +0,0 @@
from typing import Any
import gi
from ..models import VMBase, get_initial_vms
gi.require_version("Gtk", "3.0")
from gi.repository import Gio, Gtk
from ..interfaces import Callbacks
from ..ui.clan_join_page import ClanJoinPage
from ..ui.clan_select_list import ClanEdit, ClanList
class OverviewWindow(Gtk.ApplicationWindow):
def __init__(self, cbs: Callbacks) -> None:
super().__init__()
self.set_title("cLAN Manager")
self.connect("delete-event", self.on_quit)
self.set_default_size(800, 600)
self.cbs = cbs
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6, expand=True)
self.add(vbox)
self.stack = Gtk.Stack()
clan_list = ClanList(
vms=[vm.base for vm in get_initial_vms(self.cbs.running_vms())],
cbs=self.cbs,
remount_list=self.remount_list_view,
remount_edit=self.remount_edit_view,
set_selected=self.set_selected,
selected_vm=None,
)
# Add named stacks
self.stack.add_titled(clan_list, "list", "List")
self.stack.add_titled(
ClanJoinPage(stack=self.remount_list_view), "join", "Join"
)
self.stack.add_titled(
ClanEdit(remount_list=self.remount_list_view, selected_vm=None),
"edit",
"Edit",
)
vbox.add(self.stack)
# Must be called AFTER all components were added
self.show_all()
def set_selected(self, sel: VMBase | None) -> None:
self.selected_vm = sel
def remount_list_view(self) -> None:
widget = self.stack.get_child_by_name("list")
if widget:
widget.destroy()
vms = []
for vm in get_initial_vms(self.cbs.running_vms()):
vms.append(vm.base)
# FIXME: It feels very odd that we have to re-fetch the selected VM.
# The model should be just updated in-place.
if self.selected_vm and vm.base.url == self.selected_vm.url:
self.selected_vm = vm.base
clan_list = ClanList(
vms=vms,
cbs=self.cbs,
remount_list=self.remount_list_view,
remount_edit=self.remount_edit_view,
set_selected=self.set_selected,
selected_vm=self.selected_vm,
)
self.stack.add_titled(clan_list, "list", "List")
self.show_all()
self.stack.set_visible_child_name("list")
def remount_edit_view(self) -> None:
widget = self.stack.get_child_by_name("edit")
if widget:
widget.destroy()
self.stack.add_titled(
ClanEdit(remount_list=self.remount_list_view, selected_vm=self.selected_vm),
"edit",
"Edit",
)
self.show_all()
self.stack.set_visible_child_name("edit")
def on_quit(self, *args: Any) -> None:
Gio.Application.quit(self.get_application())

View File

@@ -4,13 +4,12 @@
, copyDesktopItems
, pygobject3
, wrapGAppsHook
, gtk3
, spice-gtk
, gtk4
, gnome
, gobject-introspection
, clan-cli
, makeDesktopItem
, ipdb
, libadwaita
}:
let
source = ./.;
@@ -33,7 +32,7 @@ python3.pkgs.buildPythonApplication {
gobject-introspection
];
buildInputs = [ spice-gtk gtk3 gnome.adwaita-icon-theme ];
buildInputs = [ gtk4 libadwaita gnome.adwaita-icon-theme ];
propagatedBuildInputs = [ pygobject3 clan-cli ];
# also re-expose dependencies so we test them in CI

View File

@@ -1,4 +1,4 @@
{ clan-vm-manager, clan-cli, mkShell, ruff, desktop-file-utils, xdg-utils, mypy, python3Packages }:
{ clan-vm-manager, libadwaita, clan-cli, mkShell, ruff, desktop-file-utils, xdg-utils, mypy, python3Packages }:
mkShell {
inherit (clan-vm-manager) propagatedBuildInputs buildInputs;
nativeBuildInputs = [
@@ -7,6 +7,7 @@ mkShell {
xdg-utils
mypy
python3Packages.ipdb
libadwaita.devdoc # has the demo called 'adwaita-1-demo'
] ++ clan-vm-manager.nativeBuildInputs;
PYTHONBREAKPOINT = "ipdb.set_trace";