From 16a629131394ef156649103a7f4bc7c6d2a62a36 Mon Sep 17 00:00:00 2001 From: Aaron Dinesh Date: Sat, 30 Aug 2025 18:23:49 +0100 Subject: [PATCH 01/13] Added style.css loading in ui/styles.py --- app/main.py | 27 +++++++------- app/ui/main_window.py | 87 +++++++++++++++++++++++-------------------- app/ui/styles.py | 41 ++++++++++++-------- 3 files changed, 86 insertions(+), 69 deletions(-) diff --git a/app/main.py b/app/main.py index 1dc7847..4308f02 100755 --- a/app/main.py +++ b/app/main.py @@ -1,9 +1,11 @@ #!/usr/bin/env python3 -import gi import argparse import sys + +import gi from network_service import NetworkService from ui.main_window import NetworkManagerWindow + gi.require_version("Gtk", "4.0") from gi.repository import Gtk from ui.styles import StyleManager @@ -11,42 +13,41 @@ # Application version __version__ = "1.0.0" + class NetworkManagerApp(Gtk.Application): """Main application class""" - + def __init__(self): super().__init__(application_id="com.network.manager") - + def do_activate(self): """Application activation""" StyleManager.apply_styles() win = NetworkManagerWindow(self) win.present() + def parse_arguments(): """Parse command line arguments""" parser = argparse.ArgumentParser( description="Network Manager - A GTK4 network management application", - prog="network-manager" - ) - - parser.add_argument( - "-v", "--version", - action="version", - version=f"%(prog)s {__version__}" + prog="network-manager", ) - + + parser.add_argument("-v", "--version", action="version", version=f"%(prog)s {__version__}") + return parser.parse_args() + if __name__ == "__main__": try: # Parse command line arguments first args = parse_arguments() - + # Check if NetworkManager is available before starting the app if not NetworkService.check_networkmanager(): sys.exit(1) - + # If we get here, NetworkManager is available, so start the app app = NetworkManagerApp() app.run() diff --git a/app/ui/main_window.py b/app/ui/main_window.py index c3d8ead..0e255d3 100644 --- a/app/ui/main_window.py +++ b/app/ui/main_window.py @@ -1,24 +1,27 @@ import gi - from models import NetworkInfo, WiFiState + from ui.network_list import NetworkListWidget + gi.require_version("Gtk", "4.0") -from gi.repository import Gtk, Gdk, GLib from typing import Optional -from network_service import NetworkService +from gi.repository import Gdk, GLib, Gtk +from network_service import NetworkService + +from ui.dialogs import PasswordDialog from ui.network_details import NetworkDetailsWidget from ui.wifi_off import WiFiOffWidget -from ui.dialogs import PasswordDialog + class NetworkManagerWindow(Gtk.ApplicationWindow): """Main application window""" - + def __init__(self, app): super().__init__(application=app) self.set_title("Network Manager") self.set_default_size(450, 350) - + self.current_state = WiFiState.OFF self.current_view = "list" # "list" or "details" self._setup_ui() @@ -28,7 +31,7 @@ def __init__(self, app): key_controller = Gtk.EventControllerKey.new() key_controller.connect("key-pressed", self._on_esc_pressed) self.add_controller(key_controller) - + def _setup_ui(self): """Setup the main UI""" main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) @@ -36,46 +39,46 @@ def _setup_ui(self): main_box.set_margin_bottom(20) main_box.set_margin_start(20) main_box.set_margin_end(20) - + main_box.append(self._create_wifi_toggle()) main_box.append(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)) - + self.content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, vexpand=True) main_box.append(self.content_box) - + self.set_child(main_box) - + def _create_wifi_toggle(self) -> Gtk.Box: """Create the WiFi toggle section""" self.wifi_switch = Gtk.Switch(valign=Gtk.Align.CENTER) self.wifi_switch.connect("state-set", self._on_wifi_toggled) - + wifi_label = Gtk.Label(label="Wi-Fi", xalign=0, name="wifi-label") - + toggle_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) toggle_box.append(wifi_label) toggle_box.append(Gtk.Box(hexpand=True)) # Spacer toggle_box.append(self.wifi_switch) - + return toggle_box - + def _on_wifi_toggled(self, switch, state): """Handle WiFi toggle""" current_state = NetworkService.get_wifi_status() - + if current_state == state: return - + if NetworkService.toggle_wifi(state): self._update_wifi_state(scan_immediately=state) else: switch.set_active(current_state) - + def _update_wifi_state(self, scan_immediately=False, initial_load=False): """Update the UI based on WiFi state""" wifi_status = NetworkService.get_wifi_status() self.wifi_switch.set_active(wifi_status) - + if wifi_status: self.current_state = WiFiState.ON if self.current_view == "list": @@ -84,36 +87,36 @@ def _update_wifi_state(self, scan_immediately=False, initial_load=False): self.current_state = WiFiState.OFF self.current_view = "list" self._show_wifi_off() - + def _show_network_list(self, scan_immediately=False): """Show the network list widget""" self.current_view = "list" self._clear_content() - + self.network_list = NetworkListWidget(self._on_network_selected, self._on_network_details) self.content_box.append(self.network_list) - + if scan_immediately: GLib.timeout_add(500, lambda: self.network_list.start_scan()) - + def _show_network_details(self, network: NetworkInfo): """Show the network details widget""" self.current_view = "details" self._clear_content() - + self.network_details = NetworkDetailsWidget(network, self._on_back_to_list) self.content_box.append(self.network_details) - + def _on_back_to_list(self): """Handle back button click from details view""" self._show_network_list(scan_immediately=True) - + def _show_wifi_off(self): """Show the WiFi off widget""" self._clear_content() wifi_off_widget = WiFiOffWidget() self.content_box.append(wifi_off_widget) - + def _clear_content(self): """Clear the content area""" child = self.content_box.get_first_child() @@ -121,39 +124,43 @@ def _clear_content(self): next_child = child.get_next_sibling() self.content_box.remove(child) child = next_child - + def _on_network_selected(self, network: NetworkInfo): """Handle network selection for connection""" if NetworkService.is_wifi_known(network.ssid): self._connect_to_network(network.ssid) elif network.requires_password: - dialog = PasswordDialog(self, network.ssid, - lambda password: self._connect_to_network(network.ssid, password)) + dialog = PasswordDialog( + self, + network.ssid, + lambda password: self._connect_to_network(network.ssid, password), + ) dialog.show() else: self._connect_to_network(network.ssid) - + def _on_network_details(self, network: NetworkInfo): """Handle network details button click""" self._show_network_details(network) - + def _connect_to_network(self, ssid: str, password: Optional[str] = None): """Connect to a network""" self.current_state = WiFiState.CONNECTING - + def connect_thread(): success, message = NetworkService.connect_to_network(ssid, password) GLib.idle_add(lambda: self._connection_complete(ssid, success, message)) - + import threading + thread = threading.Thread(target=connect_thread) thread.daemon = True thread.start() - + def _connection_complete(self, ssid: str, success: bool, message: str): """Handle connection completion""" self.current_state = WiFiState.ON - + dialog = Gtk.AlertDialog() if success: dialog.set_message(f"Connected to {ssid}") @@ -161,13 +168,13 @@ def _connection_complete(self, ssid: str, success: bool, message: str): else: dialog.set_message(f"Failed to connect to {ssid}") dialog.set_detail(f"Error: {message}") - + dialog.set_modal(True) dialog.show(self) - - if self.current_view == "list" and hasattr(self, 'network_list'): + + if self.current_view == "list" and hasattr(self, "network_list"): self.network_list.start_scan() - + return False def _on_esc_pressed(self, controller, keyval, keycode, state): diff --git a/app/ui/styles.py b/app/ui/styles.py index 0a726e8..9dfa5b8 100644 --- a/app/ui/styles.py +++ b/app/ui/styles.py @@ -1,17 +1,21 @@ """CSS styling for the application""" import gi + gi.require_version("Gtk", "4.0") -from gi.repository import Gtk, Gdk +import os + +from gi.repository import Gdk, Gtk + class StyleManager: """Handles CSS styling for the application""" - + CSS_STYLES = b""" #wifi-label { font-weight: bold; font-size: 16px; - } + } #wifi-networks-label { font-weight: bold; @@ -25,18 +29,18 @@ class StyleManager: #wifi-scan-label:hover { color: #4a90d9; } - + #scanning-label { font-style: italic; color: #888888; margin: 10px; } - + #error-label { color: #cc0000; margin: 10px; } - + #no-networks-label { font-style: italic; color: #888888; @@ -50,21 +54,21 @@ class StyleManager: .connected-network { background-color: rgba(74, 144, 217, 0.1); } - + #wifi-off-box { margin: 50px; } - + #wifi-off-label { color: #666666; font-size: 14px; } - + .rescan-in-progress { color: #4a90d9; animation: pulse 1.5s infinite; } - + @keyframes pulse { 0% { opacity: 1; } 50% { opacity: 0.5; } @@ -75,30 +79,35 @@ class StyleManager: font-size: 1.5rem; font-weight: 500; } - + .title-4 { font-size: 1.1rem; font-weight: 500; margin-bottom: 8px; } - + .dim-label { opacity: 0.7; font-size: 0.9rem; } - + scrollbar slider { min-width: 6px; min-height: 6px; } """ - + @classmethod def apply_styles(cls): """Apply CSS styles to the application""" css_provider = Gtk.CssProvider() - css_provider.load_from_data(cls.CSS_STYLES) - + + # Test if the ~/.config/nmgui/style.css exists + if os.path_exists("~/.config/nmgui/style.css"): + css_provider.load_from_path("~/.config/nmgui/style.css") + else: + css_provider.load_from_data(cls.CSS_STYLES) + display = Gdk.Display.get_default() Gtk.StyleContext.add_provider_for_display( display, css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION From b90e7696506753fc506ed6b15af57c6bd1ac7777 Mon Sep 17 00:00:00 2001 From: Aaron Dinesh Date: Sat, 30 Aug 2025 18:53:27 +0100 Subject: [PATCH 02/13] Fixed error in previous code. Used path.expanduser to be more robust on different systems --- app/ui/styles.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/ui/styles.py b/app/ui/styles.py index 9dfa5b8..82ce261 100644 --- a/app/ui/styles.py +++ b/app/ui/styles.py @@ -103,9 +103,12 @@ def apply_styles(cls): css_provider = Gtk.CssProvider() # Test if the ~/.config/nmgui/style.css exists - if os.path_exists("~/.config/nmgui/style.css"): - css_provider.load_from_path("~/.config/nmgui/style.css") + home_path = os.path.expanduser("~") + if os.path.exists(f"{home_path}/.config/nmgui/style.css"): + print("Using style CSS") + css_provider.load_from_path(f"{home_path}/.config/nmgui/style.css") else: + print("No config found, using default css") css_provider.load_from_data(cls.CSS_STYLES) display = Gdk.Display.get_default() From 2526d8ee05652ce4d98c978bdba94f0d11a2a1af Mon Sep 17 00:00:00 2001 From: Aaron Dinesh Date: Sat, 30 Aug 2025 18:57:02 +0100 Subject: [PATCH 03/13] Fixed some formatting --- app/ui/styles.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/ui/styles.py b/app/ui/styles.py index 82ce261..c9fd91b 100644 --- a/app/ui/styles.py +++ b/app/ui/styles.py @@ -101,14 +101,11 @@ class StyleManager: def apply_styles(cls): """Apply CSS styles to the application""" css_provider = Gtk.CssProvider() - # Test if the ~/.config/nmgui/style.css exists home_path = os.path.expanduser("~") if os.path.exists(f"{home_path}/.config/nmgui/style.css"): - print("Using style CSS") css_provider.load_from_path(f"{home_path}/.config/nmgui/style.css") else: - print("No config found, using default css") css_provider.load_from_data(cls.CSS_STYLES) display = Gdk.Display.get_default() From 0e072304e2f6111dac3b8895933c67575e26cb7a Mon Sep 17 00:00:00 2001 From: Keo Ponleou Sok Date: Fri, 17 Oct 2025 00:33:50 +1100 Subject: [PATCH 04/13] feat: parse ethernet data from nmcli --- app/ethernet_device.py | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 app/ethernet_device.py diff --git a/app/ethernet_device.py b/app/ethernet_device.py new file mode 100644 index 0000000..4870d65 --- /dev/null +++ b/app/ethernet_device.py @@ -0,0 +1,37 @@ +import nmcli +from typing import Literal +from nmcli.data.device import DeviceDetails + + +class EthernetDevice: + device: str + hwaddr: str + name: str + ipaddr: str + gateway_addr: str + state: Literal["connected", "disconnected", "unavailable"] + + def __init__(self): + eth_dev_obj: DeviceDetails + + for dev in nmcli.device.show_all(): + if dev.get("GENERAL.TYPE") == "ethernet": + eth_dev_obj = dev + break + + self.parse_data(eth_dev_obj) + + def parse_data(self, eth_dev: DeviceDetails) -> None: + self.device = eth_dev.get("GENERAL.DEVICE") + self.hwaddr = eth_dev.get("GENERAL.HWADDR") + self.name = eth_dev.get("GENERAL.CONNECTION") + self.ipaddr = eth_dev.get("IP4.ADDRESS[1]") or eth_dev.get("IP6.ADDRESS[1]") or "unavailable" + self.gateway_addr = eth_dev.get("IP4.GATEWAY") or eth_dev.get("IP6.GATEWAY") or "unavailable" + + state: int = int(eth_dev.get("GENERAL.STATE").split(" ")[0]) + if state == 30: + self.state = "disconnected" + elif state == 100: + self.state = "connected" + else: + self.state = "unavailable" From 35144108cc69f333f4e808d645ddb0b3534e93af Mon Sep 17 00:00:00 2001 From: Keo Ponleou Sok Date: Fri, 17 Oct 2025 00:36:44 +1100 Subject: [PATCH 05/13] docs: added comments --- app/ethernet_device.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/ethernet_device.py b/app/ethernet_device.py index 4870d65..6e693c3 100644 --- a/app/ethernet_device.py +++ b/app/ethernet_device.py @@ -14,21 +14,28 @@ class EthernetDevice: def __init__(self): eth_dev_obj: DeviceDetails + # find device with ethernet type for dev in nmcli.device.show_all(): if dev.get("GENERAL.TYPE") == "ethernet": eth_dev_obj = dev break - + + # pass device object to parser self.parse_data(eth_dev_obj) def parse_data(self, eth_dev: DeviceDetails) -> None: + + # parses the information and add to properties self.device = eth_dev.get("GENERAL.DEVICE") self.hwaddr = eth_dev.get("GENERAL.HWADDR") self.name = eth_dev.get("GENERAL.CONNECTION") self.ipaddr = eth_dev.get("IP4.ADDRESS[1]") or eth_dev.get("IP6.ADDRESS[1]") or "unavailable" self.gateway_addr = eth_dev.get("IP4.GATEWAY") or eth_dev.get("IP6.GATEWAY") or "unavailable" + # the format of GENERAL.STATE is "int (str)", e.g. "100 (connected)" state: int = int(eth_dev.get("GENERAL.STATE").split(" ")[0]) + + # check for connected and disconnected, the rest is unavailable if state == 30: self.state = "disconnected" elif state == 100: From 9b41e4df671a994b4e950e48331f3d8cc22d9174 Mon Sep 17 00:00:00 2001 From: Keo Ponleou Sok Date: Fri, 17 Oct 2025 00:47:44 +1100 Subject: [PATCH 06/13] feat: added ethernet rescan method --- app/ethernet_device.py | 39 ++++++++++++++++++++++++++++----------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/app/ethernet_device.py b/app/ethernet_device.py index 6e693c3..f77d9d5 100644 --- a/app/ethernet_device.py +++ b/app/ethernet_device.py @@ -12,25 +12,29 @@ class EthernetDevice: state: Literal["connected", "disconnected", "unavailable"] def __init__(self): - eth_dev_obj: DeviceDetails + # pass device object to parser + self._parse_data(self.find_device()) + @staticmethod + def find_device() -> DeviceDetails: # find device with ethernet type for dev in nmcli.device.show_all(): if dev.get("GENERAL.TYPE") == "ethernet": - eth_dev_obj = dev - break - - # pass device object to parser - self.parse_data(eth_dev_obj) - - def parse_data(self, eth_dev: DeviceDetails) -> None: - + return dev + + def _parse_data(self, eth_dev: DeviceDetails) -> None: # parses the information and add to properties self.device = eth_dev.get("GENERAL.DEVICE") self.hwaddr = eth_dev.get("GENERAL.HWADDR") self.name = eth_dev.get("GENERAL.CONNECTION") - self.ipaddr = eth_dev.get("IP4.ADDRESS[1]") or eth_dev.get("IP6.ADDRESS[1]") or "unavailable" - self.gateway_addr = eth_dev.get("IP4.GATEWAY") or eth_dev.get("IP6.GATEWAY") or "unavailable" + self.ipaddr = ( + eth_dev.get("IP4.ADDRESS[1]") + or eth_dev.get("IP6.ADDRESS[1]") + or "unavailable" + ) + self.gateway_addr = ( + eth_dev.get("IP4.GATEWAY") or eth_dev.get("IP6.GATEWAY") or "unavailable" + ) # the format of GENERAL.STATE is "int (str)", e.g. "100 (connected)" state: int = int(eth_dev.get("GENERAL.STATE").split(" ")[0]) @@ -42,3 +46,16 @@ def parse_data(self, eth_dev: DeviceDetails) -> None: self.state = "connected" else: self.state = "unavailable" + + # not really a scan, but keeping consistent naming with network_service + def rescan(self) -> None: + self._parse_data(EthernetDevice.find_device()) + + # FIXME: delete later + def test_print(self): + print(self.device) + print(self.name) + print(self.hwaddr) + print(self.ipaddr) + print(self.gateway_addr) + print(self.state) From f3b933b434f9a3e9b4949b0dfc8cee3c955e41dc Mon Sep 17 00:00:00 2001 From: Keo Ponleou Sok Date: Fri, 17 Oct 2025 01:18:57 +1100 Subject: [PATCH 07/13] docs: changed file name to ethernet_service --- ...ethernet_device.py => ethernet_service.py} | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) rename app/{ethernet_device.py => ethernet_service.py} (81%) diff --git a/app/ethernet_device.py b/app/ethernet_service.py similarity index 81% rename from app/ethernet_device.py rename to app/ethernet_service.py index f77d9d5..bfeea37 100644 --- a/app/ethernet_device.py +++ b/app/ethernet_service.py @@ -1,9 +1,9 @@ import nmcli -from typing import Literal +from typing import Literal, overload from nmcli.data.device import DeviceDetails -class EthernetDevice: +class EthernetDetails: device: str hwaddr: str name: str @@ -13,14 +13,9 @@ class EthernetDevice: def __init__(self): # pass device object to parser - self._parse_data(self.find_device()) - - @staticmethod - def find_device() -> DeviceDetails: - # find device with ethernet type - for dev in nmcli.device.show_all(): - if dev.get("GENERAL.TYPE") == "ethernet": - return dev + details = EthernetService.get_device_details() + if details != None: + self._parse_data(details) def _parse_data(self, eth_dev: DeviceDetails) -> None: # parses the information and add to properties @@ -49,7 +44,9 @@ def _parse_data(self, eth_dev: DeviceDetails) -> None: # not really a scan, but keeping consistent naming with network_service def rescan(self) -> None: - self._parse_data(EthernetDevice.find_device()) + details = EthernetService.get_device_details() + if details != None: + self._parse_data(details) # FIXME: delete later def test_print(self): @@ -59,3 +56,15 @@ def test_print(self): print(self.ipaddr) print(self.gateway_addr) print(self.state) + + +class EthernetService: + + @staticmethod + def get_device_details() -> DeviceDetails | None: + # find device with ethernet type + for dev in nmcli.device.show_all(): + if dev.get("GENERAL.TYPE") == "ethernet": + return dev + + return None From 3b180c2a057bd90e6527e6906b22e3f6a6b55645 Mon Sep 17 00:00:00 2001 From: Keo Ponleou Sok Date: Fri, 17 Oct 2025 01:19:26 +1100 Subject: [PATCH 08/13] feat: added service methods to check status and availability --- app/ethernet_service.py | 69 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/app/ethernet_service.py b/app/ethernet_service.py index bfeea37..d04558d 100644 --- a/app/ethernet_service.py +++ b/app/ethernet_service.py @@ -68,3 +68,72 @@ def get_device_details() -> DeviceDetails | None: return dev return None + + @staticmethod + def get_device() -> str | None: + details = EthernetService.get_device_details() + + if details == None: + return None + + return details.get("GENERAL.DEVICE") + + @overload + @staticmethod + def get_ethernet_status() -> bool: ... + @overload + @staticmethod + def get_ethernet_status(details: EthernetDetails) -> bool: ... + + def get_ethernet_status(details: EthernetDetails | None = None) -> bool: + if details == None: + details = EthernetService.get_device_details() + + # no ethernet device available + if details == None: + return False + + state = int(details.get("GENERAL.STATE").split(" ")[0]) + + if state == 100: + return True + + return False + + # if details is an EthernetDetails object + else: + if details.state == "connected": + return True + + return False + + @overload + @staticmethod + def is_ethernet_available() -> bool: ... + + @overload + @staticmethod + def is_ethernet_available(details: EthernetDetails) -> bool: ... + + def is_ethernet_available(details: EthernetDetails | None = None) -> bool: + if details == None: + details = EthernetService.get_device_details() + + # no ethernet device available + if details == None: + return False + + state = int(details.get("GENERAL.STATE").split(" ")[0]) + + # if state is disconnected or connected, then its available + if state == 30 or state == 100: + return True + + return False + + # if details is an EthernetDetails object + else: + if details.state != "unavailable": + return True + + return False From 0a1853c6b93eb63b4210f12f363572398d853003 Mon Sep 17 00:00:00 2001 From: Keo Ponleou Sok Date: Fri, 17 Oct 2025 02:06:46 +1100 Subject: [PATCH 09/13] feat: create ethernet toggle function --- app/ethernet_service.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/app/ethernet_service.py b/app/ethernet_service.py index d04558d..c33860d 100644 --- a/app/ethernet_service.py +++ b/app/ethernet_service.py @@ -137,3 +137,22 @@ def is_ethernet_available(details: EthernetDetails | None = None) -> bool: return True return False + + @staticmethod + def toggle_ethernet(state: bool) -> bool: + """Enable or disable Ethernet""" + try: + current_state = EthernetService.get_ethernet_status() + if current_state == state: + return True + + if state: + nmcli._syscmd.nmcli(["device", "connect", EthernetService.get_device()]) + else: + nmcli._syscmd.nmcli( + ["device", "disconnect", EthernetService.get_device()] + ) + return True + except Exception as e: + print(f"Error toggling Ethernet: {e}") + return False From 956db201a1dff5d7bb9a59a2e67c4f066de7e619 Mon Sep 17 00:00:00 2001 From: Keo Ponleou Sok Date: Fri, 17 Oct 2025 02:08:06 +1100 Subject: [PATCH 10/13] feat: added ui switch for ethernet --- app/ui/main_window.py | 30 ++++++++++++++++++++++++++++++ app/ui/styles.py | 2 +- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/app/ui/main_window.py b/app/ui/main_window.py index 0e255d3..e39453e 100644 --- a/app/ui/main_window.py +++ b/app/ui/main_window.py @@ -8,6 +8,7 @@ from gi.repository import Gdk, GLib, Gtk from network_service import NetworkService +from ethernet_service import EthernetService from ui.dialogs import PasswordDialog from ui.network_details import NetworkDetailsWidget @@ -40,6 +41,9 @@ def _setup_ui(self): main_box.set_margin_start(20) main_box.set_margin_end(20) + main_box.append(self._create_ethernet_toggle()) + main_box.append(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)) + main_box.append(self._create_wifi_toggle()) main_box.append(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)) @@ -48,6 +52,32 @@ def _setup_ui(self): self.set_child(main_box) + def _create_ethernet_toggle(self) -> Gtk.Box: + """Create the Ethernet toggle section""" + self.ethernet_switch = Gtk.Switch(valign=Gtk.Align.CENTER) + self.ethernet_switch.set_sensitive(EthernetService.is_ethernet_available()) + self.ethernet_switch.set_active(EthernetService.get_ethernet_status()) + self.ethernet_switch.connect("state-set", self._on_ethernet_toggled) + + ethernet_label = Gtk.Label(label="Ethernet", xalign=0, name="ethernet-label") + + toggle_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + toggle_box.append(ethernet_label) + toggle_box.append(Gtk.Box(hexpand=True)) # Spacer + toggle_box.append(self.ethernet_switch) + + return toggle_box + + def _on_ethernet_toggled(self, switch, state): + """Handle Ethernet toggle""" + current_state = EthernetService.get_ethernet_status() + + if current_state == state: + return + + if EthernetService.toggle_ethernet(state): + switch.set_active(state) + def _create_wifi_toggle(self) -> Gtk.Box: """Create the WiFi toggle section""" self.wifi_switch = Gtk.Switch(valign=Gtk.Align.CENTER) diff --git a/app/ui/styles.py b/app/ui/styles.py index c9fd91b..e0fc5e8 100644 --- a/app/ui/styles.py +++ b/app/ui/styles.py @@ -12,7 +12,7 @@ class StyleManager: """Handles CSS styling for the application""" CSS_STYLES = b""" - #wifi-label { + #wifi-label, #ethernet-label { font-weight: bold; font-size: 16px; } From 25bd934d43bc085abfdfaf5e5408eb5394b1734f Mon Sep 17 00:00:00 2001 From: Keo Ponleou Sok Date: Fri, 17 Oct 2025 15:20:46 +1100 Subject: [PATCH 11/13] feat: added ethernet details, update, and refreshes --- app/ui/main_window.py | 106 ++++++++++++++++++++++++++++++++++++++++-- app/ui/styles.py | 4 +- 2 files changed, 105 insertions(+), 5 deletions(-) diff --git a/app/ui/main_window.py b/app/ui/main_window.py index e39453e..62e6287 100644 --- a/app/ui/main_window.py +++ b/app/ui/main_window.py @@ -1,14 +1,15 @@ import gi +from ui.utils import UIUtils from models import NetworkInfo, WiFiState from ui.network_list import NetworkListWidget gi.require_version("Gtk", "4.0") -from typing import Optional +from typing import Callable, Optional from gi.repository import Gdk, GLib, Gtk from network_service import NetworkService -from ethernet_service import EthernetService +from ethernet_service import EthernetService, EthernetDetails from ui.dialogs import PasswordDialog from ui.network_details import NetworkDetailsWidget @@ -43,6 +44,15 @@ def _setup_ui(self): main_box.append(self._create_ethernet_toggle()) main_box.append(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)) + main_box.append(self._create_ethernet_header()) + + self.ethernet_content = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, spacing=12 + ) + main_box.append(self.ethernet_content) + self._show_ethernet_content() + + main_box.append(Gtk.Box(hexpand=True)) # spacer main_box.append(self._create_wifi_toggle()) main_box.append(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)) @@ -55,7 +65,9 @@ def _setup_ui(self): def _create_ethernet_toggle(self) -> Gtk.Box: """Create the Ethernet toggle section""" self.ethernet_switch = Gtk.Switch(valign=Gtk.Align.CENTER) + self.ethernet_switch.set_sensitive(EthernetService.is_ethernet_available()) + self.ethernet_switch.set_active(EthernetService.get_ethernet_status()) self.ethernet_switch.connect("state-set", self._on_ethernet_toggled) @@ -78,6 +90,92 @@ def _on_ethernet_toggled(self, switch, state): if EthernetService.toggle_ethernet(state): switch.set_active(state) + self._update_ethernet() + + def _create_ethernet_header(self) -> Gtk.Box: + self.ethernet_scan_label = Gtk.Label( + label="Refresh", name="ethernet-scan-label" + ) + self.ethernet_scan_label.set_cursor_from_name("pointer") + self.ethernet_scan_label.add_controller( + self._create_click_controller(self._update_ethernet) + ) + + self.spinner = Gtk.Spinner() + self.spinner.set_size_request(20, 20) + + self.ethernet_label = Gtk.Label( + label=EthernetDetails().name or "Not Connected", + xalign=0, + name="ethernet-name-label", + ) + + header_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + header_box.append(self.ethernet_label) + header_box.append(Gtk.Box(hexpand=True)) # Spacer + header_box.append(self.spinner) + header_box.append(self.ethernet_scan_label) + + return header_box + + def _create_click_controller(self, callback: Callable): + """Create a click controller for a label""" + controller = Gtk.GestureClick() + controller.set_button(0) + controller.connect("pressed", lambda *args: callback()) + return controller + + def _clear_ethernet_content(self): + child = self.ethernet_content.get_first_child() + while child: + next_child = child.get_next_sibling() + self.ethernet_content.remove(child) + child = next_child + + def _show_ethernet_content(self): + if not EthernetService.is_ethernet_available(): + return + + details = EthernetDetails() + + self.ethernet_content.append( + UIUtils.create_detail_row( + "Network Status", + details.state.title(), + "network-transmit-receive-symbolic", + ) + ) + self.ethernet_content.append( + UIUtils.create_detail_row( + "IP Address", details.ipaddr, "network-workgroup-symbolic" + ) + ) + self.ethernet_content.append( + UIUtils.create_detail_row( + "MAC Address", details.hwaddr, "network-wired-symbolic" + ) + ) + + def _update_ethernet(self): + self.spinner.start() + self.ethernet_scan_label.set_sensitive(False) + self.ethernet_scan_label.add_css_class("rescan-in-progress") + + self.ethernet_label.set_label(EthernetDetails().name or "Not Connected") + + if EthernetService.is_ethernet_available(): + self._clear_ethernet_content() + self._show_ethernet_content() + self.ethernet_switch.set_sensitive(True) + else: + self._clear_ethernet_content() + self.ethernet_switch.set_active(False) + self.ethernet_switch.set_sensitive(False) + + self.spinner.stop() + self.ethernet_scan_label.set_sensitive(True) + self.ethernet_scan_label.remove_css_class("rescan-in-progress") + def _create_wifi_toggle(self) -> Gtk.Box: """Create the WiFi toggle section""" self.wifi_switch = Gtk.Switch(valign=Gtk.Align.CENTER) @@ -123,7 +221,9 @@ def _show_network_list(self, scan_immediately=False): self.current_view = "list" self._clear_content() - self.network_list = NetworkListWidget(self._on_network_selected, self._on_network_details) + self.network_list = NetworkListWidget( + self._on_network_selected, self._on_network_details + ) self.content_box.append(self.network_list) if scan_immediately: diff --git a/app/ui/styles.py b/app/ui/styles.py index e0fc5e8..6283414 100644 --- a/app/ui/styles.py +++ b/app/ui/styles.py @@ -17,12 +17,12 @@ class StyleManager: font-size: 16px; } - #wifi-networks-label { + #wifi-networks-label, #ethernet-name-label { font-weight: bold; font-size: 14px; } - #wifi-scan-label { + #wifi-scan-label, #ethernet-scan-label { font-size: 14px; } From 48ffa9baa52ae18d1b01409163cfa8da8f0c93d1 Mon Sep 17 00:00:00 2001 From: Keo Ponleou Sok Date: Fri, 17 Oct 2025 15:45:46 +1100 Subject: [PATCH 12/13] feat: moved ethernet methods and properties from main_window to widget --- app/ui/ethernet_details.py | 106 +++++++++++++++++++++++++++++++++++++ app/ui/main_window.py | 101 +++-------------------------------- 2 files changed, 112 insertions(+), 95 deletions(-) create mode 100644 app/ui/ethernet_details.py diff --git a/app/ui/ethernet_details.py b/app/ui/ethernet_details.py new file mode 100644 index 0000000..af1a857 --- /dev/null +++ b/app/ui/ethernet_details.py @@ -0,0 +1,106 @@ +from typing import Callable +import gi + +from ui.utils import UIUtils +from ethernet_service import EthernetService, EthernetDetails + +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk + + +class EthernetDetailsWidget(Gtk.Box): + content: Gtk.Box + refresh_label: Gtk.Label + name_label: Gtk.Label + spinner: Gtk.Spinner + switch: Gtk.Switch + + def __init__(self, ethernet_switch: Gtk.Switch): + super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=10) + + self.content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + self.switch = ethernet_switch + + self._create_header() + self.append(self.content) + self._show_content() + + def _create_header(self): + self.refresh_label = Gtk.Label(label="Refresh", name="ethernet-scan-label") + self.refresh_label.set_cursor_from_name("pointer") + self.refresh_label.add_controller(self._create_click_controller(self.update)) + + self.spinner = Gtk.Spinner() + self.spinner.set_size_request(20, 20) + + self.name_label = Gtk.Label( + label=EthernetDetails().name or "Not Connected", + xalign=0, + name="ethernet-name-label", + ) + + header_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + header_box.append(self.name_label) + header_box.append(Gtk.Box(hexpand=True)) # Spacer + header_box.append(self.spinner) + header_box.append(self.refresh_label) + + self.append(header_box) + + def _create_click_controller(self, callback: Callable): + """Create a click controller for a label""" + controller = Gtk.GestureClick() + controller.set_button(0) + controller.connect("pressed", lambda *args: callback()) + return controller + + def _show_content(self): + if not EthernetService.is_ethernet_available(): + return + + details = EthernetDetails() + + self.content.append( + UIUtils.create_detail_row( + "Network Status", + details.state.title(), + "network-transmit-receive-symbolic", + ) + ) + self.content.append( + UIUtils.create_detail_row( + "IP Address", details.ipaddr, "network-workgroup-symbolic" + ) + ) + self.content.append( + UIUtils.create_detail_row( + "MAC Address", details.hwaddr, "network-wired-symbolic" + ) + ) + + def _clear_content(self): + child = self.content.get_first_child() + while child: + next_child = child.get_next_sibling() + self.content.remove(child) + child = next_child + + def update(self): + self.spinner.start() + self.refresh_label.set_sensitive(False) + self.refresh_label.add_css_class("rescan-in-progress") + + self.name_label.set_label(EthernetDetails().name or "Not Connected") + + if EthernetService.is_ethernet_available(): + self._clear_content() + self._show_content() + self.switch.set_sensitive(True) + else: + self._clear_content() + self.switch.set_active(False) + self.switch.set_sensitive(False) + + self.spinner.stop() + self.refresh_label.set_sensitive(True) + self.refresh_label.remove_css_class("rescan-in-progress") diff --git a/app/ui/main_window.py b/app/ui/main_window.py index 62e6287..c4e0683 100644 --- a/app/ui/main_window.py +++ b/app/ui/main_window.py @@ -1,15 +1,15 @@ import gi -from ui.utils import UIUtils +from ui.ethernet_details import EthernetDetailsWidget from models import NetworkInfo, WiFiState from ui.network_list import NetworkListWidget gi.require_version("Gtk", "4.0") -from typing import Callable, Optional +from typing import Optional from gi.repository import Gdk, GLib, Gtk from network_service import NetworkService -from ethernet_service import EthernetService, EthernetDetails +from ethernet_service import EthernetService from ui.dialogs import PasswordDialog from ui.network_details import NetworkDetailsWidget @@ -44,13 +44,8 @@ def _setup_ui(self): main_box.append(self._create_ethernet_toggle()) main_box.append(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)) - main_box.append(self._create_ethernet_header()) - - self.ethernet_content = Gtk.Box( - orientation=Gtk.Orientation.VERTICAL, spacing=12 - ) - main_box.append(self.ethernet_content) - self._show_ethernet_content() + self.ethernet_details_widget = EthernetDetailsWidget(self.ethernet_switch) + main_box.append(self.ethernet_details_widget) main_box.append(Gtk.Box(hexpand=True)) # spacer @@ -90,91 +85,7 @@ def _on_ethernet_toggled(self, switch, state): if EthernetService.toggle_ethernet(state): switch.set_active(state) - self._update_ethernet() - - def _create_ethernet_header(self) -> Gtk.Box: - self.ethernet_scan_label = Gtk.Label( - label="Refresh", name="ethernet-scan-label" - ) - self.ethernet_scan_label.set_cursor_from_name("pointer") - self.ethernet_scan_label.add_controller( - self._create_click_controller(self._update_ethernet) - ) - - self.spinner = Gtk.Spinner() - self.spinner.set_size_request(20, 20) - - self.ethernet_label = Gtk.Label( - label=EthernetDetails().name or "Not Connected", - xalign=0, - name="ethernet-name-label", - ) - - header_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) - header_box.append(self.ethernet_label) - header_box.append(Gtk.Box(hexpand=True)) # Spacer - header_box.append(self.spinner) - header_box.append(self.ethernet_scan_label) - - return header_box - - def _create_click_controller(self, callback: Callable): - """Create a click controller for a label""" - controller = Gtk.GestureClick() - controller.set_button(0) - controller.connect("pressed", lambda *args: callback()) - return controller - - def _clear_ethernet_content(self): - child = self.ethernet_content.get_first_child() - while child: - next_child = child.get_next_sibling() - self.ethernet_content.remove(child) - child = next_child - - def _show_ethernet_content(self): - if not EthernetService.is_ethernet_available(): - return - - details = EthernetDetails() - - self.ethernet_content.append( - UIUtils.create_detail_row( - "Network Status", - details.state.title(), - "network-transmit-receive-symbolic", - ) - ) - self.ethernet_content.append( - UIUtils.create_detail_row( - "IP Address", details.ipaddr, "network-workgroup-symbolic" - ) - ) - self.ethernet_content.append( - UIUtils.create_detail_row( - "MAC Address", details.hwaddr, "network-wired-symbolic" - ) - ) - - def _update_ethernet(self): - self.spinner.start() - self.ethernet_scan_label.set_sensitive(False) - self.ethernet_scan_label.add_css_class("rescan-in-progress") - - self.ethernet_label.set_label(EthernetDetails().name or "Not Connected") - - if EthernetService.is_ethernet_available(): - self._clear_ethernet_content() - self._show_ethernet_content() - self.ethernet_switch.set_sensitive(True) - else: - self._clear_ethernet_content() - self.ethernet_switch.set_active(False) - self.ethernet_switch.set_sensitive(False) - - self.spinner.stop() - self.ethernet_scan_label.set_sensitive(True) - self.ethernet_scan_label.remove_css_class("rescan-in-progress") + self.ethernet_details_widget.update() def _create_wifi_toggle(self) -> Gtk.Box: """Create the WiFi toggle section""" From 87984c3fe30a8751ba69ce7f6b2b1257dda0f96e Mon Sep 17 00:00:00 2001 From: Keo Ponleou Sok Date: Fri, 17 Oct 2025 15:58:59 +1100 Subject: [PATCH 13/13] docs: added comments and docs to methods --- app/ethernet_service.py | 37 ++++++++++++++++--------------------- app/ui/ethernet_details.py | 2 ++ 2 files changed, 18 insertions(+), 21 deletions(-) diff --git a/app/ethernet_service.py b/app/ethernet_service.py index c33860d..cb8bb1c 100644 --- a/app/ethernet_service.py +++ b/app/ethernet_service.py @@ -18,7 +18,7 @@ def __init__(self): self._parse_data(details) def _parse_data(self, eth_dev: DeviceDetails) -> None: - # parses the information and add to properties + """parses the information from eth_dev and add to properties""" self.device = eth_dev.get("GENERAL.DEVICE") self.hwaddr = eth_dev.get("GENERAL.HWADDR") self.name = eth_dev.get("GENERAL.CONNECTION") @@ -42,26 +42,11 @@ def _parse_data(self, eth_dev: DeviceDetails) -> None: else: self.state = "unavailable" - # not really a scan, but keeping consistent naming with network_service - def rescan(self) -> None: - details = EthernetService.get_device_details() - if details != None: - self._parse_data(details) - - # FIXME: delete later - def test_print(self): - print(self.device) - print(self.name) - print(self.hwaddr) - print(self.ipaddr) - print(self.gateway_addr) - print(self.state) - class EthernetService: - @staticmethod def get_device_details() -> DeviceDetails | None: + """Get the DeviceDetails object of the ethernet device""" # find device with ethernet type for dev in nmcli.device.show_all(): if dev.get("GENERAL.TYPE") == "ethernet": @@ -71,6 +56,7 @@ def get_device_details() -> DeviceDetails | None: @staticmethod def get_device() -> str | None: + """Get the ethernet device's identifier""" details = EthernetService.get_device_details() if details == None: @@ -80,10 +66,15 @@ def get_device() -> str | None: @overload @staticmethod - def get_ethernet_status() -> bool: ... + def get_ethernet_status() -> bool: + """Get the status of the ethernet, True for connected, if not, False. Will always return False if ethernet is not available.""" + pass + @overload @staticmethod - def get_ethernet_status(details: EthernetDetails) -> bool: ... + def get_ethernet_status(details: EthernetDetails) -> bool: + """Get the status of the ethernet from the details, True for connected, if not, False. Will always return False if ethernet is not available.""" + pass def get_ethernet_status(details: EthernetDetails | None = None) -> bool: if details == None: @@ -109,11 +100,15 @@ def get_ethernet_status(details: EthernetDetails | None = None) -> bool: @overload @staticmethod - def is_ethernet_available() -> bool: ... + def is_ethernet_available() -> bool: + """Check if ethernet is available and able to be connected. This is different from status.""" + pass @overload @staticmethod - def is_ethernet_available(details: EthernetDetails) -> bool: ... + def is_ethernet_available(details: EthernetDetails) -> bool: + """Check if ethernet is available and able to be connected from the details. This is different from status.""" + pass def is_ethernet_available(details: EthernetDetails | None = None) -> bool: if details == None: diff --git a/app/ui/ethernet_details.py b/app/ui/ethernet_details.py index af1a857..2a657a8 100644 --- a/app/ui/ethernet_details.py +++ b/app/ui/ethernet_details.py @@ -19,6 +19,8 @@ def __init__(self, ethernet_switch: Gtk.Switch): super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=10) self.content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + + # an ethernet_switch parameter is passed to the widget so that we can set the switch's state on update self.switch = ethernet_switch self._create_header()