Merge pull request 'Improved Table with feedback from W' (#598) from Qubasa-main into main
0
.gitmodules
vendored
Normal file
@@ -8,7 +8,7 @@ from typing import Any
|
|||||||
|
|
||||||
from . import config, flakes, machines, secrets, vms, webui
|
from . import config, flakes, machines, secrets, vms, webui
|
||||||
from .custom_logger import setup_logging
|
from .custom_logger import setup_logging
|
||||||
from .dirs import get_clan_flake_toplevel
|
from .dirs import get_clan_flake_toplevel, is_clan_flake
|
||||||
from .ssh import cli as ssh_cli
|
from .ssh import cli as ssh_cli
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
@@ -56,11 +56,27 @@ def create_parser(prog: str | None = None) -> argparse.ArgumentParser:
|
|||||||
default=[],
|
default=[],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def flake_path(arg: str) -> Path:
|
||||||
|
flake_dir = Path(arg).resolve()
|
||||||
|
if not flake_dir.exists():
|
||||||
|
raise argparse.ArgumentTypeError(
|
||||||
|
f"flake directory {flake_dir} does not exist"
|
||||||
|
)
|
||||||
|
if not flake_dir.is_dir():
|
||||||
|
raise argparse.ArgumentTypeError(
|
||||||
|
f"flake directory {flake_dir} is not a directory"
|
||||||
|
)
|
||||||
|
if not is_clan_flake(flake_dir):
|
||||||
|
raise argparse.ArgumentTypeError(
|
||||||
|
f"flake directory {flake_dir} is not a clan flake"
|
||||||
|
)
|
||||||
|
return flake_dir
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--flake",
|
"--flake",
|
||||||
help="path to the flake where the clan resides in",
|
help="path to the flake where the clan resides in",
|
||||||
default=get_clan_flake_toplevel(),
|
default=get_clan_flake_toplevel(),
|
||||||
type=Path,
|
type=flake_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
subparsers = parser.add_subparsers()
|
subparsers = parser.add_subparsers()
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ def get_clan_flake_toplevel() -> Path | None:
|
|||||||
return find_toplevel([".clan-flake", ".git", ".hg", ".svn", "flake.nix"])
|
return find_toplevel([".clan-flake", ".git", ".hg", ".svn", "flake.nix"])
|
||||||
|
|
||||||
|
|
||||||
|
def is_clan_flake(path: Path) -> bool:
|
||||||
|
return (path / ".clan-flake").exists()
|
||||||
|
|
||||||
|
|
||||||
def find_git_repo_root() -> Path | None:
|
def find_git_repo_root() -> Path | None:
|
||||||
return find_toplevel([".git"])
|
return find_toplevel([".git"])
|
||||||
|
|
||||||
|
|||||||
@@ -73,3 +73,4 @@ import the glade file through GTK template
|
|||||||
- [GTK3 Python] https://github.com/sam-m888/python-gtk3-tutorial/tree/master
|
- [GTK3 Python] https://github.com/sam-m888/python-gtk3-tutorial/tree/master
|
||||||
- https://gnome.pages.gitlab.gnome.org/libhandy/doc/1.8/index.html
|
- https://gnome.pages.gitlab.gnome.org/libhandy/doc/1.8/index.html
|
||||||
- https://github.com/geigi/cozy
|
- https://github.com/geigi/cozy
|
||||||
|
- https://github.com/lutris/lutris/blob/2e9bd115febe08694f5d42dabcf9da36a1065f1d/lutris/gui/widgets/cellrenderers.py#L92
|
||||||
|
|||||||
11
pkgs/clan-vm-manager/clan-vm-manager.code-workspace
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"path": "."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../clan-cli/clan_cli"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings": {}
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import gi
|
import gi
|
||||||
@@ -14,38 +13,21 @@ from .constants import constants
|
|||||||
from .ui.clan_select_list import ClanSelectPage
|
from .ui.clan_select_list import ClanSelectPage
|
||||||
|
|
||||||
|
|
||||||
class VM:
|
|
||||||
def __init__(self, url: str, autostart: bool, path: Path) -> None:
|
|
||||||
self.url = url
|
|
||||||
self.autostart = autostart
|
|
||||||
self.path = path
|
|
||||||
|
|
||||||
|
|
||||||
vms = [
|
|
||||||
VM("clan://clan.lol", True, "/home/user/my-clan"),
|
|
||||||
VM("clan://lassul.lol", False, "/home/user/my-clan"),
|
|
||||||
VM("clan://mic.lol", False, "/home/user/my-clan"),
|
|
||||||
VM("clan://dan.lol", False, "/home/user/my-clan"),
|
|
||||||
]
|
|
||||||
vms.extend(vms)
|
|
||||||
# vms.extend(vms)
|
|
||||||
# vms.extend(vms)
|
|
||||||
|
|
||||||
|
|
||||||
class ClanJoinPage(Gtk.Box):
|
class ClanJoinPage(Gtk.Box):
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.page = Gtk.Box()
|
self.page = Gtk.Box()
|
||||||
self.set_border_width(10)
|
self.set_border_width(10)
|
||||||
self.add(Gtk.Label(label="Add/Join another clan"))
|
self.add(Gtk.Label(label="Join"))
|
||||||
|
|
||||||
|
|
||||||
class MainWindow(Gtk.ApplicationWindow):
|
class MainWindow(Gtk.ApplicationWindow):
|
||||||
def __init__(self, application: Gtk.Application) -> None:
|
def __init__(self, application: Gtk.Application) -> None:
|
||||||
super().__init__(application=application)
|
super().__init__(application=application)
|
||||||
# Initialize the main window
|
# Initialize the main window
|
||||||
self.set_title("Clan VM Manager")
|
self.set_title("cLAN Manager")
|
||||||
self.connect("delete-event", self.on_quit)
|
self.connect("delete-event", self.on_quit)
|
||||||
|
self.set_default_size(800, 600)
|
||||||
|
|
||||||
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6, expand=True)
|
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6, expand=True)
|
||||||
self.add(vbox)
|
self.add(vbox)
|
||||||
@@ -55,8 +37,8 @@ class MainWindow(Gtk.ApplicationWindow):
|
|||||||
self.notebook = Gtk.Notebook()
|
self.notebook = Gtk.Notebook()
|
||||||
vbox.add(self.notebook)
|
vbox.add(self.notebook)
|
||||||
|
|
||||||
self.notebook.append_page(ClanSelectPage(vms), Gtk.Label(label="Overview"))
|
self.notebook.append_page(ClanSelectPage(), Gtk.Label(label="Overview"))
|
||||||
self.notebook.append_page(ClanJoinPage(), Gtk.Label(label="Add/Join"))
|
self.notebook.append_page(ClanJoinPage(), Gtk.Label(label="Join"))
|
||||||
|
|
||||||
# Must be called AFTER all components were added
|
# Must be called AFTER all components were added
|
||||||
self.show_all()
|
self.show_all()
|
||||||
|
|||||||
BIN
pkgs/clan-vm-manager/clan_vm_manager/assets/cybernet.jpeg
Normal file
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 98 KiB |
BIN
pkgs/clan-vm-manager/clan_vm_manager/assets/firestorm.jpeg
Normal file
|
After Width: | Height: | Size: 155 KiB |
BIN
pkgs/clan-vm-manager/clan_vm_manager/assets/penguin.jpeg
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
pkgs/clan-vm-manager/clan_vm_manager/assets/placeholder.jpeg
Normal file
|
After Width: | Height: | Size: 163 KiB |
BIN
pkgs/clan-vm-manager/clan_vm_manager/assets/placeholder2.jpeg
Normal file
|
After Width: | Height: | Size: 183 KiB |
BIN
pkgs/clan-vm-manager/clan_vm_manager/assets/zenith.jpeg
Normal file
|
After Width: | Height: | Size: 152 KiB |
89
pkgs/clan-vm-manager/clan_vm_manager/models.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
from collections import OrderedDict
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import clan_cli
|
||||||
|
from gi.repository import GdkPixbuf
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class VMBase:
|
||||||
|
icon: Path | GdkPixbuf.Pixbuf
|
||||||
|
name: str
|
||||||
|
url: str
|
||||||
|
running: bool
|
||||||
|
_path: Path
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def name_to_type_map() -> OrderedDict[str, type]:
|
||||||
|
return OrderedDict(
|
||||||
|
{
|
||||||
|
"Icon": GdkPixbuf.Pixbuf,
|
||||||
|
"Name": str,
|
||||||
|
"URL": str,
|
||||||
|
"Running": bool,
|
||||||
|
"_Path": str,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def list_data(self) -> OrderedDict[str, Any]:
|
||||||
|
return OrderedDict(
|
||||||
|
{
|
||||||
|
"Icon": str(self.icon),
|
||||||
|
"Name": self.name,
|
||||||
|
"URL": self.url,
|
||||||
|
"Running": self.running,
|
||||||
|
"_Path": str(self._path),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class VM(VMBase):
|
||||||
|
autostart: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def list_vms() -> list[VM]:
|
||||||
|
assets = Path(__file__).parent / "assets"
|
||||||
|
|
||||||
|
vms = [
|
||||||
|
VM(
|
||||||
|
icon=assets / "cybernet.jpeg",
|
||||||
|
name="Cybernet Clan",
|
||||||
|
url="clan://cybernet.lol",
|
||||||
|
_path=Path(__file__).parent.parent / "test_democlan",
|
||||||
|
running=True,
|
||||||
|
),
|
||||||
|
VM(
|
||||||
|
icon=assets / "zenith.jpeg",
|
||||||
|
name="Zenith Clan",
|
||||||
|
url="clan://zenith.lol",
|
||||||
|
_path=Path(__file__).parent.parent / "test_democlan",
|
||||||
|
running=False,
|
||||||
|
),
|
||||||
|
VM(
|
||||||
|
icon=assets / "firestorm.jpeg",
|
||||||
|
name="Firestorm Clan",
|
||||||
|
url="clan://firestorm.lol",
|
||||||
|
_path=Path(__file__).parent.parent / "test_democlan",
|
||||||
|
running=False,
|
||||||
|
),
|
||||||
|
VM(
|
||||||
|
icon=assets / "placeholder.jpeg",
|
||||||
|
name="Demo Clan",
|
||||||
|
url="clan://demo.lol",
|
||||||
|
_path=Path(__file__).parent.parent / "test_democlan",
|
||||||
|
running=False,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
for path in clan_cli.flakes.history.list_history():
|
||||||
|
new_vm = {
|
||||||
|
"icon": assets / "placeholder.jpeg",
|
||||||
|
"name": "Placeholder Clan",
|
||||||
|
"url": "clan://placeholder.lol",
|
||||||
|
"path": path,
|
||||||
|
}
|
||||||
|
vms.append(VM(**new_vm))
|
||||||
|
return vms
|
||||||
@@ -1,22 +1,29 @@
|
|||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from gi.repository import Gtk
|
from gi.repository import GdkPixbuf, Gtk
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
from ..models import VM, VMBase, list_vms
|
||||||
from ..app import VM
|
|
||||||
|
|
||||||
|
|
||||||
class ClanSelectPage(Gtk.Box):
|
class ClanSelectPage(Gtk.Box):
|
||||||
def __init__(self, vms: list["VM"]) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__(orientation=Gtk.Orientation.VERTICAL, expand=True)
|
super().__init__(orientation=Gtk.Orientation.VERTICAL, expand=True)
|
||||||
|
|
||||||
self.add(ClanSelectList(vms, self.on_cell_toggled, self.on_select_row))
|
vms = list_vms()
|
||||||
self.add(
|
|
||||||
ClanSelectButtons(
|
list_hooks = {
|
||||||
self.on_start_clicked, self.on_stop_clicked, self.on_backup_clicked
|
"on_cell_toggled": self.on_cell_toggled,
|
||||||
)
|
"on_select_row": self.on_select_row,
|
||||||
)
|
"on_double_click": self.on_double_click,
|
||||||
|
}
|
||||||
|
self.add(ClanSelectList(vms=vms, **list_hooks))
|
||||||
|
|
||||||
|
button_hooks = {
|
||||||
|
"on_start_clicked": self.on_start_clicked,
|
||||||
|
"on_stop_clicked": self.on_stop_clicked,
|
||||||
|
"on_backup_clicked": self.on_backup_clicked,
|
||||||
|
}
|
||||||
|
self.add(ClanSelectButtons(**button_hooks))
|
||||||
|
|
||||||
def on_start_clicked(self, widget: Gtk.Widget) -> None:
|
def on_start_clicked(self, widget: Gtk.Widget) -> None:
|
||||||
print("Start clicked")
|
print("Start clicked")
|
||||||
@@ -27,26 +34,36 @@ class ClanSelectPage(Gtk.Box):
|
|||||||
def on_backup_clicked(self, widget: Gtk.Widget) -> None:
|
def on_backup_clicked(self, widget: Gtk.Widget) -> None:
|
||||||
print("Backup clicked")
|
print("Backup clicked")
|
||||||
|
|
||||||
def on_cell_toggled(self, widget: Gtk.Widget, path: str) -> None:
|
def on_cell_toggled(self, vm: VMBase) -> None:
|
||||||
print(f"on_cell_toggled: {path}")
|
print(f"on_cell_toggled: {vm}")
|
||||||
# Get the current value from the model
|
# # Get the current value from the model
|
||||||
current_value = self.list_store[path][1]
|
# current_value = self.list_store[path][1]
|
||||||
|
|
||||||
print(f"current_value: {current_value}")
|
# print(f"current_value: {current_value}")
|
||||||
# Toggle the value
|
# # Toggle the value
|
||||||
self.list_store[path][1] = not current_value
|
# self.list_store[path][1] = not current_value
|
||||||
# Print the updated value
|
# # Print the updated value
|
||||||
print("Switched", path, "to", self.list_store[path][1])
|
# print("Switched", path, "to", self.list_store[path][1])
|
||||||
|
|
||||||
def on_select_row(self, selection: Gtk.TreeSelection) -> None:
|
def on_select_row(self, selection: Gtk.TreeSelection) -> None:
|
||||||
model, row = selection.get_selected()
|
model, row = selection.get_selected()
|
||||||
if row is not None:
|
if row is not None:
|
||||||
print(f"Selected {model[row][0]}")
|
print(f"Selected {model[row][1]}")
|
||||||
|
|
||||||
|
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:
|
||||||
|
print(f"Double clicked {model[row][1]}")
|
||||||
|
|
||||||
|
|
||||||
class ClanSelectButtons(Gtk.Box):
|
class ClanSelectButtons(Gtk.Box):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
*,
|
||||||
on_start_clicked: Callable[[Gtk.Widget], None],
|
on_start_clicked: Callable[[Gtk.Widget], None],
|
||||||
on_stop_clicked: Callable[[Gtk.Widget], None],
|
on_stop_clicked: Callable[[Gtk.Widget], None],
|
||||||
on_backup_clicked: Callable[[Gtk.Widget], None],
|
on_backup_clicked: Callable[[Gtk.Widget], None],
|
||||||
@@ -55,13 +72,13 @@ class ClanSelectButtons(Gtk.Box):
|
|||||||
orientation=Gtk.Orientation.HORIZONTAL, margin_bottom=10, margin_top=10
|
orientation=Gtk.Orientation.HORIZONTAL, margin_bottom=10, margin_top=10
|
||||||
)
|
)
|
||||||
|
|
||||||
button = Gtk.Button(label="Start", margin_left=10)
|
button = Gtk.Button(label="Join", margin_left=10)
|
||||||
button.connect("clicked", on_start_clicked)
|
button.connect("clicked", on_start_clicked)
|
||||||
self.add(button)
|
self.add(button)
|
||||||
button = Gtk.Button(label="Stop", margin_left=10)
|
button = Gtk.Button(label="Leave", margin_left=10)
|
||||||
button.connect("clicked", on_stop_clicked)
|
button.connect("clicked", on_stop_clicked)
|
||||||
self.add(button)
|
self.add(button)
|
||||||
button = Gtk.Button(label="Backup", margin_left=10)
|
button = Gtk.Button(label="Edit", margin_left=10)
|
||||||
button.connect("clicked", on_backup_clicked)
|
button.connect("clicked", on_backup_clicked)
|
||||||
self.add(button)
|
self.add(button)
|
||||||
|
|
||||||
@@ -69,45 +86,68 @@ class ClanSelectButtons(Gtk.Box):
|
|||||||
class ClanSelectList(Gtk.Box):
|
class ClanSelectList(Gtk.Box):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
vms: list["VM"],
|
*,
|
||||||
on_cell_toggled: Callable[[Gtk.Widget, str], None],
|
vms: list[VM],
|
||||||
|
on_cell_toggled: Callable[[VMBase, str], None],
|
||||||
on_select_row: Callable[[Gtk.TreeSelection], None],
|
on_select_row: Callable[[Gtk.TreeSelection], None],
|
||||||
|
on_double_click: Callable[[Gtk.TreeSelection], None],
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(expand=True)
|
super().__init__(expand=True)
|
||||||
self.vms = vms
|
self.vms = vms
|
||||||
|
self.on_cell_toggled = on_cell_toggled
|
||||||
self.list_store = Gtk.ListStore(str, bool, str)
|
self.list_store = Gtk.ListStore(*VM.name_to_type_map().values())
|
||||||
for vm in vms:
|
for vm in vms:
|
||||||
items = list(vm.__dict__.values())
|
items = list(vm.list_data().values())
|
||||||
print(f"Table: {items}")
|
items[0] = GdkPixbuf.Pixbuf.new_from_file_at_scale(
|
||||||
|
filename=items[0], width=64, height=64, preserve_aspect_ratio=True
|
||||||
|
)
|
||||||
self.list_store.append(items)
|
self.list_store.append(items)
|
||||||
|
|
||||||
self.tree_view = Gtk.TreeView(self.list_store, expand=True)
|
self.tree_view = Gtk.TreeView(self.list_store, expand=True)
|
||||||
for idx, (key, value) in enumerate(vm.__dict__.items()):
|
for idx, (key, value) in enumerate(vm.list_data().items()):
|
||||||
if isinstance(value, str):
|
if key.startswith("_"):
|
||||||
renderer = Gtk.CellRendererText()
|
continue
|
||||||
# renderer.set_property("xalign", 0.5)
|
match key:
|
||||||
col = Gtk.TreeViewColumn(key.capitalize(), renderer, text=idx)
|
case "Icon":
|
||||||
col.set_resizable(True)
|
renderer = Gtk.CellRendererPixbuf()
|
||||||
col.set_expand(True)
|
col = Gtk.TreeViewColumn(key, renderer, pixbuf=idx)
|
||||||
col.set_property("sizing", Gtk.TreeViewColumnSizing.AUTOSIZE)
|
# col.add_attribute(renderer, "pixbuf", idx)
|
||||||
col.set_property("alignment", 0.5)
|
col.set_resizable(True)
|
||||||
col.set_sort_column_id(idx)
|
col.set_expand(True)
|
||||||
self.tree_view.append_column(col)
|
col.set_property("sizing", Gtk.TreeViewColumnSizing.AUTOSIZE)
|
||||||
if isinstance(value, bool):
|
col.set_property("alignment", 0.5)
|
||||||
renderer = Gtk.CellRendererToggle()
|
col.set_sort_column_id(idx)
|
||||||
renderer.set_property("activatable", True)
|
self.tree_view.append_column(col)
|
||||||
renderer.connect("toggled", on_cell_toggled)
|
case "Name" | "URL":
|
||||||
col = Gtk.TreeViewColumn(key.capitalize(), renderer, active=idx)
|
renderer = Gtk.CellRendererText()
|
||||||
col.set_resizable(True)
|
# renderer.set_property("xalign", 0.5)
|
||||||
col.set_expand(True)
|
col = Gtk.TreeViewColumn(key, renderer, text=idx)
|
||||||
col.set_property("sizing", Gtk.TreeViewColumnSizing.AUTOSIZE)
|
col.set_resizable(True)
|
||||||
col.set_property("alignment", 0.5)
|
col.set_expand(True)
|
||||||
col.set_sort_column_id(idx)
|
col.set_property("sizing", Gtk.TreeViewColumnSizing.AUTOSIZE)
|
||||||
self.tree_view.append_column(col)
|
col.set_property("alignment", 0.5)
|
||||||
|
col.set_sort_column_id(idx)
|
||||||
|
self.tree_view.append_column(col)
|
||||||
|
case "Running":
|
||||||
|
renderer = Gtk.CellRendererToggle()
|
||||||
|
renderer.set_property("activatable", True)
|
||||||
|
renderer.connect("toggled", self._on_cell_toggled)
|
||||||
|
col = Gtk.TreeViewColumn(key, renderer, active=idx)
|
||||||
|
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)
|
||||||
|
self.tree_view.append_column(col)
|
||||||
|
|
||||||
selection = self.tree_view.get_selection()
|
selection = self.tree_view.get_selection()
|
||||||
selection.connect("changed", on_select_row)
|
selection.connect("changed", on_select_row)
|
||||||
|
self.tree_view.connect("row-activated", on_double_click)
|
||||||
|
|
||||||
self.set_border_width(10)
|
self.set_border_width(10)
|
||||||
self.add(self.tree_view)
|
self.add(self.tree_view)
|
||||||
|
|
||||||
|
def _on_cell_toggled(self, widget: Gtk.CellRendererToggle, path: str) -> None:
|
||||||
|
row = self.list_store[path]
|
||||||
|
vm = VMBase(*row)
|
||||||
|
self.on_cell_toggled(vm)
|
||||||
|
|||||||