Files
Modus/src/window/settings/main.py
T

414 lines
13 KiB
Python

from fabric.utils import Gdk, GLib, Gtk
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.centerbox import CenterBox
from fabric.widgets.entry import Entry
from fabric.widgets.label import Label
from fabric.widgets.scrolledwindow import ScrolledWindow
from fabric.widgets.stack import Stack
from services.config import config, off_config_change, on_config_change
from shared.widgets.smooth_switch import SmoothSwitch
from utils.gtk_utils import setup_cursor_hover, svg_file
class SettingsRow(CenterBox):
def __init__(
self, label: str, child=None, description: str | None = None, **kwargs
):
label_widget = Label(label=label, name="settings-row-label", h_align="start")
start_children = [label_widget]
if description:
description_widget = Label(
label=description, name="settings-row-description", h_align="start"
)
start_children = [
Box(
orientation="v",
spacing=2,
children=[label_widget, description_widget],
)
]
super().__init__(
name="settings-row",
start_children=start_children,
end_children=[child] if child else [],
**kwargs,
)
self.label_widget = label_widget
if description:
self.description_widget = description_widget
class SettingsSwitch(SmoothSwitch):
def __init__(self, config_key: str, **kwargs):
self.config_key = config_key
initial_active = config().get(config_key, False)
super().__init__(
name="settings-switch",
active=initial_active,
on_user_toggle=self.toggle,
**kwargs,
)
setup_cursor_hover(self, "pointer")
# Sync with external config changes
on_config_change(self._on_config_change)
def _on_config_change(self, new_config, old_config):
if config().has_changed(self.config_key, old_config):
self.set_active(new_config.get(self.config_key, False))
def toggle(self, state: bool):
config().set(self.config_key, state)
config().save()
def destroy(self):
off_config_change(self._on_config_change)
super().destroy()
class SettingsEntry(Entry):
def __init__(self, config_key: str, **kwargs):
self.config_key = config_key
initial_value = str(config().get(config_key, ""))
super().__init__(
name="settings-entry",
text=initial_value,
**kwargs,
)
self.connect("activate", self.on_change)
# Sync with external config changes
on_config_change(self._on_config_change)
def _on_config_change(self, new_config, old_config):
if config().has_changed(self.config_key, old_config):
new_val = str(new_config.get(self.config_key, ""))
if self.get_text() != new_val:
self.set_text(new_val)
def on_change(self, entry, *args):
value = entry.get_text()
try:
if value.isdigit():
value = int(value)
else:
value = float(value)
except (ValueError, TypeError):
pass
config().set(self.config_key, value)
config().save()
def destroy(self):
off_config_change(self._on_config_change)
super().destroy()
class SettingsComboBox(Gtk.ComboBoxText):
def __init__(self, config_key: str, options: list, **kwargs):
super().__init__(**kwargs)
self.set_name("settings-combo")
self.set_halign(Gtk.Align.END)
self.config_key = config_key
self.options = options
for opt in options:
self.append_text(opt)
self._update_from_config()
self.connect("changed", self.on_change)
# Sync with external config changes
on_config_change(self._on_config_change)
def _update_from_config(self):
current_val = str(config().get(self.config_key, ""))
if current_val in self.options:
self.set_active(self.options.index(current_val))
def _on_config_change(self, new_config, old_config):
if config().has_changed(self.config_key, old_config):
self._update_from_config()
def on_change(self, combo):
value = combo.get_active_text()
if value:
config().set(self.config_key, value)
config().save()
def destroy(self):
off_config_change(self._on_config_change)
super().destroy()
class SettingsList(Box):
"""A list of tags that can be added or removed"""
def __init__(self, config_key: str, **kwargs):
super().__init__(name="settings-list", orientation="v", spacing=5, **kwargs)
self.config_key = config_key
self.list_box = Box(spacing=5, name="settings-list-tags")
self.entry = Entry(
name="settings-list-entry",
placeholder="Add new item...",
)
self.entry.connect("activate", self.on_add)
self.add(self.list_box)
self.add(self.entry)
self._update_list()
on_config_change(self._on_config_change)
def _on_config_change(self, new_config, old_config):
if config().has_changed(self.config_key, old_config):
self._update_list()
def _update_list(self):
items = config().get(self.config_key, [])
if not isinstance(items, list):
items = []
# Clear existing tags
self.list_box.children = []
for item in items:
tag = Button(
name="settings-list-tag",
child=Box(
spacing=5,
children=[
Label(label=item),
Label(label="✕", name="settings-list-tag-close"),
],
),
on_clicked=lambda *_, i=item: self.on_remove(i),
)
setup_cursor_hover(tag, "pointer")
self.list_box.pack_start(tag, False, False, 0)
self.list_box.show_all()
def on_add(self, entry):
val = entry.get_text().strip()
if not val:
return
items = config().get(self.config_key, [])
if val not in items:
items.append(val)
config().set(self.config_key, items)
config().save()
entry.set_text("")
self._update_list()
def on_remove(self, item):
items = config().get(self.config_key, [])
if item in items:
items.remove(item)
config().set(self.config_key, items)
config().save()
self._update_list()
def destroy(self):
off_config_change(self._on_config_change)
super().destroy()
class SettingsPage(ScrolledWindow):
def __init__(self, title: str, rows: list, **kwargs):
container = Box(
name="settings-page-container",
orientation="v",
spacing=10,
h_expand=True,
v_expand=True,
children=[
Label(label=title, name="settings-page-title", h_align="start"),
Box(orientation="v", spacing=1, children=rows, h_expand=True),
],
)
super().__init__(
name="settings-page",
h_expand=True,
v_expand=True,
propagate_width=True,
propagate_height=False,
min_content_width=600,
**kwargs,
)
self.add(container)
self.show_all()
self._container = container
SECTION_ICONS = {
"general": "misc/logo.svg",
"dock": "misc/control.svg",
"panel": "misc/control-center.svg",
"switcher": "screencapture/window.svg",
"notification": "notifications/notification-active.svg",
}
DEFAULT_SECTION_ICON = "misc/logo.svg"
def humanize_key(key):
return " ".join(word.capitalize() for word in key.replace("_", " ").split())
class SettingsWindow(Gtk.Window):
def __init__(self, **kwargs):
super().__init__(title="Modus Settings", **kwargs)
self.set_name("settings-window")
self.set_title("modus-settings")
self.set_wmclass("modus-settings", "Modus")
self.set_default_size(850, 600)
self.set_resizable(False)
self.set_type_hint(Gtk.Window.get_type_hint(self) | Gdk.WindowTypeHint.DIALOG)
self.set_position(Gtk.WindowPosition.CENTER)
self.set_visible(False)
# Destroy on close so the widget tree is freed; get_settings_window
# recreates it on next open. Destruction is deferred out of the
# delete-event emission to avoid tearing the window down mid-signal.
self.connect("delete-event", self._on_delete_event)
self.connect("destroy", self._on_window_destroyed)
self.stack = Stack(
name="settings-stack",
transition_type="none",
h_expand=True,
v_expand=True,
)
self.pages = {}
self.sidebar_buttons = {}
for section, values in config().get_all().items():
if not isinstance(values, dict):
continue
self._add_section(section, values)
self.set_page(next(iter(self.pages)))
self.sidebar = Box(
name="settings-sidebar",
orientation="v",
spacing=5,
children=list(self.sidebar_buttons.values()),
)
self.main_box = Box(
name="settings-main-box",
orientation="h",
h_expand=True,
v_expand=True,
children=[
self.sidebar,
Box(
name="settings-content-wrapper",
children=[self.stack],
h_expand=True,
v_expand=True,
),
],
)
self.add(self.main_box)
def _create_sidebar_button(self, label, page_name, icon_name):
btn = Button(
name="settings-sidebar-button",
child=Box(
orientation="h",
spacing=10,
children=[svg_file(icon_name, size=16), Label(label=label)],
),
on_clicked=lambda *_: self.set_page(page_name),
)
setup_cursor_hover(btn, "pointer")
return btn
def set_page(self, page_name):
if page_name not in self.pages:
return
self.stack.set_visible_child(self.pages[page_name])
for name, btn in self.sidebar_buttons.items():
if name == page_name:
btn.add_style_class("active")
else:
btn.remove_style_class("active")
def _add_section(self, section, values):
page = self._build_page(section, values)
self.pages[section] = page
self.stack.add_named(page, section)
self.sidebar_buttons[section] = self._create_sidebar_button(
humanize_key(section),
section,
SECTION_ICONS.get(section, DEFAULT_SECTION_ICON),
)
def _build_page(self, section, values):
rows = []
for key, value in values.items():
widget = self._infer_widget(f"{section}.{key}", value)
rows.append(SettingsRow(humanize_key(key), widget))
return SettingsPage(f"{humanize_key(section)} Settings", rows)
def _infer_widget(self, full_key, value):
if isinstance(value, bool):
return SettingsSwitch(full_key)
if isinstance(value, list):
return SettingsList(full_key)
return SettingsEntry(full_key)
def toggle(self):
if getattr(self, "_destroyed", False):
# A stale reference (e.g. __main__.settings) outlived this window.
get_settings_window().toggle()
return
if not self.get_visible():
self.show_all()
self.present()
# Re-sync current page
current_page = "general"
for name, btn in self.sidebar_buttons.items():
if "active" in btn.get_style_context().list_classes():
current_page = name
break
self.set_page(current_page)
else:
self.hide()
def _on_delete_event(self, *_):
GLib.idle_add(self.destroy)
return True
def _on_window_destroyed(self, *_):
global _settings_window
self._destroyed = True
if _settings_window is self:
_settings_window = None
_settings_window = None
def get_settings_window():
global _settings_window
if _settings_window is None:
_settings_window = SettingsWindow()
return _settings_window