Commit ad03fe58 authored by Roman Alifanov's avatar Roman Alifanov

Merge branch 'tuneit_next' into 'master'

Tuneit next See merge request !4 feat: graph widgets, system monitor, async improvements - Add info_graph, info_graph_large, info_graph_multi widgets - Add system_monitor module with CPU/RAM/Swap monitoring - Rework async value loading with ThreadPoolExecutor - Pause update timer when page is hidden (map/unmap) - Rework Entry/FileChooser reset button behavior - Fix NumStepper int conversion when digits=0 - Update app icon and add artists to About (GNOME Design)
parents da758584 1c726c10
...@@ -163,6 +163,14 @@ ...@@ -163,6 +163,14 @@
lower: 0 lower: 0
step: 1 step: 1
digits: 0 digits: 0
- name: "Actions"
weight: 10
page: "Boot"
type: custom
settings:
- name: Update GRUB
type: button
set_command: "pkexec /usr/sbin/update-grub 2>&1 | sed 's/:/-/g' | while read line; do echo \"NOTIFY:$line\"; done"
- name: "Main" - name: "Main"
weight: 0 weight: 0
page: "Dirs" page: "Dirs"
......
...@@ -2,6 +2,9 @@ modules_dir = pkgdatadir / 'modules' ...@@ -2,6 +2,9 @@ modules_dir = pkgdatadir / 'modules'
plugins = [ plugins = [
'example_module', 'example_module',
'system_monitor',
] ]
install_subdir(plugins, install_dir: modules_dir) foreach plugin : plugins
install_subdir(plugin, install_dir: modules_dir)
endforeach
#!/bin/bash
get_cpu_usage() {
local cpu_line="$1"
local stat1=$(grep "^${cpu_line} " /proc/stat)
sleep 0.1
local stat2=$(grep "^${cpu_line} " /proc/stat)
local idle1=$(echo "$stat1" | awk '{print $5}')
local idle2=$(echo "$stat2" | awk '{print $5}')
local total1=$(echo "$stat1" | awk '{sum=0; for(i=2;i<=NF;i++) sum+=$i; print sum}')
local total2=$(echo "$stat2" | awk '{sum=0; for(i=2;i<=NF;i++) sum+=$i; print sum}')
local idle_diff=$((idle2 - idle1))
local total_diff=$((total2 - total1))
if [ "$total_diff" -gt 0 ]; then
awk "BEGIN {printf \"%.1f\", 100 * (1 - $idle_diff / $total_diff)}"
else
echo "0.0"
fi
}
case "$1" in
cpu)
get_cpu_usage "cpu"
;;
cpu[0-9]*)
get_cpu_usage "$1"
;;
cpus_all)
cores=$(nproc)
echo -n "["
for i in $(seq 0 $((cores-1))); do
usage=$(get_cpu_usage "cpu$i")
[ $i -gt 0 ] && echo -n ", "
echo -n "{'name': 'CPU $i', 'value': $usage}"
done
echo "]"
;;
ram)
free | awk '/Mem:/ {printf "%.1f", $3/$2*100}'
;;
swap)
free | awk '/Swap:/ {if($2>0) printf "%.1f", $3/$2*100; else print "0"}'
;;
esac
- name: "System Monitor"
weight: 50
pages:
- name: "Monitor Inline"
icon: view-list-symbolic
- name: "Monitor Large"
icon: view-fullscreen-symbolic
- name: "Monitor Cards"
icon: view-grid-symbolic
sections:
- name: "CPU"
weight: 0
page: "Monitor Inline"
type: custom
settings:
- name: CPU Total
type: info_graph
update_interval: 1
get_command: "{module_path}/bin/stats.sh cpu"
map:
min: 0
max: 100
points: 60
color: "#3584e4"
suffix: "%"
- name: RAM Usage
type: info_graph
update_interval: 1
get_command: "{module_path}/bin/stats.sh ram"
map:
min: 0
max: 100
points: 60
color: "#e5a50a"
suffix: "%"
- name: Swap Usage
type: info_graph
update_interval: 2
get_command: "{module_path}/bin/stats.sh swap"
map:
min: 0
max: 100
points: 30
color: "#c061cb"
suffix: "%"
- name: "CPU"
weight: 0
page: "Monitor Large"
type: custom
settings:
- name: Total Usage
type: info_graph_large
update_interval: 1
get_command: "{module_path}/bin/stats.sh cpu"
map:
min: 0
max: 100
points: 60
color: "#3584e4"
suffix: "%"
- name: "Memory"
weight: 10
page: "Monitor Large"
type: custom
settings:
- name: RAM Usage
type: info_graph_large
update_interval: 1
get_command: "{module_path}/bin/stats.sh ram"
map:
min: 0
max: 100
points: 60
color: "#e5a50a"
suffix: "%"
show_max: true
- name: "CPU Cores"
weight: 0
page: "Monitor Cards"
type: custom
settings:
- name: CPU Cores
type: info_graph_multi
update_interval: 1
get_command: "{module_path}/bin/stats.sh cpus_all"
map:
min: 0
max: 100
points: 40
columns: 3
color: "#3584e4"
suffix: "%"
...@@ -26,6 +26,10 @@ developers = [ ...@@ -26,6 +26,10 @@ developers = [
"Vladimir Vaskov <rirusha@altlinux.org>" "Vladimir Vaskov <rirusha@altlinux.org>"
] ]
artists = [
"GNOME Design https://gitlab.gnome.org/Teams/Design/"
]
def build_about_dialog() -> Adw.AboutDialog: def build_about_dialog() -> Adw.AboutDialog:
about = Adw.AboutDialog( about = Adw.AboutDialog(
application_name='tuneit', application_name='tuneit',
...@@ -33,6 +37,7 @@ def build_about_dialog() -> Adw.AboutDialog: ...@@ -33,6 +37,7 @@ def build_about_dialog() -> Adw.AboutDialog:
developer_name='Etersoft', developer_name='Etersoft',
version=tuneit_config.VERSION, version=tuneit_config.VERSION,
developers=developers, developers=developers,
artists=artists,
copyright='© 2024-2025 Etersoft', copyright='© 2024-2025 Etersoft',
license_type=Gtk.License.GPL_3_0 license_type=Gtk.License.GPL_3_0
) )
......
import ast import json
import logging import logging
import dbus import dbus
...@@ -82,10 +82,10 @@ class Daemon(dbus.service.Object): ...@@ -82,10 +82,10 @@ class Daemon(dbus.service.Object):
"Permission denied" "Permission denied"
) )
try: try:
backend_params = ast.literal_eval(backend_params) backend_params = json.loads(backend_params)
backend = root_backend_factory.get_backend(backend_name, backend_params) backend = root_backend_factory.get_backend(backend_name, backend_params)
if backend: if backend:
return str(backend.get_value(key, gtype)) return json.dumps(backend.get_value(key, gtype))
except Exception as e: except Exception as e:
return dbus.DBusException( return dbus.DBusException(
"ru.ximperlinux.TuneIt.Daemon", e "ru.ximperlinux.TuneIt.Daemon", e
...@@ -106,7 +106,7 @@ class Daemon(dbus.service.Object): ...@@ -106,7 +106,7 @@ class Daemon(dbus.service.Object):
"Permission denied" "Permission denied"
) )
try: try:
backend_params = ast.literal_eval(backend_params) backend_params = json.loads(backend_params)
backend = root_backend_factory.get_backend(backend_name, backend_params) backend = root_backend_factory.get_backend(backend_name, backend_params)
if backend: if backend:
backend.set_value(key, value, gtype) backend.set_value(key, value, gtype)
...@@ -130,10 +130,10 @@ class Daemon(dbus.service.Object): ...@@ -130,10 +130,10 @@ class Daemon(dbus.service.Object):
"Permission denied" "Permission denied"
) )
try: try:
backend_params = ast.literal_eval(backend_params) backend_params = json.loads(backend_params)
backend = root_backend_factory.get_backend(backend_name, backend_params) backend = root_backend_factory.get_backend(backend_name, backend_params)
if backend: if backend:
return str(backend.get_range(key, gtype)) return json.dumps(backend.get_range(key, gtype))
except Exception as e: except Exception as e:
return dbus.DBusException( return dbus.DBusException(
"ru.ximperlinux.TuneIt.Daemon", e "ru.ximperlinux.TuneIt.Daemon", e
......
import json
import logging import logging
import dbus import dbus
import ast
class DaemonClient: class DaemonClient:
...@@ -57,7 +57,7 @@ class DaemonClient: ...@@ -57,7 +57,7 @@ class DaemonClient:
:param backend_params: Параметры backend в формате JSON. :param backend_params: Параметры backend в формате JSON.
""" """
self.backend_params = str(backend_params) self.backend_params = json.dumps(backend_params)
def get_value(self, key, gtype): def get_value(self, key, gtype):
""" """
...@@ -68,7 +68,7 @@ class DaemonClient: ...@@ -68,7 +68,7 @@ class DaemonClient:
:return: Полученное значение. :return: Полученное значение.
""" """
try: try:
return ast.literal_eval(str(self.interface.GetValue(self.backend_name, str(self.backend_params), key, gtype))) return json.loads(str(self.interface.GetValue(self.backend_name, self.backend_params, key, gtype)))
except dbus.DBusException as e: except dbus.DBusException as e:
self.logger.error(f"Error in GetValue: {e}") self.logger.error(f"Error in GetValue: {e}")
return None return None
...@@ -83,7 +83,7 @@ class DaemonClient: ...@@ -83,7 +83,7 @@ class DaemonClient:
:return: Результат операции. :return: Результат операции.
""" """
try: try:
self.interface.SetValue(self.backend_name, str(self.backend_params), key, str(value), gtype) self.interface.SetValue(self.backend_name, self.backend_params, key, str(value), gtype)
except dbus.DBusException as e: except dbus.DBusException as e:
self.logger.error(f"Error in SetValue: {e}") self.logger.error(f"Error in SetValue: {e}")
...@@ -96,7 +96,7 @@ class DaemonClient: ...@@ -96,7 +96,7 @@ class DaemonClient:
:return: Диапазон значений. :return: Диапазон значений.
""" """
try: try:
return ast.literal_eval(str(self.interface.GetRange(self.backend_name, str(self.backend_params), key, gtype))) return json.loads(str(self.interface.GetRange(self.backend_name, self.backend_params, key, gtype)))
except dbus.DBusException as e: except dbus.DBusException as e:
self.logger.error(f"Error in GetRange: {e}") self.logger.error(f"Error in GetRange: {e}")
return None return None
......
import threading
from concurrent.futures import ThreadPoolExecutor
from gi.repository import GLib
def _idle_call(fn, *args, **kwargs):
if fn is None:
return False
fn(*args, **kwargs)
return False
class BackendExecutor:
def __init__(self, max_workers=4):
self._pool = ThreadPoolExecutor(max_workers=max_workers)
def submit(self, func, on_success=None, on_error=None, on_done=None):
future = self._pool.submit(func)
def _complete(f):
try:
result = f.result()
except Exception as exc:
GLib.idle_add(_idle_call, on_error, exc)
else:
GLib.idle_add(_idle_call, on_success, result)
finally:
GLib.idle_add(_idle_call, on_done)
future.add_done_callback(_complete)
return future
_executor_lock = threading.Lock()
_executor = None
def get_executor():
global _executor
with _executor_lock:
if _executor is None:
_executor = BackendExecutor()
return _executor
...@@ -5,6 +5,7 @@ import traceback ...@@ -5,6 +5,7 @@ import traceback
from .module import Module from .module import Module
from .page import Page from .page import Page
from .sections import SectionFactory from .sections import SectionFactory
from .executor import get_executor
from .tools.yml_tools import load_modules from .tools.yml_tools import load_modules
from .widgets.deps_alert_dialog import TuneItDepsAlertDialog from .widgets.deps_alert_dialog import TuneItDepsAlertDialog
...@@ -32,6 +33,8 @@ def init_settings_stack(stack, listbox, split_view): ...@@ -32,6 +33,8 @@ def init_settings_stack(stack, listbox, split_view):
modules = list(yaml_data) modules = list(yaml_data)
window = listbox.get_root() window = listbox.get_root()
executor = get_executor()
def process_next_module(): def process_next_module():
nonlocal current_module_index nonlocal current_module_index
...@@ -42,10 +45,13 @@ def init_settings_stack(stack, listbox, split_view): ...@@ -42,10 +45,13 @@ def init_settings_stack(stack, listbox, split_view):
module_data = modules[current_module_index] module_data = modules[current_module_index]
current_module_index += 1 current_module_index += 1
try: def _check():
deps_results = dep_manager.verify_deps(module_data.get('deps', {})) deps_results = dep_manager.verify_deps(module_data.get('deps', {}))
conflicts_results = dep_manager.verify_conflicts(module_data.get('conflicts', {})) conflicts_results = dep_manager.verify_conflicts(module_data.get('conflicts', {}))
return deps_results, conflicts_results
def _on_success(results):
deps_results, conflicts_results = results
deps_message = dep_manager.format_results(deps_results) deps_message = dep_manager.format_results(deps_results)
conflicts_message = dep_manager.format_results(conflicts_results) conflicts_message = dep_manager.format_results(conflicts_results)
...@@ -57,10 +63,13 @@ def init_settings_stack(stack, listbox, split_view): ...@@ -57,10 +63,13 @@ def init_settings_stack(stack, listbox, split_view):
GLib.idle_add(process_next_module) GLib.idle_add(process_next_module)
else: else:
show_dialog(module_data, deps_message, conflicts_message) show_dialog(module_data, deps_message, conflicts_message)
except Exception as e:
handle_error(e, module_data) def _on_error(exc):
handle_error(exc, module_data)
GLib.idle_add(process_next_module) GLib.idle_add(process_next_module)
executor.submit(_check, on_success=_on_success, on_error=_on_error)
def show_dialog(module_data, deps_msg, conflicts_msg): def show_dialog(module_data, deps_msg, conflicts_msg):
dialog = TuneItDepsAlertDialog() dialog = TuneItDepsAlertDialog()
dialog.set_body(module_data['name']) dialog.set_body(module_data['name'])
...@@ -117,4 +126,4 @@ def init_settings_stack(stack, listbox, split_view): ...@@ -117,4 +126,4 @@ def init_settings_stack(stack, listbox, split_view):
error_msg = f"Module '{module_data['name']}' loading error\nError: {e}\nFull traceback:\n{full_traceback}" error_msg = f"Module '{module_data['name']}' loading error\nError: {e}\nFull traceback:\n{full_traceback}"
error(error_msg) error(error_msg)
GLib.idle_add(process_next_module) GLib.idle_add(process_next_module)
\ No newline at end of file
...@@ -11,6 +11,10 @@ class ClassicSection(BaseSection): ...@@ -11,6 +11,10 @@ class ClassicSection(BaseSection):
self.settings = [Setting(s, module) for s in section_data.get('settings', [])] self.settings = [Setting(s, module) for s in section_data.get('settings', [])]
self.settings = sorted(self.settings, key=lambda s: s.weight, reverse=True) self.settings = sorted(self.settings, key=lambda s: s.weight, reverse=True)
# Link settings to their section for page tracking
for setting in self.settings:
setting.section = self
self.module.add_section(self) self.module.add_section(self)
def create_preferences_group(self): def create_preferences_group(self):
......
...@@ -6,6 +6,8 @@ import subprocess ...@@ -6,6 +6,8 @@ import subprocess
from .base import BaseSection from .base import BaseSection
from .custom import CustomSection from .custom import CustomSection
from ..setting.custom_setting import CustomSetting from ..setting.custom_setting import CustomSetting
from ..executor import get_executor
from gi.repository import Adw
class DynamicSection(CustomSection): class DynamicSection(CustomSection):
""" """
...@@ -35,29 +37,90 @@ class DynamicSection(CustomSection): ...@@ -35,29 +37,90 @@ class DynamicSection(CustomSection):
self.generator_command = section_data.get('generator_command') self.generator_command = section_data.get('generator_command')
self.setting_template = section_data.get('setting_template', {}) self.setting_template = section_data.get('setting_template', {})
self._generate_settings() self.settings = []
self.settings_dict = {}
self.settings_dict = {s.orig_name: s for s in self.settings}
self._callback_buffer = [] self._callback_buffer = []
self._generated = False
self._generation_started = False
self._group = None
self._placeholder_row = None
self.module.add_section(self) self.module.add_section(self)
def _generate_settings(self): def create_preferences_group(self):
group = Adw.PreferencesGroup(title=self.name, description=self.module.name)
self._group = group
if not self._generated:
placeholder = Adw.ActionRow(title=_("Loading..."))
placeholder.set_activatable(False)
group.add(placeholder)
self._placeholder_row = placeholder
self._start_generation()
return group
return self._populate_group(group)
def _start_generation(self):
if self._generation_started:
return
self._generation_started = True
executor = get_executor()
def _on_success(settings):
self.settings = settings
self.settings_dict = {s.orig_name: s for s in self.settings}
self._generated = True
self._process_buffered_callbacks()
self._refresh_group()
def _on_error(exc):
self.logger.error(f"Dynamic section generation failed: {exc}")
executor.submit(self._generate_settings_sync, on_success=_on_success, on_error=_on_error)
def _generate_settings_sync(self):
items = self._execute_generator() items = self._execute_generator()
if not items: if not items:
self.logger.warning("Generator returned no items") self.logger.warning("Generator returned no items")
return return []
settings = []
for item in items: for item in items:
try: try:
setting_data = self._apply_template(self.setting_template, item) setting_data = self._apply_template(self.setting_template, item)
setting = CustomSetting(setting_data, self.module, self) setting = CustomSetting(setting_data, self.module, self)
self.settings.append(setting) settings.append(setting)
except Exception as e: except Exception as e:
self.logger.error(f"Error creating setting from item {item}: {e}") self.logger.error(f"Error creating setting from item {item}: {e}")
self.settings = sorted(self.settings, key=lambda s: s.weight, reverse=True) settings = sorted(settings, key=lambda s: s.weight, reverse=True)
self.logger.info(f"Generated {len(self.settings)} settings") self.logger.info(f"Generated {len(settings)} settings")
return settings
def _populate_group(self, group):
not_empty = False
for setting in self.settings:
try:
row = setting.create_row()
if row:
self.logger.debug(f"Adding a row for setting: {setting.name}")
group.add(row)
not_empty = True
else:
self.logger.debug(f"Failed to create a row for setting: {setting.name}")
except Exception as e:
self.logger.error(f"Error creating row for {setting.orig_name}: {str(e)}")
return group if not_empty else None
def _refresh_group(self):
if not self._group:
return
if self._placeholder_row:
self._group.remove(self._placeholder_row)
self._placeholder_row = None
self._populate_group(self._group)
def _execute_generator(self): def _execute_generator(self):
if not self.generator_command: if not self.generator_command:
......
...@@ -22,24 +22,21 @@ class CustomSetting(BaseSetting): ...@@ -22,24 +22,21 @@ class CustomSetting(BaseSetting):
self.widget = WidgetFactory.create_widget(self) self.widget = WidgetFactory.create_widget(self)
if self.widget: if self.widget:
self.row = self.widget.create_row() self.row = self.widget.create_row()
self.bind_widget(self.widget)
self.mark_widget_ready()
return self.row return self.row
except Exception as e: except Exception as e:
self.logger.error(f"Error creating row: {str(e)}") self.logger.error(f"Error creating row: {str(e)}")
return None return None
def get_value(self): def get_value(self):
if self._current_value is None: return self._get_backend_value()
self._current_value = self._execute_get_command()
return self._current_value
def set_value(self, value): def set_value(self, value):
success = self._execute_set_command(value) self._set_backend_value(value)
if success:
self._current_value = value
self._update_widget()
def get_range(self): def get_range(self):
return self._execute_get_range_command() return self._get_backend_range()
def _execute_command(self, cmd, capture_output=True): def _execute_command(self, cmd, capture_output=True):
with subprocess.Popen( with subprocess.Popen(
...@@ -87,8 +84,8 @@ class CustomSetting(BaseSetting): ...@@ -87,8 +84,8 @@ class CustomSetting(BaseSetting):
try: value = ast.literal_eval(output) try: value = ast.literal_eval(output)
except Exception as e: except Exception as e:
value = output value = output
self.logger.info(f"GET: {output} with error {e}")
self.logger.info(f"GET VALUE {value}")
return value return value
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
self.logger.error(f"Get command failed: {e.stderr}") self.logger.error(f"Get command failed: {e.stderr}")
...@@ -99,6 +96,7 @@ class CustomSetting(BaseSetting): ...@@ -99,6 +96,7 @@ class CustomSetting(BaseSetting):
return False return False
try: try:
self.logger.info(f"SET VALUE {value}")
cmd = self._format_command(self.set_command, value) cmd = self._format_command(self.set_command, value)
self._execute_command(cmd, capture_output=False) self._execute_command(cmd, capture_output=False)
return True return True
...@@ -129,7 +127,9 @@ class CustomSetting(BaseSetting): ...@@ -129,7 +127,9 @@ class CustomSetting(BaseSetting):
def _handle_callback(self, line): def _handle_callback(self, line):
try: try:
_, action, target, value = line.split(':', 3) _, action, target, value = line.split(':', 3)
self.section.handle_callback( from gi.repository import GLib
GLib.idle_add(
self.section.handle_callback,
action.strip(), action.strip(),
target.strip(), target.strip(),
value.strip() value.strip()
...@@ -145,7 +145,10 @@ class CustomSetting(BaseSetting): ...@@ -145,7 +145,10 @@ class CustomSetting(BaseSetting):
_, notification, seconds = parts _, notification, seconds = parts
from ...main import get_main_window from ...main import get_main_window
get_main_window().setting_notify( from gi.repository import GLib
GLib.idle_add(
get_main_window().setting_notify,
self.module.name, self.module.name,
self._(notification), self._(notification),
int(seconds) if seconds else None int(seconds) if seconds else None
...@@ -159,11 +162,11 @@ class CustomSetting(BaseSetting): ...@@ -159,11 +162,11 @@ class CustomSetting(BaseSetting):
def current_value(self): def current_value(self):
return self.get_value() return self.get_value()
def _get_backend_value(self): def _get_backend_value_sync(self):
return self.get_value() return self._execute_get_command()
def _set_backend_value(self, value): def _set_backend_value_sync(self, value):
self.set_value(value) self._execute_set_command(value)
def _get_backend_range(self): def _get_backend_range_sync(self):
return self.get_range() return self._execute_get_range_command()
...@@ -20,7 +20,12 @@ class Setting(BaseSetting): ...@@ -20,7 +20,12 @@ class Setting(BaseSetting):
self.logger.info("Root is true") self.logger.info("Root is true")
if dclient is not None: if dclient is not None:
self.widget = WidgetFactory.create_widget(self) self.widget = WidgetFactory.create_widget(self)
return self.widget.create_row() if self.widget else None if not self.widget:
return None
row = self.widget.create_row()
self.bind_widget(self.widget)
self.mark_widget_ready()
return row
else: else:
global service_stopped global service_stopped
...@@ -35,35 +40,37 @@ class Setting(BaseSetting): ...@@ -35,35 +40,37 @@ class Setting(BaseSetting):
return None return None
self.widget = WidgetFactory.create_widget(self) self.widget = WidgetFactory.create_widget(self)
return self.widget.create_row() if self.widget else None if not self.widget:
return None
def _get_backend_value(self, force=False): row = self.widget.create_row()
if self._current_value is None or force is True: self.bind_widget(self.widget)
backend = self._get_backend() self.mark_widget_ready()
value = self.default return row
if backend: def _get_backend_value_sync(self):
backend_value = backend.get_value(self.key, self.gtype) backend = self._get_backend()
if isinstance(backend_value, list): value = self.default
if all(v is not None for v in backend_value):
value = backend_value if backend:
elif backend_value is not None: backend_value = backend.get_value(self.key, self.gtype)
if isinstance(backend_value, list):
if all(v is not None for v in backend_value):
value = backend_value value = backend_value
elif backend_value is not None:
value = backend_value
self._current_value = value return value
return self._current_value
def _get_backend_range(self): def _get_backend_range_sync(self):
backend = self._get_backend() backend = self._get_backend()
if backend: if backend:
return backend.get_range(self.key, self.gtype) return backend.get_range(self.key, self.gtype)
def _set_backend_value(self, value): def _set_backend_value_sync(self, value):
self.logger.info(f"SET VALUE {value}") self.logger.info(f"SET VALUE {value}")
backend = self._get_backend() backend = self._get_backend()
if backend: if backend:
backend.set_value(self.key, value, self.gtype) backend.set_value(self.key, value, self.gtype)
self._current_value = value
def _get_backend(self): def _get_backend(self):
if self.root is True: if self.root is True:
......
...@@ -12,6 +12,7 @@ class AvatarWidget(BaseWidget): ...@@ -12,6 +12,7 @@ class AvatarWidget(BaseWidget):
activatable=False, activatable=False,
focusable=False focusable=False
) )
self.row = row
control_box = Gtk.Box( control_box = Gtk.Box(
spacing=6, spacing=6,
...@@ -71,7 +72,7 @@ class AvatarWidget(BaseWidget): ...@@ -71,7 +72,7 @@ class AvatarWidget(BaseWidget):
self.update_display() self.update_display()
self._update_reset_visibility() self._update_reset_visibility()
return row return self.row
def update_display(self): def update_display(self):
current = self.setting._get_backend_value() current = self.setting._get_backend_value()
......
...@@ -23,6 +23,11 @@ class BaseWidget: ...@@ -23,6 +23,11 @@ class BaseWidget:
reveal_child=False, reveal_child=False,
halign=Gtk.Align.END halign=Gtk.Align.END
) )
self.reset_revealer.set_visible(False)
self.reset_revealer.connect("notify::reveal-child", self._on_reveal_changed)
def _on_reveal_changed(self, revealer, pspec):
revealer.set_visible(revealer.get_reveal_child())
def update_display(self): def update_display(self):
raise NotImplementedError("update_display method should be implemented in the subclass") raise NotImplementedError("update_display method should be implemented in the subclass")
...@@ -30,8 +35,17 @@ class BaseWidget: ...@@ -30,8 +35,17 @@ class BaseWidget:
self.row.set_visible(visible) self.row.set_visible(visible)
def set_enabled(self, enabled: bool): def set_enabled(self, enabled: bool):
if not enabled:
root = self.row.get_root()
if root:
focus = root.get_focus()
if focus and focus.is_ancestor(self.row):
root.set_focus(None)
self.row.set_sensitive(enabled) self.row.set_sensitive(enabled)
def set_busy(self, busy: bool):
self.set_enabled(not busy)
def create_row(self): def create_row(self):
raise NotImplementedError("create_row method should be implemented in the subclass") raise NotImplementedError("create_row method should be implemented in the subclass")
......
...@@ -19,7 +19,6 @@ class BooleanWidget(BaseWidget): ...@@ -19,7 +19,6 @@ class BooleanWidget(BaseWidget):
control_box.append(self.switch) control_box.append(self.switch)
self.row.add_suffix(control_box) self.row.add_suffix(control_box)
self._update_initial_state()
return self.row return self.row
def _update_initial_state(self): def _update_initial_state(self):
......
...@@ -3,11 +3,11 @@ from .BaseWidget import BaseWidget ...@@ -3,11 +3,11 @@ from .BaseWidget import BaseWidget
class ChoiceWidget(BaseWidget): class ChoiceWidget(BaseWidget):
def create_row(self): def create_row(self):
items = list(self.setting.map.keys())
self.row = Adw.ActionRow(title=self.setting.name, subtitle=self.setting.help) self.row = Adw.ActionRow(title=self.setting.name, subtitle=self.setting.help)
self.dropdown = Gtk.DropDown.new_from_strings(items) self.model = Gtk.StringList.new([])
expression = Gtk.PropertyExpression.new(Gtk.StringObject, None, "string")
self.dropdown = Gtk.DropDown.new(self.model, expression)
self.dropdown.set_halign(Gtk.Align.CENTER) self.dropdown.set_halign(Gtk.Align.CENTER)
self.dropdown.set_valign(Gtk.Align.CENTER) self.dropdown.set_valign(Gtk.Align.CENTER)
...@@ -15,28 +15,39 @@ class ChoiceWidget(BaseWidget): ...@@ -15,28 +15,39 @@ class ChoiceWidget(BaseWidget):
self.row.set_activatable_widget(self.dropdown) self.row.set_activatable_widget(self.dropdown)
self._update_dropdown_selection()
control_box = Gtk.Box(spacing=6, orientation=Gtk.Orientation.HORIZONTAL) control_box = Gtk.Box(spacing=6, orientation=Gtk.Orientation.HORIZONTAL)
control_box.append(self.reset_revealer) control_box.append(self.reset_revealer)
control_box.append(self.dropdown) control_box.append(self.dropdown)
self.row.add_suffix(control_box) self.row.add_suffix(control_box)
self._update_reset_visibility()
return self.row return self.row
def update_display(self): def update_display(self):
self._update_dropdown_selection() self._update_dropdown_selection()
self._update_reset_visibility() self._update_reset_visibility()
def on_map_updated(self):
self._rebuild_items()
self._update_dropdown_selection()
self._update_reset_visibility()
def _rebuild_items(self):
items = list(self.setting.map.keys()) if self.setting.map else []
with self.dropdown.handler_block(self.handler_id):
self.model.splice(0, self.model.get_n_items(), items)
self.dropdown.set_sensitive(bool(items))
def _update_dropdown_selection(self): def _update_dropdown_selection(self):
if not self.setting.map:
return
current_index = self.setting._get_selected_row_index() current_index = self.setting._get_selected_row_index()
with self.dropdown.handler_block(self.handler_id): with self.dropdown.handler_block(self.handler_id):
self.dropdown.set_selected(current_index) self.dropdown.set_selected(current_index)
def _on_choice_changed(self, dropdown, _): def _on_choice_changed(self, dropdown, _):
if not self.setting.map:
return
selected = dropdown.get_selected() selected = dropdown.get_selected()
if selected < 0 or selected >= len(self.setting.map): if selected < 0 or selected >= len(self.setting.map):
return return
...@@ -56,6 +67,9 @@ class ChoiceWidget(BaseWidget): ...@@ -56,6 +67,9 @@ class ChoiceWidget(BaseWidget):
self._update_reset_visibility() self._update_reset_visibility()
def _update_reset_visibility(self): def _update_reset_visibility(self):
if not self.setting.map:
self.reset_revealer.set_reveal_child(False)
return
current_value = self.setting._get_selected_row_index() current_value = self.setting._get_selected_row_index()
default_value = self.setting._get_default_row_index() default_value = self.setting._get_default_row_index()
...@@ -63,4 +77,4 @@ class ChoiceWidget(BaseWidget): ...@@ -63,4 +77,4 @@ class ChoiceWidget(BaseWidget):
current_value != default_value current_value != default_value
if default_value is not None if default_value is not None
else False else False
) )
\ No newline at end of file
...@@ -73,6 +73,7 @@ class DualListWidget(BaseWidget): ...@@ -73,6 +73,7 @@ class DualListWidget(BaseWidget):
self.main_row = Adw.PreferencesRow( self.main_row = Adw.PreferencesRow(
activatable=False activatable=False
) )
self.row = self.main_row
content_box = Gtk.Box( content_box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL, orientation=Gtk.Orientation.VERTICAL,
margin_top=8, margin_top=8,
...@@ -212,6 +213,7 @@ class DualListWidget(BaseWidget): ...@@ -212,6 +213,7 @@ class DualListWidget(BaseWidget):
def on_move_item(self, button, row, direction): def on_move_item(self, button, row, direction):
current = self.pending_changes if self.pending_changes is not None else self.setting._get_backend_value() current = self.pending_changes if self.pending_changes is not None else self.setting._get_backend_value()
current = current or []
if not current: if not current:
return return
...@@ -239,6 +241,7 @@ class DualListWidget(BaseWidget): ...@@ -239,6 +241,7 @@ class DualListWidget(BaseWidget):
def on_drop(self, target, value, x, y, row): def on_drop(self, target, value, x, y, row):
current = self.pending_changes if self.pending_changes is not None else self.setting._get_backend_value() current = self.pending_changes if self.pending_changes is not None else self.setting._get_backend_value()
current = current or []
src_item = value src_item = value
dst_item = row.key dst_item = row.key
...@@ -254,7 +257,10 @@ class DualListWidget(BaseWidget): ...@@ -254,7 +257,10 @@ class DualListWidget(BaseWidget):
return False return False
def show_dialog(self, button): def show_dialog(self, button):
if not self.setting.map:
return
current = self.setting._get_backend_value() current = self.setting._get_backend_value()
current = current or []
if self.pending_changes is not None: if self.pending_changes is not None:
current = self.pending_changes current = self.pending_changes
dialog = AddItemsDialog( dialog = AddItemsDialog(
...@@ -268,6 +274,7 @@ class DualListWidget(BaseWidget): ...@@ -268,6 +274,7 @@ class DualListWidget(BaseWidget):
if response == "add": if response == "add":
selected = dialog.get_selected() selected = dialog.get_selected()
current = self.setting._get_backend_value() if self.pending_changes is None else self.pending_changes current = self.setting._get_backend_value() if self.pending_changes is None else self.pending_changes
current = current or []
new_pending = list(set(current + selected)) new_pending = list(set(current + selected))
if new_pending != current: if new_pending != current:
self.pending_changes = new_pending self.pending_changes = new_pending
...@@ -276,6 +283,7 @@ class DualListWidget(BaseWidget): ...@@ -276,6 +283,7 @@ class DualListWidget(BaseWidget):
def on_delete_item(self, button, row): def on_delete_item(self, button, row):
current = self.setting._get_backend_value() if self.pending_changes is None else self.pending_changes current = self.setting._get_backend_value() if self.pending_changes is None else self.pending_changes
current = current or []
if row.key in current: if row.key in current:
new_pending = [k for k in current if k != row.key] new_pending = [k for k in current if k != row.key]
if new_pending != current: if new_pending != current:
...@@ -287,7 +295,9 @@ class DualListWidget(BaseWidget): ...@@ -287,7 +295,9 @@ class DualListWidget(BaseWidget):
current = self.setting._get_backend_value() current = self.setting._get_backend_value()
if self.pending_changes is not None: if self.pending_changes is not None:
current = self.pending_changes current = self.pending_changes
current = current or []
self.reorder_btn.set_sensitive(len(current) > 0) self.reorder_btn.set_sensitive(len(current) > 0)
self.add_btn.set_sensitive(bool(self.setting.map))
while child := self.selected_list.get_first_child(): while child := self.selected_list.get_first_child():
self.selected_list.remove(child) self.selected_list.remove(child)
...@@ -300,6 +310,9 @@ class DualListWidget(BaseWidget): ...@@ -300,6 +310,9 @@ class DualListWidget(BaseWidget):
self._update_reset_visibility() self._update_reset_visibility()
def on_map_updated(self):
self.update_display()
def on_apply(self, button): def on_apply(self, button):
if self.pending_changes is not None: if self.pending_changes is not None:
self.logger.info(f"applying: {self.pending_changes}") self.logger.info(f"applying: {self.pending_changes}")
...@@ -322,5 +335,6 @@ class DualListWidget(BaseWidget): ...@@ -322,5 +335,6 @@ class DualListWidget(BaseWidget):
def _update_reset_visibility(self): def _update_reset_visibility(self):
current = self.setting._get_backend_value() if self.pending_changes is None else self.pending_changes current = self.setting._get_backend_value() if self.pending_changes is None else self.pending_changes
current = current or []
is_default = set(current) == set(self.setting.default or []) is_default = set(current) == set(self.setting.default or [])
self.reset_revealer.set_reveal_child(not is_default) self.reset_revealer.set_reveal_child(not is_default)
...@@ -4,39 +4,27 @@ from .BaseWidget import BaseWidget ...@@ -4,39 +4,27 @@ from .BaseWidget import BaseWidget
class EntryWidget(BaseWidget): class EntryWidget(BaseWidget):
def create_row(self): def create_row(self):
self.row = Adw.ActionRow(title=self.setting.name) self.row = Adw.EntryRow(title=self.setting.name)
if self.setting.default is not None:
self.entry = Gtk.Entry() self.reset_button = Gtk.Button(
self.entry.set_halign(Gtk.Align.CENTER) icon_name="edit-undo-symbolic",
valign=Gtk.Align.CENTER,
self.entry.set_text(str(self.setting._get_backend_value() or "")) tooltip_text=_("Restore Default")
)
self.entry.connect("activate", self._on_text_changed) self.reset_button.add_css_class("flat")
self.reset_button.connect("clicked", self._on_reset_clicked)
self.row.add_prefix(self.reset_button)
control_box = Gtk.Box( self.row.connect("entry-activated", self._on_text_changed)
orientation=Gtk.Orientation.HORIZONTAL,
spacing=6,
margin_start=12,
valign=Gtk.Align.CENTER,
halign=Gtk.Align.CENTER,
)
control_box.append(self.reset_revealer)
control_box.append(self.entry)
self.row.add_suffix(control_box)
self._update_reset_visibility()
return self.row return self.row
def update_display(self): def update_display(self):
current_value = self.setting._get_backend_value() current_value = self.setting._get_backend_value()
self.entry.set_text(str(current_value) if current_value is not None else "") self.row.set_text(str(current_value) if current_value is not None else "")
self._update_reset_visibility() self._update_reset_visibility()
def _on_text_changed(self, entry): def _on_text_changed(self, row):
new_value = entry.get_text() new_value = row.get_text()
self.setting._set_backend_value(new_value) self.setting._set_backend_value(new_value)
...@@ -46,14 +34,15 @@ class EntryWidget(BaseWidget): ...@@ -46,14 +34,15 @@ class EntryWidget(BaseWidget):
default_value = self.setting.default default_value = self.setting.default
self.setting._set_backend_value(default_value) self.setting._set_backend_value(default_value)
self.entry.set_text(str(default_value) if default_value is not None else "") self.row.set_text(str(default_value) if default_value is not None else "")
self._update_reset_visibility() self._update_reset_visibility()
def _update_reset_visibility(self): def _update_reset_visibility(self):
current_value = self.entry.get_text() if self.setting.default is None:
return
default_value = str(self.setting.default) if self.setting.default is not None else "" current_value = self.row.get_text()
has_default = self.setting.default is not None default_value = str(self.setting.default)
is_default = current_value == default_value is_default = current_value == default_value
self.reset_revealer.set_reveal_child(not is_default and has_default) self.reset_button.set_sensitive(not is_default)
\ No newline at end of file \ No newline at end of file
...@@ -11,53 +11,52 @@ class FileChooser(BaseWidget): ...@@ -11,53 +11,52 @@ class FileChooser(BaseWidget):
self.multiple_mode = self.setting.map.get('multiple', False) self.multiple_mode = self.setting.map.get('multiple', False)
self.folder_mode = 'folder' in self.setting.map.get('extensions', []) self.folder_mode = 'folder' in self.setting.map.get('extensions', [])
row = Adw.ActionRow( self.select_button = Gtk.Button.new_from_icon_name(
title=self.setting.name, icon_name="folder-open-symbolic"
subtitle=self.setting.help,
subtitle_selectable=True
)
control_box = Gtk.Box(
spacing=6,
margin_end=12,
halign=Gtk.Align.END
) )
control_box.append(self.reset_revealer) self.select_button.set_valign(Gtk.Align.CENTER)
self.select_button.add_css_class("flat")
self.select_button.connect("clicked", self._on_button_clicked)
if not self.multiple_mode and not self.folder_mode: if self.setting.default is not None:
self.entry = Gtk.Entry( self.reset_button = Gtk.Button(
placeholder_text="Enter path or click to browse", icon_name="edit-undo-symbolic",
hexpand=True,
valign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER,
halign=Gtk.Align.END, tooltip_text=_("Restore Default")
) )
self.entry_handler_id = self.entry.connect("activate", self._on_entry_changed) self.reset_button.add_css_class("flat")
control_box.append(self.entry) self.reset_button.connect("clicked", self._on_reset_clicked)
if not self.multiple_mode and not self.folder_mode:
self.row = Adw.EntryRow(title=self.setting.name)
if self.setting.default is not None:
self.row.add_prefix(self.reset_button)
self.row.add_suffix(self.select_button)
self.row.connect("entry-activated", self._on_entry_changed)
else: else:
self.row = Adw.ActionRow(
title=self.setting.name,
subtitle=self.setting.help,
subtitle_selectable=True
)
self.row.set_activatable_widget(self.select_button)
self.info_label = Gtk.Label( self.info_label = Gtk.Label(
label="No selection" if self.folder_mode else "No files selected", label="No selection" if self.folder_mode else "No files selected",
valign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER,
css_classes=["dim-label"] css_classes=["dim-label"]
) )
control_box.append(self.info_label)
self.select_button = Gtk.Button.new_from_icon_name(
icon_name="folder-open-symbolic"
)
self.select_button.set_valign(Gtk.Align.CENTER)
row.set_activatable_widget(self.select_button) if self.setting.default is not None:
self.row.add_prefix(self.reset_button)
self.select_button.connect("clicked", self._on_button_clicked) self.row.add_suffix(self.info_label)
control_box.append(self.select_button) self.row.add_suffix(self.select_button)
row.add_suffix(control_box)
self.update_display() self.update_display()
self._update_reset_visibility() self._update_reset_visibility()
return row return self.row
def _on_reset_clicked(self, button): def _on_reset_clicked(self, button):
default_value = self.setting.default default_value = self.setting.default
...@@ -73,10 +72,12 @@ class FileChooser(BaseWidget): ...@@ -73,10 +72,12 @@ class FileChooser(BaseWidget):
self._update_reset_visibility() self._update_reset_visibility()
def _update_reset_visibility(self): def _update_reset_visibility(self):
if self.setting.default is None:
return
current_value = self.setting._get_backend_value() current_value = self.setting._get_backend_value()
default_value = self.setting.default default_value = self.setting.default
if isinstance(current_value, str) and current_value.startswith("file://"): if isinstance(current_value, str) and current_value.startswith("file://"):
current_value = current_value[7:] current_value = current_value[7:]
...@@ -88,10 +89,7 @@ class FileChooser(BaseWidget): ...@@ -88,10 +89,7 @@ class FileChooser(BaseWidget):
default_value = os.path.expanduser(default_value) default_value = os.path.expanduser(default_value)
default_value = os.path.expandvars(default_value) default_value = os.path.expandvars(default_value)
self.reset_revealer.set_reveal_child( self.reset_button.set_sensitive(current_value != default_value)
current_value != default_value if default_value is not None
else False
)
def update_display(self): def update_display(self):
current = self.setting._get_backend_value() current = self.setting._get_backend_value()
...@@ -210,17 +208,15 @@ class FileChooser(BaseWidget): ...@@ -210,17 +208,15 @@ class FileChooser(BaseWidget):
self.info_label.set_label(f"{count} files selected" if count else "No files selected") self.info_label.set_label(f"{count} files selected" if count else "No files selected")
def _update_single_file_display(self, current): def _update_single_file_display(self, current):
with self.entry.handler_block(self.entry_handler_id): self.row.set_text(current or "")
self.entry.set_text(current or "") if current:
if current: file = Gio.File.new_for_path(current)
file = Gio.File.new_for_path(current) self.row.set_tooltip_text(file.get_parse_name())
self.entry.set_tooltip_text(file.get_parse_name()) else:
else: self.row.set_tooltip_text(None)
self.entry.set_tooltip_text(None)
def _on_entry_changed(self, entry): def _on_entry_changed(self, row):
if not self.folder_mode and not self.multiple_mode: path = row.get_text().strip()
path = entry.get_text().strip() self.setting._set_backend_value(path)
self.setting._set_backend_value(path)
self._update_reset_visibility() self._update_reset_visibility()
\ No newline at end of file
...@@ -5,6 +5,7 @@ class InfoDictWidget(BaseWidget): ...@@ -5,6 +5,7 @@ class InfoDictWidget(BaseWidget):
def create_row(self): def create_row(self):
main_box = Adw.PreferencesRow() main_box = Adw.PreferencesRow()
main_box.set_activatable(False) main_box.set_activatable(False)
self.row = main_box
content_box = Gtk.Box( content_box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL, orientation=Gtk.Orientation.VERTICAL,
...@@ -38,7 +39,7 @@ class InfoDictWidget(BaseWidget): ...@@ -38,7 +39,7 @@ class InfoDictWidget(BaseWidget):
content_box.append(self.main_list) content_box.append(self.main_list)
self._update_initial_state() self._update_initial_state()
return main_box return self.row
def _create_value_label(self, value, level): def _create_value_label(self, value, level):
label = Gtk.Label( label = Gtk.Label(
......
from gi.repository import Adw, Gtk
from .BaseWidget import BaseWidget
class InfoGraphLargeWidget(BaseWidget):
def __init__(self, setting):
super().__init__(setting)
self.history = []
self.max_points = setting.map.get('points', 60) if setting.map else 60
self.min_value = setting.map.get('min', 0) if setting.map else 0
self.max_value = setting.map.get('max', 100) if setting.map else 100
self.color = setting.map.get('color', '#3584e4') if setting.map else '#3584e4'
self.suffix = setting.map.get('suffix', '%') if setting.map else '%'
self.show_max = setting.map.get('show_max', False) if setting.map else False
self.max_seen = self.min_value
def create_row(self):
self.box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL,
spacing=0
)
self.drawing_area = Gtk.DrawingArea()
self.drawing_area.set_size_request(-1, 120)
self.drawing_area.set_hexpand(True)
self.drawing_area.set_draw_func(self._draw_graph)
label_box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL,
spacing=2,
margin_start=12,
margin_end=12,
margin_top=8,
margin_bottom=12
)
self.title_label = Gtk.Label(
label=self.setting.name,
xalign=0,
css_classes=["dim-label", "caption"]
)
self.value_label = Gtk.Label(
label="--",
xalign=0,
css_classes=["title-3"]
)
label_box.append(self.title_label)
label_box.append(self.value_label)
self.box.append(self.drawing_area)
self.box.append(label_box)
self.row = Gtk.ListBoxRow(
selectable=False,
activatable=False
)
self.row.set_child(self.box)
return self.row
def _parse_color(self, color_str):
if color_str.startswith('#'):
color_str = color_str[1:]
r = int(color_str[0:2], 16) / 255.0
g = int(color_str[2:4], 16) / 255.0
b = int(color_str[4:6], 16) / 255.0
return r, g, b
def _draw_graph(self, area, cr, width, height):
r, g, b = self._parse_color(self.color)
cr.set_source_rgba(r, g, b, 0.08)
cr.rectangle(0, 0, width, height)
cr.fill()
if len(self.history) < 2:
return
value_range = self.max_value - self.min_value
if value_range == 0:
value_range = 1
points = []
padding = 0
graph_height = height - padding * 2
step = width / (self.max_points - 1)
start_x = width - (len(self.history) - 1) * step
for i, value in enumerate(self.history):
x = start_x + i * step
normalized = (value - self.min_value) / value_range
normalized = max(0, min(1, normalized))
y = height - padding - (normalized * graph_height)
points.append((x, y))
cr.set_source_rgba(r, g, b, 0.25)
cr.move_to(points[0][0], height)
cr.line_to(points[0][0], points[0][1])
for x, y in points[1:]:
cr.line_to(x, y)
cr.line_to(points[-1][0], height)
cr.close_path()
cr.fill()
cr.set_source_rgba(r, g, b, 0.8)
cr.set_line_width(1.5)
cr.move_to(points[0][0], points[0][1])
for x, y in points[1:]:
cr.line_to(x, y)
cr.stroke()
def update_display(self):
value = self.setting._current_value
if value is not None:
try:
value = float(value)
self.history.append(value)
if len(self.history) > self.max_points:
self.history.pop(0)
if value > self.max_seen:
self.max_seen = value
if self.show_max:
self.value_label.set_label(
f"{value:.0f}{self.suffix} \u00b7 Max: {self.max_seen:.0f}{self.suffix}"
)
else:
self.value_label.set_label(f"{value:.0f}{self.suffix}")
self.drawing_area.queue_draw()
except (ValueError, TypeError):
pass
from gi.repository import Gtk
from .BaseWidget import BaseWidget
class InfoGraphMultiWidget(BaseWidget):
def __init__(self, setting):
super().__init__(setting)
self.graphs = {}
self.max_points = setting.map.get('points', 40) if setting.map else 40
self.min_value = setting.map.get('min', 0) if setting.map else 0
self.max_value = setting.map.get('max', 100) if setting.map else 100
self.color = setting.map.get('color', '#3584e4') if setting.map else '#3584e4'
self.suffix = setting.map.get('suffix', '%') if setting.map else '%'
self.columns = setting.map.get('columns', 3) if setting.map else 3
def create_row(self):
self.flow_box = Gtk.FlowBox(
homogeneous=True,
selection_mode=Gtk.SelectionMode.NONE,
margin_start=6,
margin_end=6,
margin_top=6,
margin_bottom=6,
row_spacing=6,
column_spacing=6
)
self.row = Gtk.ListBoxRow(
selectable=False,
activatable=False
)
self.row.set_child(self.flow_box)
return self.row
def _parse_color(self, color_str):
if color_str.startswith('#'):
color_str = color_str[1:]
r = int(color_str[0:2], 16) / 255.0
g = int(color_str[2:4], 16) / 255.0
b = int(color_str[4:6], 16) / 255.0
return r, g, b
def _create_graph_card(self, name):
box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL,
spacing=0,
css_classes=["card"],
width_request=150
)
drawing_area = Gtk.DrawingArea()
drawing_area.set_size_request(-1, 70)
drawing_area.set_hexpand(True)
label_box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL,
spacing=2,
margin_start=10,
margin_end=10,
margin_top=6,
margin_bottom=10
)
title_label = Gtk.Label(
label=name,
xalign=0,
css_classes=["dim-label", "caption"]
)
value_label = Gtk.Label(
label="--",
xalign=0,
css_classes=["title-4"]
)
label_box.append(title_label)
label_box.append(value_label)
box.append(drawing_area)
box.append(label_box)
self.graphs[name] = {
'history': [],
'drawing_area': drawing_area,
'value_label': value_label,
'box': box
}
drawing_area.set_draw_func(lambda area, cr, w, h: self._draw_graph(cr, w, h, name))
return box
def _draw_graph(self, cr, width, height, name):
if name not in self.graphs:
return
history = self.graphs[name]['history']
r, g, b = self._parse_color(self.color)
cr.set_source_rgba(r, g, b, 0.1)
cr.rectangle(0, 0, width, height)
cr.fill()
if len(history) < 2:
return
value_range = self.max_value - self.min_value
if value_range == 0:
value_range = 1
points = []
padding_y = 4
graph_height = height - padding_y * 2
step = width / (self.max_points - 1)
start_x = width - (len(history) - 1) * step
for i, value in enumerate(history):
x = start_x + i * step
normalized = (value - self.min_value) / value_range
normalized = max(0, min(1, normalized))
y = height - padding_y - (normalized * graph_height)
points.append((x, y))
cr.set_source_rgba(r, g, b, 0.6)
cr.set_line_width(1.5)
cr.move_to(points[0][0], points[0][1])
for x, y in points[1:]:
cr.line_to(x, y)
cr.stroke()
def update_display(self):
data = self.setting._current_value
if not data or not isinstance(data, list):
return
for item in data:
if not isinstance(item, dict):
continue
name = item.get('name', '')
value = item.get('value')
subtitle = item.get('subtitle', '')
if not name or value is None:
continue
try:
value = float(value)
except (ValueError, TypeError):
continue
if name not in self.graphs:
card = self._create_graph_card(name)
self.flow_box.append(card)
graph = self.graphs[name]
graph['history'].append(value)
if len(graph['history']) > self.max_points:
graph['history'].pop(0)
if subtitle:
graph['value_label'].set_label(f"{value:.0f} {self.suffix} \u00b7 {subtitle}")
else:
graph['value_label'].set_label(f"{value:.0f} {self.suffix}")
graph['drawing_area'].queue_draw()
from gi.repository import Adw, Gtk
from .BaseWidget import BaseWidget
class InfoGraphWidget(BaseWidget):
def __init__(self, setting):
super().__init__(setting)
self.history = []
self.max_points = setting.map.get('points', 60) if setting.map else 60
self.min_value = setting.map.get('min', 0) if setting.map else 0
self.max_value = setting.map.get('max', 100) if setting.map else 100
self.color = setting.map.get('color', '#3584e4') if setting.map else '#3584e4'
self.suffix = setting.map.get('suffix', '%') if setting.map else '%'
def create_row(self):
self.row = Adw.ActionRow(
title=self.setting.name,
subtitle=self.setting.help
)
box = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL,
spacing=12,
valign=Gtk.Align.CENTER
)
self.value_label = Gtk.Label(
label="--",
width_chars=6,
xalign=1.0
)
self.value_label.add_css_class("title-4")
self.drawing_area = Gtk.DrawingArea()
self.drawing_area.set_size_request(120, 32)
self.drawing_area.set_draw_func(self._draw_graph)
box.append(self.value_label)
box.append(self.drawing_area)
self.row.add_suffix(box)
return self.row
def _parse_color(self, color_str):
if color_str.startswith('#'):
color_str = color_str[1:]
r = int(color_str[0:2], 16) / 255.0
g = int(color_str[2:4], 16) / 255.0
b = int(color_str[4:6], 16) / 255.0
return r, g, b
def _draw_graph(self, area, cr, width, height):
if not self.history:
return
r, g, b = self._parse_color(self.color)
cr.set_source_rgba(r, g, b, 0.1)
cr.rectangle(0, 0, width, height)
cr.fill()
cr.set_source_rgba(r, g, b, 0.3)
cr.set_line_width(1)
cr.rectangle(0.5, 0.5, width - 1, height - 1)
cr.stroke()
if len(self.history) < 2:
return
cr.set_source_rgba(r, g, b, 0.2)
value_range = self.max_value - self.min_value
if value_range == 0:
value_range = 1
points = []
step = width / (self.max_points - 1)
start_x = width - (len(self.history) - 1) * step
for i, value in enumerate(self.history):
x = start_x + i * step
normalized = (value - self.min_value) / value_range
y = height - (normalized * (height - 4)) - 2
points.append((x, y))
cr.move_to(points[0][0], height)
cr.line_to(points[0][0], points[0][1])
for x, y in points[1:]:
cr.line_to(x, y)
cr.line_to(points[-1][0], height)
cr.close_path()
cr.fill()
cr.set_source_rgb(r, g, b)
cr.set_line_width(1.5)
cr.move_to(points[0][0], points[0][1])
for x, y in points[1:]:
cr.line_to(x, y)
cr.stroke()
def update_display(self):
value = self.setting._current_value
if value is not None:
try:
value = float(value)
self.history.append(value)
if len(self.history) > self.max_points:
self.history.pop(0)
self.value_label.set_label(f"{value:.1f}{self.suffix}")
self.drawing_area.queue_draw()
except (ValueError, TypeError):
pass
...@@ -21,7 +21,7 @@ class InfoLabelWidget(BaseWidget): ...@@ -21,7 +21,7 @@ class InfoLabelWidget(BaseWidget):
def _update_initial_state(self): def _update_initial_state(self):
current_value = self.setting._get_backend_value() current_value = self.setting._get_backend_value()
self.label.set_label(current_value) self.label.set_label(str(current_value) if current_value is not None else "")
def update_display(self): def update_display(self):
self._update_initial_state() self._update_initial_state()
...@@ -4,30 +4,33 @@ from .BaseWidget import BaseWidget ...@@ -4,30 +4,33 @@ from .BaseWidget import BaseWidget
class NumStepper(BaseWidget): class NumStepper(BaseWidget):
def create_row(self): def create_row(self):
map = self.setting.map map_data = self.setting.map or {}
map_keys = list(map.keys()) map_keys = list(map_data.keys())
row = Adw.ActionRow( row = Adw.ActionRow(
title=self.setting.name, title=self.setting.name,
subtitle=self.setting.help, subtitle=self.setting.help,
activatable=False activatable=False
) )
self.row = row
self.spin = Gtk.SpinButton( self.spin = Gtk.SpinButton(
valign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER,
halign=Gtk.Align.CENTER, halign=Gtk.Align.CENTER,
) )
lower, upper, step, digits = self._get_range_values(map_data)
adjustment = Gtk.Adjustment( adjustment = Gtk.Adjustment(
value=self.setting._get_backend_value(), value=lower,
lower=map["lower"], lower=lower,
upper=map["upper"], upper=upper,
step_increment=map["step"], step_increment=step,
) )
self.spin.set_adjustment(adjustment) self.spin.set_adjustment(adjustment)
self.spin.set_sensitive(bool(map_data))
if "digits" in map_keys: if "digits" in map_keys:
self.spin.set_digits(map["digits"]) self.spin.set_digits(map_data["digits"])
control_box = Gtk.Box( control_box = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL, orientation=Gtk.Orientation.HORIZONTAL,
...@@ -39,20 +42,36 @@ class NumStepper(BaseWidget): ...@@ -39,20 +42,36 @@ class NumStepper(BaseWidget):
row.add_suffix(control_box) row.add_suffix(control_box)
self.spin_handler_id = self.spin.connect("value-changed", self._on_num_changed) self.spin_handler_id = self.spin.connect("value-changed", self._on_num_changed)
return self.row
self._update_reset_visibility()
return row
def update_display(self): def update_display(self):
if not self.setting.map:
return
current_value = self.setting._get_backend_value() current_value = self.setting._get_backend_value()
if current_value is None:
current_value = self.spin.get_adjustment().get_lower()
with self.spin.handler_block(self.spin_handler_id): with self.spin.handler_block(self.spin_handler_id):
self.spin.set_value(float(current_value)) self.spin.set_value(float(current_value))
self._update_reset_visibility() self._update_reset_visibility()
def on_map_updated(self):
map_data = self.setting.map or {}
lower, upper, step, digits = self._get_range_values(map_data)
adjustment = self.spin.get_adjustment()
with self.spin.handler_block(self.spin_handler_id):
adjustment.set_lower(lower)
adjustment.set_upper(upper)
adjustment.set_step_increment(step)
self.spin.set_digits(digits)
self.spin.set_sensitive(bool(map_data))
self.update_display()
def _on_num_changed(self, widget): def _on_num_changed(self, widget):
selected_value = widget.get_value() selected_value = widget.get_value()
if self.spin.get_digits() == 0:
selected_value = int(selected_value)
self.setting._set_backend_value(selected_value) self.setting._set_backend_value(selected_value)
self._update_reset_visibility() self._update_reset_visibility()
...@@ -68,10 +87,24 @@ class NumStepper(BaseWidget): ...@@ -68,10 +87,24 @@ class NumStepper(BaseWidget):
self._update_reset_visibility() self._update_reset_visibility()
def _update_reset_visibility(self): def _update_reset_visibility(self):
current_value = float(self.setting._get_backend_value()) if not self.setting.map:
self.reset_revealer.set_reveal_child(False)
return
current_value = self.setting._get_backend_value()
if current_value is None:
self.reset_revealer.set_reveal_child(False)
return
current_value = float(current_value)
default_value = self.setting.default default_value = self.setting.default
self.reset_revealer.set_reveal_child( self.reset_revealer.set_reveal_child(
current_value != default_value if default_value is not None current_value != default_value if default_value is not None
else False else False
) )
\ No newline at end of file
def _get_range_values(self, map_data):
lower = map_data.get("lower", 0)
upper = map_data.get("upper", max(lower + 1, 1))
step = map_data.get("step", 1)
digits = map_data.get("digits", 0)
return lower, upper, step, digits
...@@ -5,6 +5,7 @@ from .BaseWidget import BaseWidget ...@@ -5,6 +5,7 @@ from .BaseWidget import BaseWidget
class RadioChoiceWidget(BaseWidget): class RadioChoiceWidget(BaseWidget):
def create_row(self): def create_row(self):
main_box = Adw.PreferencesRow() main_box = Adw.PreferencesRow()
self.row = main_box
content_box = Gtk.Box( content_box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL, orientation=Gtk.Orientation.VERTICAL,
...@@ -48,24 +49,48 @@ class RadioChoiceWidget(BaseWidget): ...@@ -48,24 +49,48 @@ class RadioChoiceWidget(BaseWidget):
title_box.append(subtitle_label) title_box.append(subtitle_label)
radio_container = Gtk.Box( self.radio_container = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL, orientation=Gtk.Orientation.VERTICAL,
spacing=8 spacing=8
) )
content_box.append(radio_container) content_box.append(self.radio_container)
self.radio_buttons = {} self.radio_buttons = {}
self._build_radio_buttons()
self.reset_revealer.set_halign(Gtk.Align.END)
title_horizontal_box.append(self.reset_revealer)
self._update_reset_visibility()
return self.row
def on_map_updated(self):
while True:
child = self.radio_container.get_first_child()
if child is None:
break
self.radio_container.remove(child)
self._build_radio_buttons()
self._update_reset_visibility()
def _build_radio_buttons(self):
self.radio_buttons = {}
if not self.setting.map:
placeholder = Gtk.Label(label=_("Loading..."), halign=Gtk.Align.START)
placeholder.add_css_class("dim-label")
self.radio_container.append(placeholder)
return
current_value = self.setting._get_backend_value() current_value = self.setting._get_backend_value()
group = None group = None
# Создаем радио-кнопки
for label, value in self.setting.map.items(): for label, value in self.setting.map.items():
radio = Gtk.CheckButton( radio = Gtk.CheckButton(
label=label, label=label,
halign=Gtk.Align.START, halign=Gtk.Align.START,
active=(value == current_value) active=(value == current_value)
) )
radio.add_css_class('selection-mode') radio.add_css_class("selection-mode")
if group: if group:
radio.set_group(group) radio.set_group(group)
...@@ -74,15 +99,11 @@ class RadioChoiceWidget(BaseWidget): ...@@ -74,15 +99,11 @@ class RadioChoiceWidget(BaseWidget):
handler_id = radio.connect("toggled", self._on_toggle, value) handler_id = radio.connect("toggled", self._on_toggle, value)
self.radio_buttons[value] = (radio, handler_id) self.radio_buttons[value] = (radio, handler_id)
radio_container.append(radio) self.radio_container.append(radio)
self.reset_revealer.set_halign(Gtk.Align.END)
title_horizontal_box.append(self.reset_revealer)
self._update_reset_visibility()
return main_box
def update_display(self): def update_display(self):
if not self.setting.map:
return
current_value = self.setting._get_backend_value() current_value = self.setting._get_backend_value()
for value, (radio, handler_id) in self.radio_buttons.items(): for value, (radio, handler_id) in self.radio_buttons.items():
with GObject.signal_handler_block(radio, handler_id): with GObject.signal_handler_block(radio, handler_id):
...@@ -108,6 +129,9 @@ class RadioChoiceWidget(BaseWidget): ...@@ -108,6 +129,9 @@ class RadioChoiceWidget(BaseWidget):
self._update_reset_visibility() self._update_reset_visibility()
def _update_reset_visibility(self): def _update_reset_visibility(self):
if not self.setting.map:
self.reset_revealer.set_reveal_child(False)
return
current_value = self.setting._get_backend_value() current_value = self.setting._get_backend_value()
default_value = self.setting.default default_value = self.setting.default
......
...@@ -107,22 +107,8 @@ class ThemeChooserWidget(BaseWidget): ...@@ -107,22 +107,8 @@ class ThemeChooserWidget(BaseWidget):
content_box.append(self.flowbox) content_box.append(self.flowbox)
self.theme_cards = {} self.theme_cards = {}
current_value = self.setting._get_backend_value() self.handler_id = self.flowbox.connect("child-activated", self._on_theme_selected)
previews = self.setting.previews or {} self._build_cards()
for label, value in self.setting.map.items():
card = self._create_theme_card(label, value, previews.get(value))
flowbox_child = Gtk.FlowBoxChild()
flowbox_child.set_child(card)
flowbox_child.value = value
self.flowbox.append(flowbox_child)
self.theme_cards[value] = flowbox_child
if value == current_value:
self.flowbox.select_child(flowbox_child)
self.handler_id = self.flowbox.connect(
"child-activated", self._on_theme_selected)
self._update_reset_visibility() self._update_reset_visibility()
return self.row return self.row
...@@ -173,6 +159,36 @@ class ThemeChooserWidget(BaseWidget): ...@@ -173,6 +159,36 @@ class ThemeChooserWidget(BaseWidget):
card.append(card_inner) card.append(card_inner)
return card return card
def _clear_flowbox(self):
while True:
child = self.flowbox.get_first_child()
if child is None:
break
self.flowbox.remove(child)
self.theme_cards = {}
def _build_cards(self):
if not self.setting.map:
placeholder = Gtk.Label(label=_("Loading..."), halign=Gtk.Align.START)
placeholder.add_css_class("dim-label")
self.flowbox.append(placeholder)
return
current_value = self.setting._get_backend_value()
previews = self.setting.previews or {}
for label, value in self.setting.map.items():
card = self._create_theme_card(label, value, previews.get(value))
flowbox_child = Gtk.FlowBoxChild()
flowbox_child.set_child(card)
flowbox_child.value = value
self.flowbox.append(flowbox_child)
self.theme_cards[value] = flowbox_child
if value == current_value:
with GObject.signal_handler_block(self.flowbox, self.handler_id):
self.flowbox.select_child(flowbox_child)
def _create_color_preview(self, value): def _create_color_preview(self, value):
"""Create a colored preview box for the theme.""" """Create a colored preview box for the theme."""
preview = Gtk.Box( preview = Gtk.Box(
...@@ -250,6 +266,11 @@ class ThemeChooserWidget(BaseWidget): ...@@ -250,6 +266,11 @@ class ThemeChooserWidget(BaseWidget):
self._update_reset_visibility() self._update_reset_visibility()
def on_map_updated(self):
self._clear_flowbox()
self._build_cards()
self._update_reset_visibility()
def _on_theme_selected(self, flowbox, child): def _on_theme_selected(self, flowbox, child):
value = child.value value = child.value
self.setting._set_backend_value(value) self.setting._set_backend_value(value)
......
...@@ -9,6 +9,9 @@ from .FileChooser import FileChooser ...@@ -9,6 +9,9 @@ from .FileChooser import FileChooser
from .ButtonWidget import ButtonWidget from .ButtonWidget import ButtonWidget
from .InfoLabelWidget import InfoLabelWidget from .InfoLabelWidget import InfoLabelWidget
from .InfoDictWidget import InfoDictWidget from .InfoDictWidget import InfoDictWidget
from .InfoGraphWidget import InfoGraphWidget
from .InfoGraphLargeWidget import InfoGraphLargeWidget
from .InfoGraphMultiWidget import InfoGraphMultiWidget
from .DualListWidget import DualListWidget from .DualListWidget import DualListWidget
from .ImageChooserWidget import ImageChooserWidget from .ImageChooserWidget import ImageChooserWidget
from .DualImageChooserWidget import DualImageChooserWidget from .DualImageChooserWidget import DualImageChooserWidget
...@@ -31,6 +34,9 @@ class WidgetFactory: ...@@ -31,6 +34,9 @@ class WidgetFactory:
'button': ButtonWidget, 'button': ButtonWidget,
'info_label': InfoLabelWidget, 'info_label': InfoLabelWidget,
'info_dict': InfoDictWidget, 'info_dict': InfoDictWidget,
'info_graph': InfoGraphWidget,
'info_graph_large': InfoGraphLargeWidget,
'info_graph_multi': InfoGraphMultiWidget,
'list_dual': DualListWidget, 'list_dual': DualListWidget,
} }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment