import json import os import subprocess import threading import time import gi from fabric.widgets.box import Box from fabric.widgets.button import Button from fabric.widgets.entry import Entry from fabric.widgets.image import Image as FabricImage from fabric.widgets.label import Label from fabric.widgets.scrolledwindow import ScrolledWindow from fabric.widgets.stack import Stack from fabric.widgets.window import Window from gi.repository import GdkPixbuf, GLib, Gtk from PIL import Image from config.data import APP_NAME, APP_NAME_CAP, NOTIF_POS_DEFAULT, NOTIF_POS_KEY from config.settings.utils import backup_and_replace, bind_vars, start_config gi.require_version("Gtk", "3.0") class HyprConfGUI(Window): def __init__(self, show_lock_checkbox: bool, show_idle_checkbox: bool, **kwargs): super().__init__( title="Modus Settings", name="modus-settings-window", size=(640, 640), **kwargs, ) self.set_resizable(False) self.themes = ["Pills", "Dense", "Edge"] self.selected_face_icon = None self.show_lock_checkbox = show_lock_checkbox self.show_idle_checkbox = show_idle_checkbox root_box = Box(orientation="v", spacing=10, style="margin: 10px;") self.add(root_box) main_content_box = Box(orientation="h", spacing=6, v_expand=False, h_expand=False) root_box.add(main_content_box) self.tab_stack = Stack( transition_type="slide-up-down", transition_duration=250, v_expand=False, h_expand=False, ) self.key_bindings_tab_content = self.create_key_bindings_tab() self.appearance_tab_content = self.create_appearance_tab() self.system_tab_content = self.create_system_tab() self.about_tab_content = self.create_about_tab() self.tab_stack.add_titled( self.key_bindings_tab_content, "key_bindings", "Key Bindings" ) self.tab_stack.add_titled( self.appearance_tab_content, "appearance", "Appearance" ) self.tab_stack.add_titled(self.system_tab_content, "system", "System") self.tab_stack.add_titled(self.about_tab_content, "about", "About") tab_switcher = Gtk.StackSwitcher() tab_switcher.set_stack(self.tab_stack) tab_switcher.set_orientation(Gtk.Orientation.VERTICAL) main_content_box.add(tab_switcher) main_content_box.add(self.tab_stack) button_box = Box(orientation="h", spacing=10, h_align="end") reset_btn = Button(label="Reset to Defaults", on_clicked=self.on_reset) button_box.add(reset_btn) close_btn = Button(label="Close", on_clicked=self.on_close) button_box.add(close_btn) accept_btn = Button(label="Apply & Reload", on_clicked=self.on_accept) button_box.add(accept_btn) root_box.add(button_box) # Force window size after all content is added self.connect("realize", self._force_window_size) def _force_window_size(self, widget): """Force the window to respect the declared size""" self.resize(640, 640) self.set_size_request(640, 640) def create_key_bindings_tab(self): scrolled_window = ScrolledWindow( h_scrollbar_policy="never", v_scrollbar_policy="automatic", h_expand=False, v_expand=False, propagate_width=True, propagate_height=True, ) scrolled_window.set_size_request(620, 580) # Set explicit size for content area main_vbox = Box(orientation="v", spacing=10, style="margin: 15px;") scrolled_window.add(main_vbox) keybind_grid = Gtk.Grid() keybind_grid.set_column_spacing(10) keybind_grid.set_row_spacing(8) keybind_grid.set_margin_start(5) keybind_grid.set_margin_end(5) keybind_grid.set_margin_top(5) keybind_grid.set_margin_bottom(5) action_label = Label( markup="Action", h_align="start", style="margin-bottom: 5px;" ) modifier_label = Label( markup="Modifier", h_align="start", style="margin-bottom: 5px;" ) separator_label = Label( label="+", h_align="center", style="margin-bottom: 5px;" ) key_label = Label( markup="Key", h_align="start", style="margin-bottom: 5px;" ) keybind_grid.attach(action_label, 0, 0, 1, 1) keybind_grid.attach(modifier_label, 1, 0, 1, 1) keybind_grid.attach(separator_label, 2, 0, 1, 1) keybind_grid.attach(key_label, 3, 0, 1, 1) self.entries = [] bindings = [ (f"Reload {APP_NAME_CAP}", "prefix_restart", "suffix_restart"), ("Message", "prefix_msg", "suffix_msg"), ( "Application Switcher", "prefix_application_switcher", "suffix_application_switcher", ), ("Launcher", "prefix_launcher", "suffix_launcher"), ("App Launcher", "prefix_app_launcher", "suffix_app_launcher"), ("Clipboard History", "prefix_cliphist", "suffix_cliphist"), ("Wallpapers", "prefix_wallpapers", "suffix_wallpapers"), ("Random Wallpaper", "prefix_randwall", "suffix_randwall"), ("Emoji Picker", "prefix_emoji", "suffix_emoji"), ("Kanban", "prefix_kanban", "suffix_kanban"), ("Power Menu", "prefix_power", "suffix_power"), ("Toggle Caffeine", "prefix_caffeine", "suffix_caffeine"), ("Settings", "prefix_settings", "suffix_settings"), ( "Restart with inspector", "prefix_restart_inspector", "suffix_restart_inspector", ), ] for i, (label_text, prefix_key, suffix_key) in enumerate(bindings): row = i + 1 binding_label = Label(label=label_text, h_align="start") keybind_grid.attach(binding_label, 0, row, 1, 1) prefix_entry = Entry(text=bind_vars.get(prefix_key, "")) keybind_grid.attach(prefix_entry, 1, row, 1, 1) plus_label = Label(label="+", h_align="center") keybind_grid.attach(plus_label, 2, row, 1, 1) suffix_entry = Entry(text=bind_vars.get(suffix_key, "")) keybind_grid.attach(suffix_entry, 3, row, 1, 1) self.entries.append((prefix_key, suffix_key, prefix_entry, suffix_entry)) main_vbox.add(keybind_grid) return scrolled_window def create_appearance_tab(self): scrolled_window = ScrolledWindow( h_scrollbar_policy="never", v_scrollbar_policy="automatic", h_expand=True, v_expand=True, propagate_width=False, propagate_height=False, ) vbox = Box(orientation="v", spacing=15, style="margin: 15px;") scrolled_window.add(vbox) top_grid = Gtk.Grid() top_grid.set_column_spacing(20) top_grid.set_row_spacing(5) top_grid.set_margin_bottom(10) vbox.add(top_grid) wall_header = Label(markup="Wallpapers", h_align="start") top_grid.attach(wall_header, 0, 0, 1, 1) wall_label = Label(label="Directory:", h_align="start", v_align="center") top_grid.attach(wall_label, 0, 1, 1, 1) chooser_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) chooser_container.set_halign(Gtk.Align.START) chooser_container.set_valign(Gtk.Align.CENTER) self.wall_dir_chooser = Gtk.FileChooserButton( title="Select a folder", action=Gtk.FileChooserAction.SELECT_FOLDER ) self.wall_dir_chooser.set_tooltip_text( "Select the directory containing your wallpaper images" ) self.wall_dir_chooser.set_filename(bind_vars.get("wallpapers_dir", "")) self.wall_dir_chooser.set_size_request(180, -1) chooser_container.add(self.wall_dir_chooser) top_grid.attach(chooser_container, 1, 1, 1, 1) face_header = Label(markup="Profile Icon", h_align="start") top_grid.attach(face_header, 2, 0, 2, 1) current_face = os.path.expanduser("~/.face.icon") face_image_container = Box( style_classes=["image-frame"], h_align="center", v_align="center" ) self.face_image = FabricImage(size=64) try: if os.path.exists(current_face): pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(current_face, 64, 64) self.face_image.set_from_pixbuf(pixbuf) else: self.face_image.set_from_icon_name("user-info", Gtk.IconSize.DIALOG) except Exception as e: print(f"Error loading face icon: {e}") self.face_image.set_from_icon_name("image-missing", Gtk.IconSize.DIALOG) face_image_container.add(self.face_image) top_grid.attach(face_image_container, 2, 1, 1, 1) browse_btn_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) browse_btn_container.set_halign(Gtk.Align.START) browse_btn_container.set_valign(Gtk.Align.CENTER) face_btn = Button( label="Browse...", tooltip_text="Select a square image for your profile icon", on_clicked=self.on_select_face_icon, ) browse_btn_container.add(face_btn) top_grid.attach(browse_btn_container, 3, 1, 1, 1) self.face_status_label = Label(label="", h_align="start") top_grid.attach(self.face_status_label, 2, 2, 2, 1) separator1 = Box( style="min-height: 1px; background-color: alpha(@fg_color, 0.2); margin: 5px 0px;", h_expand=True, ) vbox.add(separator1) layout_header = Label(markup="Layout Options", h_align="start") vbox.add(layout_header) layout_grid = Gtk.Grid() layout_grid.set_column_spacing(20) layout_grid.set_row_spacing(10) layout_grid.set_margin_start(10) layout_grid.set_margin_top(5) vbox.add(layout_grid) # Workspace mode ws_mode_label = Label(label="Workspace Mode", h_align="start", v_align="center") layout_grid.attach(ws_mode_label, 0, 0, 1, 1) ws_mode_combo_container = Gtk.Box( orientation=Gtk.Orientation.HORIZONTAL, halign=Gtk.Align.START, valign=Gtk.Align.CENTER, ) self.ws_mode_combo = Gtk.ComboBoxText() self.ws_mode_combo.set_tooltip_text("Select how workspaces are displayed") ws_modes = ["Dots", "Numbers", "Single Button"] for mode in ws_modes: self.ws_mode_combo.append_text(mode) # Set current mode based on config current_dots = bind_vars.get("workspace_dots", True) current_nums = bind_vars.get("workspace_nums", False) if current_dots: current_mode = "Dots" elif current_nums: current_mode = "Numbers" else: current_mode = "Single Button" try: self.ws_mode_combo.set_active(ws_modes.index(current_mode)) except ValueError: self.ws_mode_combo.set_active(0) self.ws_mode_combo.connect("changed", self.on_ws_mode_changed) ws_mode_combo_container.add(self.ws_mode_combo) layout_grid.attach(ws_mode_combo_container, 1, 0, 1, 1) # Chinese numerals option ws_chinese_label = Label( label="Use Chinese Numerals", h_align="start", v_align="center" ) layout_grid.attach(ws_chinese_label, 2, 0, 1, 1) ws_chinese_switch_container = Gtk.Box( orientation=Gtk.Orientation.HORIZONTAL, halign=Gtk.Align.START, valign=Gtk.Align.CENTER, ) self.ws_chinese_switch = Gtk.Switch( active=bind_vars.get("workspace_use_chinese_numerals", False), sensitive=current_nums, # Only enabled when numbers mode is selected ) self.ws_chinese_switch.set_tooltip_text( "Use Chinese numerals (一, 二, 三...) instead of Arabic numbers" ) ws_chinese_switch_container.add(self.ws_chinese_switch) layout_grid.attach(ws_chinese_switch_container, 3, 0, 1, 1) position_label = Label(label="Dock Position", h_align="start", v_align="center") layout_grid.attach(position_label, 0, 1, 1, 1) position_combo_container = Gtk.Box( orientation=Gtk.Orientation.HORIZONTAL, halign=Gtk.Align.START, valign=Gtk.Align.CENTER, ) self.position_combo = Gtk.ComboBoxText() self.position_combo.set_tooltip_text("Select the position of the dock") positions = ["Bottom", "Top", "Left", "Right"] for pos in positions: self.position_combo.append_text(pos) current_position = bind_vars.get("dock_position", "Bottom") try: self.position_combo.set_active(positions.index(current_position)) except ValueError: self.position_combo.set_active(0) self.position_combo.connect("changed", self.on_position_changed) position_combo_container.add(self.position_combo) layout_grid.attach(position_combo_container, 1, 1, 1, 1) dock_theme_label = Label(label="Dock Theme", h_align="start", v_align="center") layout_grid.attach(dock_theme_label, 2, 1, 1, 1) dock_theme_combo_container = Gtk.Box( orientation=Gtk.Orientation.HORIZONTAL, halign=Gtk.Align.START, valign=Gtk.Align.CENTER, ) self.dock_theme_combo = Gtk.ComboBoxText() self.dock_theme_combo.set_tooltip_text("Select the visual theme for the dock") for theme in self.themes: self.dock_theme_combo.append_text(theme) current_dock_theme = bind_vars.get("dock_theme", "Pills") try: self.dock_theme_combo.set_active(self.themes.index(current_dock_theme)) except ValueError: self.dock_theme_combo.set_active(0) dock_theme_combo_container.add(self.dock_theme_combo) layout_grid.attach(dock_theme_combo_container, 3, 1, 1, 1) # Add icon size scale icon_size_label = Label( label="Taskbar Icon Size", h_align="start", v_align="center" ) layout_grid.attach(icon_size_label, 0, 4, 1, 1) icon_size_scale_container = Gtk.Box( orientation=Gtk.Orientation.HORIZONTAL, halign=Gtk.Align.START, valign=Gtk.Align.CENTER, hexpand=True, ) self.icon_size_scale = Gtk.Scale.new_with_range( Gtk.Orientation.HORIZONTAL, 20, 30, 2 ) self.icon_size_scale.set_value(bind_vars.get("dock_icon_size", 20)) self.icon_size_scale.set_size_request(150, -1) self.icon_size_scale.set_draw_value(True) self.icon_size_scale.set_value_pos(Gtk.PositionType.RIGHT) self.icon_size_scale.set_tooltip_text("Adjust the size of dock icons") icon_size_scale_container.add(self.icon_size_scale) layout_grid.attach(icon_size_scale_container, 1, 4, 1, 1) # Add application switcher items scale application_switcher_label = Label( label="Application Switcher Items", h_align="start", v_align="center" ) layout_grid.attach(application_switcher_label, 2, 4, 1, 1) application_switcher_scale_container = Gtk.Box( orientation=Gtk.Orientation.HORIZONTAL, halign=Gtk.Align.START, valign=Gtk.Align.CENTER, hexpand=True, ) self.application_switcher_scale = Gtk.Scale.new_with_range( Gtk.Orientation.HORIZONTAL, 5, 20, 1 ) self.application_switcher_scale.set_value( bind_vars.get("window_switcher_items_per_row", 13) ) self.application_switcher_scale.set_size_request(150, -1) self.application_switcher_scale.set_draw_value(True) self.application_switcher_scale.set_value_pos(Gtk.PositionType.RIGHT) self.application_switcher_scale.set_tooltip_text( "Adjust the number of items per row in the application switcher" ) application_switcher_scale_container.add(self.application_switcher_scale) layout_grid.attach(application_switcher_scale_container, 3, 4, 1, 1) dock_label = Label(label="Show Dock", h_align="start", v_align="center") layout_grid.attach(dock_label, 0, 2, 1, 1) dock_switch_container = Gtk.Box( orientation=Gtk.Orientation.HORIZONTAL, halign=Gtk.Align.START, valign=Gtk.Align.CENTER, ) self.dock_switch = Gtk.Switch(active=bind_vars.get("dock_enabled", True)) self.dock_switch.connect("notify::active", self.on_dock_enabled_changed) dock_switch_container.add(self.dock_switch) layout_grid.attach(dock_switch_container, 1, 2, 1, 1) dock_auto_hide_label = Label( label="Auto-hide Dock", h_align="start", v_align="center" ) layout_grid.attach(dock_auto_hide_label, 0, 3, 1, 1) dock_auto_hide_switch_container = Gtk.Box( orientation=Gtk.Orientation.HORIZONTAL, halign=Gtk.Align.START, valign=Gtk.Align.CENTER, ) self.dock_auto_hide_switch = Gtk.Switch( active=bind_vars.get("dock_auto_hide", False), sensitive=self.dock_switch.get_active(), ) dock_auto_hide_switch_container.add(self.dock_auto_hide_switch) layout_grid.attach(dock_auto_hide_switch_container, 1, 3, 1, 1) dock_hover_label = Label( label="Show Dock Only on Hover", h_align="start", v_align="center" ) layout_grid.attach(dock_hover_label, 2, 2, 1, 1) dock_hover_switch_container = Gtk.Box( orientation=Gtk.Orientation.HORIZONTAL, halign=Gtk.Align.START, valign=Gtk.Align.CENTER, ) self.dock_hover_switch = Gtk.Switch( active=bind_vars.get("dock_always_occluded", False), sensitive=self.dock_switch.get_active(), ) dock_hover_switch_container.add(self.dock_hover_switch) layout_grid.attach(dock_hover_switch_container, 3, 2, 1, 1) # Add dock hide special workspace option dock_hide_special_label = Label( label="Hide Special Workspace", h_align="start", v_align="center" ) layout_grid.attach(dock_hide_special_label, 2, 3, 1, 1) dock_hide_special_switch_container = Gtk.Box( orientation=Gtk.Orientation.HORIZONTAL, halign=Gtk.Align.START, valign=Gtk.Align.CENTER, ) self.dock_hide_special_switch = Gtk.Switch( active=bind_vars.get("dock_hide_special_workspace", True), sensitive=self.dock_switch.get_active(), ) self.dock_hide_special_switch.set_tooltip_text( "Hide the dock when on special workspaces (scratchpad)" ) dock_hide_special_switch_container.add(self.dock_hide_special_switch) layout_grid.attach(dock_hide_special_switch_container, 3, 3, 1, 1) notification_pos_label = Label( label="Notification Position", h_align="start", v_align="center" ) layout_grid.attach(notification_pos_label, 0, 8, 1, 1) notification_pos_combo_container = Box( orientation=Gtk.Orientation.HORIZONTAL, halign=Gtk.Align.START, valign=Gtk.Align.CENTER, ) self.notification_pos_combo = Gtk.ComboBoxText() self.notification_pos_combo.set_tooltip_text( "Select where notifications appear on the screen." ) notification_positions_list = ["Top", "Bottom"] for pos in notification_positions_list: self.notification_pos_combo.append_text(pos) current_notif_pos = bind_vars.get(NOTIF_POS_KEY, NOTIF_POS_DEFAULT) try: self.notification_pos_combo.set_active( notification_positions_list.index(current_notif_pos) ) except ValueError: self.notification_pos_combo.set_active(0) self.notification_pos_combo.connect( "changed", self.on_notification_position_changed ) notification_pos_combo_container.add(self.notification_pos_combo) layout_grid.attach(notification_pos_combo_container, 1, 8, 3, 1) separator2 = Box( style="min-height: 1px; background-color: alpha(@fg_color, 0.2); margin: 5px 0px;", h_expand=True, ) vbox.add(separator2) components_header = Label(markup="Modules", h_align="start") vbox.add(components_header) components_grid = Gtk.Grid() components_grid.set_column_spacing(15) components_grid.set_row_spacing(8) components_grid.set_margin_start(10) components_grid.set_margin_top(5) vbox.add(components_grid) self.component_switches = {} component_display_names = { "workspace": "Workspaces", "metrics": "System Metrics", "date_time": "Date & Time", "battery": "Battery Indicator", "controls": "Control Panel", "indicators": "Indicators", "music_player": "Music Player", "applications": "Taskbar", "language": "Language Indicator", } self.corners_switch = Gtk.Switch(active=bind_vars.get("corners_visible", True)) num_components = len(component_display_names) + 1 rows_per_column = (num_components + 1) // 2 corners_label = Label( label="Rounded Corners", h_align="start", v_align="center" ) components_grid.attach(corners_label, 0, 0, 1, 1) switch_container_corners = Gtk.Box( orientation=Gtk.Orientation.HORIZONTAL, halign=Gtk.Align.START, valign=Gtk.Align.CENTER, ) switch_container_corners.add(self.corners_switch) components_grid.attach(switch_container_corners, 1, 0, 1, 1) current_row = 0 current_col = 0 item_idx = 0 for i, (name, display) in enumerate(component_display_names.items()): if item_idx < (rows_per_column - 1): row = item_idx + 1 col = 0 else: row = item_idx - (rows_per_column - 1) col = 2 component_label = Label(label=display, h_align="start", v_align="center") components_grid.attach(component_label, col, row, 1, 1) switch_container = Gtk.Box( orientation=Gtk.Orientation.HORIZONTAL, halign=Gtk.Align.START, valign=Gtk.Align.CENTER, ) component_switch = Gtk.Switch( active=bind_vars.get(f"dock_{name}_visible", True) ) switch_container.add(component_switch) components_grid.attach(switch_container, col + 1, row, 1, 1) self.component_switches[name] = component_switch item_idx += 1 return scrolled_window def on_notification_position_changed(self, combo: Gtk.ComboBoxText): selected_text = combo.get_active_text() if selected_text: bind_vars[NOTIF_POS_KEY] = selected_text print( f"Notification position updated in bind_vars: { bind_vars[NOTIF_POS_KEY] }" ) def create_system_tab(self): scrolled_window = ScrolledWindow( h_scrollbar_policy="never", v_scrollbar_policy="automatic", h_expand=True, v_expand=True, propagate_width=False, propagate_height=False, ) vbox = Box(orientation="v", spacing=15, style="margin: 15px;") scrolled_window.add(vbox) system_grid = Gtk.Grid() system_grid.set_column_spacing(20) system_grid.set_row_spacing(10) system_grid.set_margin_bottom(15) vbox.add(system_grid) terminal_header = Label(markup="Terminal Settings", h_align="start") system_grid.attach(terminal_header, 0, 0, 2, 1) terminal_label = Label(label="Command:", h_align="start", v_align="center") system_grid.attach(terminal_label, 0, 1, 1, 1) self.terminal_entry = Entry( text=bind_vars.get("terminal_command", "kitty -e"), tooltip_text="Command used to launch terminal apps (e.g., 'kitty -e')", h_expand=True, ) system_grid.attach(self.terminal_entry, 1, 1, 1, 1) hint_label = Label( markup="Examples: 'kitty -e', 'alacritty -e', 'foot -e'", h_align="start", ) system_grid.attach(hint_label, 0, 2, 2, 1) hypr_header = Label(markup="Hyprland Integration", h_align="start") system_grid.attach(hypr_header, 2, 0, 2, 1) row = 1 self.lock_switch = None if self.show_lock_checkbox: lock_label = Label( label="Replace Hyprlock config", h_align="start", v_align="center" ) system_grid.attach(lock_label, 2, row, 1, 1) lock_switch_container = Gtk.Box( orientation=Gtk.Orientation.HORIZONTAL, halign=Gtk.Align.START, valign=Gtk.Align.CENTER, ) self.lock_switch = Gtk.Switch( tooltip_text="Replace Hyprlock configuration with Modus's custom config" ) lock_switch_container.add(self.lock_switch) system_grid.attach(lock_switch_container, 3, row, 1, 1) row += 1 self.idle_switch = None if self.show_idle_checkbox: idle_label = Label( label="Replace Hypridle config", h_align="start", v_align="center" ) system_grid.attach(idle_label, 2, row, 1, 1) idle_switch_container = Gtk.Box( orientation=Gtk.Orientation.HORIZONTAL, halign=Gtk.Align.START, valign=Gtk.Align.CENTER, ) self.idle_switch = Gtk.Switch( tooltip_text="Replace Hypridle configuration with Modus's custom config" ) idle_switch_container.add(self.idle_switch) system_grid.attach(idle_switch_container, 3, row, 1, 1) row += 1 if self.show_lock_checkbox or self.show_idle_checkbox: note_label = Label( markup="Existing configs will be backed up", h_align="start", ) system_grid.attach(note_label, 2, row, 2, 1) metrics_header = Label(markup="System Metrics Options", h_align="start") vbox.add(metrics_header) metrics_grid = Gtk.Grid( column_spacing=15, row_spacing=8, margin_start=10, margin_top=5 ) vbox.add(metrics_grid) self.metrics_switches = {} metric_names = { "cpu": "CPU", "ram": "RAM", "swap": "Swap", "disk": "Disk", "gpu": "GPU", } metrics_grid.attach(Label(label="Show in Metrics", h_align="start"), 0, 0, 1, 1) for i, (key, label_text) in enumerate(metric_names.items()): switch = Gtk.Switch( active=bind_vars.get("metrics_visible", {}).get(key, True) ) self.metrics_switches[key] = switch metrics_grid.attach( Label(label=label_text, h_align="start"), 0, i + 1, 1, 1 ) metrics_grid.attach(switch, 1, i + 1, 1, 1) def enforce_minimum_metrics(switch_dict): enabled_switches = [s for s in switch_dict.values() if s.get_active()] can_disable = len(enabled_switches) > 1 for s in switch_dict.values(): s.set_sensitive(True if can_disable or not s.get_active() else False) def on_metric_toggle(switch, gparam, switch_dict): enforce_minimum_metrics(switch_dict) for k_s, s_s in self.metrics_switches.items(): s_s.connect("notify::active", on_metric_toggle, self.metrics_switches) enforce_minimum_metrics(self.metrics_switches) disks_label = Label( label="Disk directories for Metrics", h_align="start", v_align="center" ) vbox.add(disks_label) self.disk_entries = Box(orientation="v", spacing=8, h_align="start") self._create_disk_edit_entry_func = lambda path: self._add_disk_entry_widget( path ) for p in bind_vars.get("metrics_disks", ["/"]): self._create_disk_edit_entry_func(p) vbox.add(self.disk_entries) add_container = Gtk.Box( orientation=Gtk.Orientation.HORIZONTAL, halign=Gtk.Align.START, valign=Gtk.Align.CENTER, ) add_btn = Button( label="Add new disk", on_clicked=lambda _: self._create_disk_edit_entry_func("/"), ) add_container.add(add_btn) vbox.add(add_container) return scrolled_window def _add_disk_entry_widget(self, path): """Helper para añadir una fila de entrada de disco al Box disk_entries.""" bar = Box(orientation="h", spacing=10, h_align="start") entry = Entry(text=path, h_expand=True) bar.add(entry) x_btn = Button(label="X") x_btn.connect( "clicked", lambda _, current_bar_to_remove=bar: self.disk_entries.remove( current_bar_to_remove ), ) bar.add(x_btn) self.disk_entries.add(bar) self.disk_entries.show_all() def create_about_tab(self): vbox = Box(orientation="v", spacing=18, style="margin: 30px;") vbox.add( Label( markup=f"{APP_NAME_CAP}", h_align="start", style="font-size: 1.5em; margin-bottom: 8px;", ) ) vbox.add( Label( label="A hackable shell for Hyprland, powered by Fabric.", h_align="start", style="margin-bottom: 12px;", ) ) repo_box = Box(orientation="h", spacing=6, h_align="start") repo_label = Label(label="GitHub:", h_align="start") repo_link = Label( markup='https://github.com/S4NKALP/Modus' ) repo_box.add(repo_label) repo_box.add(repo_link) vbox.add(repo_box) # def on_kofi_clicked(_): # import webbrowser # webbrowser.open("https://github.com/S4NKALP") # kofi_btn = Button( # label="Support on Ko-Fi ❤️", # on_clicked=on_kofi_clicked, # tooltip_text="Support S4NKALP on Ko-Fi", # style="margin-top: 18px; min-width: 160px;", # ) # vbox.add(kofi_btn) vbox.add(Box(v_expand=True)) return vbox def on_ws_mode_changed(self, combo): mode = combo.get_active_text() is_numbers_mode = mode == "Numbers" self.ws_chinese_switch.set_sensitive(is_numbers_mode) if not is_numbers_mode: self.ws_chinese_switch.set_active(False) def on_ws_num_changed(self, switch, gparam): is_active = switch.get_active() self.ws_chinese_switch.set_sensitive(is_active) if not is_active: self.ws_chinese_switch.set_active(False) def on_position_changed(self, combo): position = combo.get_active_text() is_vertical = position in ["Left", "Right"] self.centered_switch.set_sensitive(is_vertical) if not is_vertical: self.centered_switch.set_active(False) def on_dock_enabled_changed(self, switch, gparam): is_active = switch.get_active() self.dock_hover_switch.set_sensitive(is_active) self.dock_auto_hide_switch.set_sensitive(is_active) self.dock_hide_special_switch.set_sensitive(is_active) if not is_active: self.dock_hover_switch.set_active(False) self.dock_auto_hide_switch.set_active(False) self.dock_hide_special_switch.set_active(False) def on_select_face_icon(self, widget): dialog = Gtk.FileChooserDialog( title="Select Face Icon", transient_for=self.get_toplevel(), action=Gtk.FileChooserAction.OPEN, ) dialog.add_buttons( Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_OPEN, Gtk.ResponseType.OK, ) image_filter = Gtk.FileFilter() image_filter.set_name("Image files") for mime in ["image/png", "image/jpeg"]: image_filter.add_mime_type(mime) for pattern in ["*.png", "*.jpg", "*.jpeg"]: image_filter.add_pattern(pattern) dialog.add_filter(image_filter) if dialog.run() == Gtk.ResponseType.OK: self.selected_face_icon = dialog.get_filename() self.face_status_label.label = ( f"Selected: {os.path.basename(self.selected_face_icon)}" ) try: pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size( self.selected_face_icon, 64, 64 ) self.face_image.set_from_pixbuf(pixbuf) except Exception as e: print(f"Error loading selected face icon preview: {e}") self.face_image.set_from_icon_name("image-missing", Gtk.IconSize.DIALOG) dialog.destroy() def on_accept(self, widget): current_bind_vars_snapshot = {} for prefix_key, suffix_key, prefix_entry, suffix_entry in self.entries: current_bind_vars_snapshot[prefix_key] = prefix_entry.get_text() current_bind_vars_snapshot[suffix_key] = suffix_entry.get_text() current_bind_vars_snapshot["wallpapers_dir"] = ( self.wall_dir_chooser.get_filename() ) current_bind_vars_snapshot["dock_position"] = ( self.position_combo.get_active_text() ) current_bind_vars_snapshot["vertical"] = current_bind_vars_snapshot[ "dock_position" ] in ["Left", "Right"] current_bind_vars_snapshot["dock_enabled"] = self.dock_switch.get_active() current_bind_vars_snapshot["dock_auto_hide"] = ( self.dock_auto_hide_switch.get_active() ) current_bind_vars_snapshot["dock_always_occluded"] = ( self.dock_hover_switch.get_active() ) current_bind_vars_snapshot["dock_hide_special_workspace"] = ( self.dock_hide_special_switch.get_active() ) current_bind_vars_snapshot["dock_icon_size"] = int( self.icon_size_scale.get_value() ) current_bind_vars_snapshot["window_switcher_items_per_row"] = int( self.application_switcher_scale.get_value() ) current_bind_vars_snapshot["terminal_command"] = self.terminal_entry.get_text() current_bind_vars_snapshot["corners_visible"] = self.corners_switch.get_active() current_bind_vars_snapshot["dock_theme"] = ( self.dock_theme_combo.get_active_text() ) selected_notif_pos_text = self.notification_pos_combo.get_active_text() if selected_notif_pos_text: current_bind_vars_snapshot[NOTIF_POS_KEY] = selected_notif_pos_text else: current_bind_vars_snapshot[NOTIF_POS_KEY] = NOTIF_POS_DEFAULT for component_name, switch in self.component_switches.items(): current_bind_vars_snapshot[f"dock_{component_name}_visible"] = ( switch.get_active() ) # Save workspace settings ws_mode = self.ws_mode_combo.get_active_text() current_bind_vars_snapshot["workspace_dots"] = ws_mode == "Dots" current_bind_vars_snapshot["workspace_nums"] = ws_mode == "Numbers" current_bind_vars_snapshot["workspace_use_chinese_numerals"] = ( self.ws_chinese_switch.get_active() ) current_bind_vars_snapshot["metrics_visible"] = { k: s.get_active() for k, s in self.metrics_switches.items() } current_bind_vars_snapshot["metrics_disks"] = [ child.get_children()[0].get_text() for child in self.disk_entries.get_children() if isinstance(child, Gtk.Box) and child.get_children() and isinstance(child.get_children()[0], Entry) ] selected_icon_path = self.selected_face_icon replace_lock = self.lock_switch and self.lock_switch.get_active() replace_idle = self.idle_switch and self.idle_switch.get_active() if self.selected_face_icon: self.selected_face_icon = None self.face_status_label.label = "" def _apply_and_reload_task_thread(): nonlocal current_bind_vars_snapshot from . import utils utils.bind_vars.clear() utils.bind_vars.update(current_bind_vars_snapshot) start_time = time.time() print(f"{start_time:.4f}: Background task started.") config_json = os.path.expanduser( f"~/.config/{APP_NAME_CAP}/config/assets/config.json" ) os.makedirs(os.path.dirname(config_json), exist_ok=True) try: with open(config_json, "w") as f: json.dump(utils.bind_vars, f, indent=4) print(f"{time.time():.4f}: Saved config.json.") except Exception as e: print(f"Error saving config.json: {e}") if selected_icon_path: print(f"{time.time():.4f}: Processing face icon...") try: img = Image.open(selected_icon_path) side = min(img.size) left = (img.width - side) // 2 top = (img.height - side) // 2 cropped_img = img.crop((left, top, left + side, top + side)) face_icon_dest = os.path.expanduser("~/.face.icon") cropped_img.save(face_icon_dest, format="PNG") print(f"{time.time():.4f}: Face icon saved to {face_icon_dest}") GLib.idle_add(self._update_face_image_widget, face_icon_dest) except Exception as e: print(f"Error processing face icon: {e}") print(f"{time.time():.4f}: Finished processing face icon.") if replace_lock: print(f"{time.time():.4f}: Replacing hyprlock config...") src = os.path.expanduser( f"~/.config/{APP_NAME_CAP}/config/hypr/hyprlock.conf" ) dest = os.path.expanduser("~/.config/hypr/hyprlock.conf") if os.path.exists(src): backup_and_replace(src, dest, "Hyprlock") else: print(f"Warning: Source hyprlock config not found at {src}") print(f"{time.time():.4f}: Finished replacing hyprlock config.") if replace_idle: print(f"{time.time():.4f}: Replacing hypridle config...") src = os.path.expanduser( f"~/.config/{APP_NAME_CAP}/config/hypr/hypridle.conf" ) dest = os.path.expanduser("~/.config/hypr/hypridle.conf") if os.path.exists(src): backup_and_replace(src, dest, "Hypridle") else: print(f"Warning: Source hypridle config not found at {src}") print(f"{time.time():.4f}: Finished replacing hypridle config.") print( f"{time.time():.4f}: Checking/Appending hyprland.conf source string..." ) hypr_path = os.path.expanduser("~/.config/hypr/hyprland.conf") try: from .constants import SOURCE_STRING needs_append = True if os.path.exists(hypr_path): with open(hypr_path, "r") as f: if SOURCE_STRING.strip() in f.read(): needs_append = False else: os.makedirs(os.path.dirname(hypr_path), exist_ok=True) if needs_append: with open(hypr_path, "a") as f: f.write("\n" + SOURCE_STRING) print(f"Appended source string to {hypr_path}") except Exception as e: print(f"Error updating {hypr_path}: {e}") print( f"{time.time():.4f}: Finished checking/appending hyprland.conf source string." ) print(f"{time.time():.4f}: Running start_config()...") start_config() print(f"{time.time():.4f}: Finished start_config().") print(f"{time.time():.4f}: Initiating Modus restart using Popen...") main_py = os.path.expanduser(f"~/.config/{APP_NAME_CAP}/main.py") kill_cmd = f"killall {APP_NAME}" start_cmd = ["uwsm", "app", "--", "python", main_py] try: kill_proc = subprocess.Popen( kill_cmd, shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) kill_proc.wait(timeout=2) print(f"{time.time():.4f}: killall process finished (o timed out).") except subprocess.TimeoutExpired: print("Warning: killall command timed out.") except Exception as e: print(f"Error running killall: {e}") try: subprocess.Popen( start_cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, ) print(f"{APP_NAME_CAP} restart initiated via Popen.") except FileNotFoundError as e: print(f"Error restarting {APP_NAME_CAP}: Command not found ({e})") except Exception as e: print(f"Error restarting {APP_NAME_CAP} via Popen: {e}") print(f"{time.time():.4f}: Modus restart commands issued via Popen.") end_time = time.time() total_time = end_time - start_time print( f"{end_time:.4f}: Background task finished (Total: {total_time:.4f}s)." ) thread = threading.Thread(target=_apply_and_reload_task_thread) thread.daemon = True thread.start() print("Configuration apply/reload task started in background.") def _update_face_image_widget(self, icon_path): try: if self.face_image and self.face_image.get_window(): pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(icon_path, 64, 64) self.face_image.set_from_pixbuf(pixbuf) except Exception as e: print(f"Error reloading face icon preview: {e}") if self.face_image and self.face_image.get_window(): self.face_image.set_from_icon_name("image-missing", Gtk.IconSize.DIALOG) return GLib.SOURCE_REMOVE def on_reset(self, widget): dialog = Gtk.MessageDialog( transient_for=self.get_toplevel(), flags=0, message_type=Gtk.MessageType.QUESTION, buttons=Gtk.ButtonsType.YES_NO, text="Reset all settings to defaults?", ) dialog.format_secondary_text( "This will reset all keybindings and appearance settings to their default values." ) response = dialog.run() dialog.hide() # Hide the dialog first dialog.destroy() # Then destroy it # Process any pending events while Gtk.events_pending(): Gtk.main_iteration() if response == Gtk.ResponseType.YES: from . import utils from .constants import DEFAULTS utils.bind_vars.clear() utils.bind_vars.update(DEFAULTS.copy()) for prefix_key, suffix_key, prefix_entry, suffix_entry in self.entries: prefix_entry.set_text(utils.bind_vars[prefix_key]) suffix_entry.set_text(utils.bind_vars[suffix_key]) self.wall_dir_chooser.set_filename(utils.bind_vars["wallpapers_dir"]) positions = ["Bottom", "Top", "Left", "Right"] default_position = DEFAULTS.get("dock_position", "Bottom") try: self.position_combo.set_active(positions.index(default_position)) except ValueError: self.position_combo.set_active(0) self.dock_switch.set_active(utils.bind_vars.get("dock_enabled", True)) self.dock_auto_hide_switch.set_active( utils.bind_vars.get("dock_auto_hide", True) ) self.dock_hover_switch.set_active( utils.bind_vars.get("dock_always_occluded", False) ) self.dock_hide_special_switch.set_active( utils.bind_vars.get("dock_hide_special_workspace", True) ) self.dock_hover_switch.set_sensitive(self.dock_switch.get_active()) self.dock_auto_hide_switch.set_sensitive(self.dock_switch.get_active()) self.dock_hide_special_switch.set_sensitive(self.dock_switch.get_active()) self.terminal_entry.set_text(utils.bind_vars["terminal_command"]) default_dock_theme_val = DEFAULTS.get("dock_theme", "Pills") try: self.dock_theme_combo.set_active( self.themes.index(default_dock_theme_val) ) except ValueError: self.dock_theme_combo.set_active(0) default_notif_pos_val = DEFAULTS.get(NOTIF_POS_KEY, NOTIF_POS_DEFAULT) notification_positions_list = ["Top", "Bottom"] try: self.notification_pos_combo.set_active( notification_positions_list.index(default_notif_pos_val) ) except ValueError: self.notification_pos_combo.set_active(0) for name, switch in self.component_switches.items(): switch.set_active(utils.bind_vars.get(f"dock_{name}_visible", True)) # Reset workspace settings default_dots = DEFAULTS.get("workspace_dots", True) default_nums = DEFAULTS.get("workspace_nums", False) if default_dots: default_mode = "Dots" elif default_nums: default_mode = "Numbers" else: default_mode = "Single Button" ws_modes = ["Dots", "Numbers", "Single Button"] try: self.ws_mode_combo.set_active(ws_modes.index(default_mode)) except ValueError: self.ws_mode_combo.set_active(0) self.ws_chinese_switch.set_active( utils.bind_vars.get("workspace_use_chinese_numerals", False) ) self.ws_chinese_switch.set_sensitive(default_nums) self.corners_switch.set_active(utils.bind_vars.get("corners_visible", True)) metrics_vis_defaults = DEFAULTS.get("metrics_visible", {}) for k, s_widget in self.metrics_switches.items(): s_widget.set_active(metrics_vis_defaults.get(k, True)) def enforce_minimum_metrics(switch_dict): enabled_switches = [s for s in switch_dict.values() if s.get_active()] can_disable = len(enabled_switches) > 1 for s in switch_dict.values(): s.set_sensitive( True if can_disable or not s.get_active() else False ) enforce_minimum_metrics(self.metrics_switches) for child in list(self.disk_entries.get_children()): self.disk_entries.remove(child) for p in DEFAULTS.get("metrics_disks", ["/"]): self._add_disk_edit_entry_func(p) self._update_panel_position_sensitivity() self.selected_face_icon = None self.face_status_label.label = "" current_face = os.path.expanduser("~/.face.icon") try: pixbuf = ( GdkPixbuf.Pixbuf.new_from_file_at_size(current_face, 64, 64) if os.path.exists(current_face) else None ) if pixbuf: self.face_image.set_from_pixbuf(pixbuf) else: self.face_image.set_from_icon_name("user-info", Gtk.IconSize.DIALOG) except Exception: self.face_image.set_from_icon_name("image-missing", Gtk.IconSize.DIALOG) if self.lock_switch: self.lock_switch.set_active(False) if self.idle_switch: self.idle_switch.set_active(False) # Add reset for icon size scale self.icon_size_scale.set_value(utils.bind_vars.get("dock_icon_size", 20)) self.application_switcher_scale.set_value( utils.bind_vars.get("window_switcher_items_per_row", 13) ) print("Settings reset to defaults.") def on_close(self, widget): if self.application: self.application.quit() else: self.destroy()