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
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Creator: CorelDRAW 2019 (64-Bit) -->
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="254mm" height="254mm" version="1.1" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd"
viewBox="0 0 25400 25400"
xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<style type="text/css">
<![CDATA[
.fil5 {fill:#0F164A}
.fil0 {fill:#303354}
.fil1 {fill:#5E81FF}
.fil4 {fill:#7B879D}
.fil2 {fill:#EEF2F1}
.fil3 {fill:#F8FAF9}
]]>
</style>
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="128"
height="128"
viewBox="0 0 33.866667 33.866667"
version="1.1"
id="svg1"
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
sodipodi:docname="Tunelt.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#aaaaaa"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#282828"
inkscape:document-units="px"
inkscape:zoom="4"
inkscape:cx="37.5"
inkscape:cy="66.75"
inkscape:window-width="1920"
inkscape:window-height="1011"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs1">
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient7"
id="linearGradient8"
x1="0.15467961"
y1="244"
x2="127.82812"
y2="243"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.26458333,0,0,0.26458333,-5.1255424,-48.49011)" />
<linearGradient
id="linearGradient7"
inkscape:collect="always">
<stop
style="stop-color:#26a269;stop-opacity:1;"
offset="0"
id="stop7" />
<stop
style="stop-color:#2ebf7c;stop-opacity:1;"
offset="0.02750842"
id="stop9" />
<stop
style="stop-color:#26a269;stop-opacity:1;"
offset="0.05935227"
id="stop10" />
<stop
style="stop-color:#208e5b;stop-opacity:1;"
offset="0.16044359"
id="stop11" />
<stop
style="stop-color:#33d289;stop-opacity:1;"
offset="0.1867277"
id="stop12" />
<stop
style="stop-color:#26a269;stop-opacity:1;"
offset="0.22828485"
id="stop13" />
<stop
style="stop-color:#22925d;stop-opacity:1;"
offset="0.3764497"
id="stop14" />
<stop
style="stop-color:#33cf88;stop-opacity:1;"
offset="0.49692422"
id="stop15" />
<stop
style="stop-color:#1c7e50;stop-opacity:1;"
offset="0.50103444"
id="stop22" />
<stop
style="stop-color:#26a269;stop-opacity:1;"
offset="0.6228826"
id="stop16" />
<stop
style="stop-color:#26a269;stop-opacity:1;"
offset="0.79457337"
id="stop17" />
<stop
style="stop-color:#2fbf7c;stop-opacity:1;"
offset="0.82089001"
id="stop18" />
<stop
style="stop-color:#1c8354;stop-opacity:1;"
offset="0.8444376"
id="stop19" />
<stop
style="stop-color:#26a269;stop-opacity:1;"
offset="0.95658338"
id="stop20" />
<stop
style="stop-color:#2fc17e;stop-opacity:1;"
offset="0.98427367"
id="stop21" />
<stop
style="stop-color:#1c7f51;stop-opacity:1;"
offset="1"
id="stop8" />
</linearGradient>
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient23"
id="linearGradient24"
x1="58"
y1="287"
x2="69.53125"
y2="287"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.26458333,0,0,0.26458333,-5.1255424,-48.623952)" />
<linearGradient
id="linearGradient23"
inkscape:collect="always">
<stop
style="stop-color:#26a269;stop-opacity:1;"
offset="0"
id="stop23" />
<stop
style="stop-color:#32d58c;stop-opacity:1;"
offset="0.36043361"
id="stop25" />
<stop
style="stop-color:#22935f;stop-opacity:1;"
offset="1"
id="stop24" />
</linearGradient>
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient5"
id="linearGradient6"
x1="32"
y1="284"
x2="104"
y2="204"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.26458333,0,0,0.26458333,-5.1255424,-48.49011)" />
<linearGradient
id="linearGradient5"
inkscape:collect="always">
<stop
style="stop-color:#3584e4;stop-opacity:1;"
offset="0"
id="stop5" />
<stop
style="stop-color:#33d17a;stop-opacity:1"
offset="1"
id="stop6" />
</linearGradient>
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient5"
id="linearGradient1"
gradientUnits="userSpaceOnUse"
x1="32"
y1="284"
x2="104"
y2="204"
gradientTransform="matrix(0.26458333,0,0,0.26458333,-5.1255424,-48.49011)" />
</defs>
<g id="Слой_x0020_1">
<metadata id="CorelCorpID_0Corel-Layer"/>
<g id="_2892636538912">
<path class="fil0" d="M903.95 11787.51l10287.29 -10264.88c1050.69,-955.6 2351.91,-833.29 3066.92,-1.82l10487.15 10432.66c679.55,599.05 572.43,1688.25 -829.66,1706.39l-2415.18 -12.02 -3.76 7015.2c-2.32,1628.67 -1943.73,3355.2 -3787.82,3384.97l-2527.87 0 -10973.09 -10456.54 -2924.88 66.45c-713.82,-21.17 -1442.47,-971.82 -379.1,-1870.41z"/>
<path class="fil1" d="M4223.55 13621.7l6400.31 6083.69 1.15 4368.31 -2361.45 10.55c-2711.35,-31.49 -4025.21,-2459.28 -4022.56,-3570.92l-17.45 -6891.63z"/>
<path class="fil2" d="M13153.63 12991.72l2518.06 -2221.52 16.99 -3552.67c2362.12,573.04 3110.45,5568.37 996.57,7302.33 -2113.88,1733.96 -4580.46,2325.58 -7092.36,72.96 -2511.91,-2252.61 -1691.34,-6269.57 909.54,-7329.57l-11.85 3446.34 2663.05 2282.13z"/>
<path class="fil3" d="M10639.38 24064.5l4774.42 0 13.47 -8682.3c-1616.1,873.98 -3220.19,831.22 -4787.89,-13.36l0 8695.66z"/>
<path class="fil4" d="M10648.34 16514.39c1616.03,663.08 3206.78,643.98 4774.43,0l18.45 -1139.89c-1567.41,858.99 -3185.76,872.11 -4801.84,-5.65l8.96 1145.54z"/>
<circle class="fil5" cx="12973.89" cy="19256.26" r="626.31"/>
<circle class="fil5" cx="12961.1" cy="21578.27" r="666.31"/>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<g
id="g1"
transform="translate(5.1288781,3.2939081)">
<path
id="path6"
style="font-variation-settings:'opsz' 32, 'wght' 900;display:inline;fill:url(#linearGradient8);fill-opacity:1;stroke-width:5.46238;stroke-linecap:square;-inkscape-stroke:none;paint-order:markers stroke fill;enable-background:new"
d="m 11.809858,-0.96019763 c -0.76124,0.001 -1.48948,0.31094 -2.0184804,0.85835 L -5.0846124,14.745302 v 1.32292 c 0,0.76644 0.68462,1.45363 1.58429,1.45366 h 3.79719003 v 9.92342 c 0,1.169 0.94765997,2.11667 2.11665997,2.11667 h 6.21926 v -8.02639 c -3.19821,-1.29374 -5.29158,-4.39863 -5.29166,-7.84861 -1e-5,-3.02486 1.61373,-5.8199396 4.23333,-7.3323696 v 7.8615296 l 4.2333304,2.64584 4.23334,-2.64584 V 6.3546024 c 2.6196,1.51243 4.23334,4.3075096 4.23333,7.3323696 -8e-5,3.44998 -2.09345,6.55487 -5.29167,7.84861 v 8.02639 h 6.35 c 1.16901,0 2.11667,-0.94767 2.11667,-2.11667 v -10.05417 h 3.66644 c 0.94811,2.7e-4 1.57585,-0.77725 1.57975,-1.5875 0.002,-0.38413 0,-1.32291 0,-1.32291 L 13.830928,-0.10856763 c -0.53076,-0.54558 -1.25992,-0.85283 -2.02107,-0.85163 z"
sodipodi:nodetypes="cccsccsscccccccccccssccsccc" />
<path
id="circle23"
style="font-variation-settings:'opsz' 32, 'wght' 900;display:inline;vector-effect:none;fill:url(#linearGradient24);fill-opacity:1;stroke-width:5.46238;stroke-linecap:square;paint-order:markers stroke fill;enable-background:new;stop-color:#000000"
d="m 11.807788,25.569962 c -0.0911,-6e-5 -0.18204,0.008 -0.27181,0.0233 h -1.31569 v 1.32291 0.13075 h 0.005 c 0.068,0.82334 0.75618,1.45687 1.58233,1.45675 0.82615,1.2e-4 1.5143,-0.63341 1.58234,-1.45675 h 0.005 v -0.13075 -1.32291 h -1.31568 c -0.0898,-0.0155 -0.18071,-0.0233 -0.27182,-0.0233 z"
sodipodi:nodetypes="ccccccccccccc" />
<path
id="path1"
style="font-variation-settings:'opsz' 32, 'wght' 900;display:inline;fill:url(#linearGradient1);fill-opacity:1;stroke-width:5.46238;stroke-linecap:square;-inkscape-stroke:none;paint-order:markers stroke fill;enable-background:new"
d="m 11.809858,-2.2831176 a 2.8134437,2.8134437 0 0 0 -2.0184804,0.85835 l -14.43323,14.9334496 a 1.5873407,1.5873407 0 0 0 1.14153,2.69028 h 3.79719003 v 9.92342 a 2.1166666,2.1166666 0 0 0 2.11665997,2.11667 h 6.21926 v -8.02638 a 8.4666666,8.4666666 0 0 1 -5.29166,-7.84862 8.4666666,8.4666666 0 0 1 4.23333,-7.3323696 v 7.8615396 l 4.2333304,2.64583 4.23334,-2.64583 V 5.0316824 a 8.4666666,8.4666666 0 0 1 4.23333,7.3323696 8.4666666,8.4666666 0 0 1 -5.29167,7.84862 v 8.02638 h 6.35 a 2.1166666,2.1166666 0 0 0 2.11667,-2.11667 v -10.05416 h 3.66644 a 1.5799911,1.5799911 0 0 0 1.13275,-2.68201 L 13.830928,-1.4314876 a 2.8134437,2.8134437 0 0 0 -2.02107,-0.85163 z" />
<circle
style="font-variation-settings:'opsz' 32, 'wght' 900;display:inline;vector-effect:none;fill:url(#linearGradient6);fill-opacity:1;stroke-width:5.46238;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;-inkscape-stroke:none;paint-order:markers stroke fill;enable-background:new;stop-color:#000000"
id="path23"
cx="11.807768"
cy="25.593224"
r="1.5875" />
</g>
</g>
</svg>
......@@ -163,6 +163,14 @@
lower: 0
step: 1
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"
weight: 0
page: "Dirs"
......
......@@ -2,6 +2,9 @@ modules_dir = pkgdatadir / 'modules'
plugins = [
'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 = [
"Vladimir Vaskov <rirusha@altlinux.org>"
]
artists = [
"GNOME Design https://gitlab.gnome.org/Teams/Design/"
]
def build_about_dialog() -> Adw.AboutDialog:
about = Adw.AboutDialog(
application_name='tuneit',
......@@ -33,6 +37,7 @@ def build_about_dialog() -> Adw.AboutDialog:
developer_name='Etersoft',
version=tuneit_config.VERSION,
developers=developers,
artists=artists,
copyright='© 2024-2025 Etersoft',
license_type=Gtk.License.GPL_3_0
)
......
import ast
import json
import logging
import dbus
......@@ -82,10 +82,10 @@ class Daemon(dbus.service.Object):
"Permission denied"
)
try:
backend_params = ast.literal_eval(backend_params)
backend_params = json.loads(backend_params)
backend = root_backend_factory.get_backend(backend_name, backend_params)
if backend:
return str(backend.get_value(key, gtype))
return json.dumps(backend.get_value(key, gtype))
except Exception as e:
return dbus.DBusException(
"ru.ximperlinux.TuneIt.Daemon", e
......@@ -106,7 +106,7 @@ class Daemon(dbus.service.Object):
"Permission denied"
)
try:
backend_params = ast.literal_eval(backend_params)
backend_params = json.loads(backend_params)
backend = root_backend_factory.get_backend(backend_name, backend_params)
if backend:
backend.set_value(key, value, gtype)
......@@ -130,10 +130,10 @@ class Daemon(dbus.service.Object):
"Permission denied"
)
try:
backend_params = ast.literal_eval(backend_params)
backend_params = json.loads(backend_params)
backend = root_backend_factory.get_backend(backend_name, backend_params)
if backend:
return str(backend.get_range(key, gtype))
return json.dumps(backend.get_range(key, gtype))
except Exception as e:
return dbus.DBusException(
"ru.ximperlinux.TuneIt.Daemon", e
......
import json
import logging
import dbus
import ast
class DaemonClient:
......@@ -57,7 +57,7 @@ class DaemonClient:
:param backend_params: Параметры backend в формате JSON.
"""
self.backend_params = str(backend_params)
self.backend_params = json.dumps(backend_params)
def get_value(self, key, gtype):
"""
......@@ -68,7 +68,7 @@ class DaemonClient:
:return: Полученное значение.
"""
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:
self.logger.error(f"Error in GetValue: {e}")
return None
......@@ -83,7 +83,7 @@ class DaemonClient:
:return: Результат операции.
"""
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:
self.logger.error(f"Error in SetValue: {e}")
......@@ -96,7 +96,7 @@ class DaemonClient:
:return: Диапазон значений.
"""
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:
self.logger.error(f"Error in GetRange: {e}")
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
from .module import Module
from .page import Page
from .sections import SectionFactory
from .executor import get_executor
from .tools.yml_tools import load_modules
from .widgets.deps_alert_dialog import TuneItDepsAlertDialog
......@@ -32,6 +33,8 @@ def init_settings_stack(stack, listbox, split_view):
modules = list(yaml_data)
window = listbox.get_root()
executor = get_executor()
def process_next_module():
nonlocal current_module_index
......@@ -42,10 +45,13 @@ def init_settings_stack(stack, listbox, split_view):
module_data = modules[current_module_index]
current_module_index += 1
try:
def _check():
deps_results = dep_manager.verify_deps(module_data.get('deps', {}))
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)
conflicts_message = dep_manager.format_results(conflicts_results)
......@@ -57,10 +63,13 @@ def init_settings_stack(stack, listbox, split_view):
GLib.idle_add(process_next_module)
else:
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)
executor.submit(_check, on_success=_on_success, on_error=_on_error)
def show_dialog(module_data, deps_msg, conflicts_msg):
dialog = TuneItDepsAlertDialog()
dialog.set_body(module_data['name'])
......
......@@ -11,6 +11,10 @@ class ClassicSection(BaseSection):
self.settings = [Setting(s, module) for s in section_data.get('settings', [])]
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)
def create_preferences_group(self):
......
......@@ -6,6 +6,8 @@ import subprocess
from .base import BaseSection
from .custom import CustomSection
from ..setting.custom_setting import CustomSetting
from ..executor import get_executor
from gi.repository import Adw
class DynamicSection(CustomSection):
"""
......@@ -35,29 +37,90 @@ class DynamicSection(CustomSection):
self.generator_command = section_data.get('generator_command')
self.setting_template = section_data.get('setting_template', {})
self._generate_settings()
self.settings_dict = {s.orig_name: s for s in self.settings}
self.settings = []
self.settings_dict = {}
self._callback_buffer = []
self._generated = False
self._generation_started = False
self._group = None
self._placeholder_row = None
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()
if not items:
self.logger.warning("Generator returned no items")
return
return []
settings = []
for item in items:
try:
setting_data = self._apply_template(self.setting_template, item)
setting = CustomSetting(setting_data, self.module, self)
self.settings.append(setting)
settings.append(setting)
except Exception as e:
self.logger.error(f"Error creating setting from item {item}: {e}")
self.settings = sorted(self.settings, key=lambda s: s.weight, reverse=True)
self.logger.info(f"Generated {len(self.settings)} settings")
settings = sorted(settings, key=lambda s: s.weight, reverse=True)
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):
if not self.generator_command:
......
import logging
import threading
import time
from gi.repository import GLib, Gtk, Adw
from ..executor import get_executor
from ..searcher import SearcherFactory
from gi.repository import GLib
class BaseSetting:
def __init__(self, setting_data, module):
self._ = module.get_translation
self.module = module
# section will be set by Section after creation (for Setting)
# or before super().__init__ (for CustomSetting)
if not hasattr(self, 'section'):
self.section = None
self.name = self._(setting_data['name'])
self.orig_name = setting_data['name']
self.name = self._(setting_data["name"])
self.orig_name = setting_data["name"]
self.logger = logging.getLogger(f"{self.__class__.__name__}[{self.name}]")
self.weight = setting_data.get('weight', 1)
self.weight = setting_data.get("weight", 1)
self.widget = None
self.root = setting_data.get('root', False)
self.root = setting_data.get("root", False)
self.params = {
**setting_data.get('params', {}),
'module_path': module.path
**setting_data.get("params", {}),
"module_path": module.path,
}
self.type = setting_data['type']
self.type = setting_data["type"]
self.help = setting_data.get('help', None)
self.help = setting_data.get("help", None)
if self.help is not None:
self.help = self._(self.help)
self.default = setting_data.get('default')
self.gtype = setting_data.get('gtype', [])
self.default = setting_data.get("default")
self.gtype = setting_data.get("gtype", [])
self._current_value = None
self.search_target = setting_data.get('search_target', None)
self.map = setting_data.get('map')
self.previews = setting_data.get('previews')
self._value_loading = False
self._map_loading = False
self._map_ready = False
self._busy_count = 0
self._widget_ready = False
self._executor = get_executor()
self._timer_id = None
self._destroyed = False
self._pending_futures = []
self.search_target = setting_data.get("search_target", None)
self.map = setting_data.get("map")
self.previews = setting_data.get("previews")
self._map_source = None
self.prepare_map()
self.update_interval = setting_data.get('update_interval', None)
self.update_interval = setting_data.get("update_interval", None)
if self.update_interval:
self._start_update_thread()
# Page reload flags
self.reload_page_after_set = setting_data.get("reload_page_after_set", False)
self.reload_page_after_get = setting_data.get("reload_page_after_get", False)
def bind_widget(self, widget):
self.widget = widget
self._sync_busy()
def prepare_map(self):
if self.map is None:
if self.search_target is not None:
self.map = SearcherFactory.create(self.search_target).search()
def mark_widget_ready(self):
self._widget_ready = True
self._sync_busy()
if not self._map_ready:
self._load_map_async()
else:
self._notify_map_updated()
self._ensure_value_loaded()
self._update_widget()
if self.update_interval and self.widget and hasattr(self.widget, 'row'):
self.widget.row.connect('map', self._on_row_map)
self.widget.row.connect('unmap', self._on_row_unmap)
def _sync_busy(self):
if self.widget and self._widget_ready:
if self.type.startswith('info_'):
return
self.widget.set_busy(self._busy_count > 0)
def _set_busy(self, busy):
if busy:
self._busy_count += 1
else:
self.map = self._default_map()
self._busy_count = max(0, self._busy_count - 1)
self._sync_busy()
if isinstance(self.map, list) and ('choice' in self.type or 'list_dual' in self.type):
def prepare_map(self):
if len(self.gtype) > 2:
self.gtype = self.gtype[0]
if self.map is not None:
self._map_ready = True
self._normalize_map()
return
if self.search_target is not None:
self.map = {}
self._map_ready = False
self._map_source = "search"
return
if self.type == "boolean":
self.map = {True: True, False: False}
self._map_ready = True
return
if self._needs_range_map():
self.map = {}
self._map_ready = False
self._map_source = "range"
return
self.map = {}
self._map_ready = True
def _normalize_map(self):
if isinstance(self.map, list) and ("choice" in self.type or "list_dual" in self.type):
self.map = {
item.title(): item for item in self.map
if item is not None
item.title(): item for item in self.map if item is not None
}
if not self.map:
self.logger.warning(f"Warning: 'map' is empty for setting {self.name}. Check data source.")
self.logger.warning(
f"Warning: 'map' is empty for setting {self.name}. Check data source."
)
if isinstance(self.map, dict) and ('choice' in self.type or 'list_dual' in self.type):
if isinstance(self.map, dict) and ("choice" in self.type or "list_dual" in self.type):
self.map = {
self._(key) if isinstance(key, str) else key: value
for key, value in self.map.items()
}
if not self.map:
self.logger.warning(f"Warning: 'map' is empty for setting {self.name}. Check data source.")
self.logger.warning(
f"Warning: 'map' is empty for setting {self.name}. Check data source."
)
def _needs_range_map(self):
return "choice" in self.type or "list_dual" in self.type or self.type == "number"
if len(self.gtype) > 2:
self.gtype = self.gtype[0]
else:
self.gtype = self.gtype
def _default_map(self):
if self.type == 'boolean':
# Дефолтная карта для булевых настроек
return {True: True, False: False}
def _build_map_from_range(self, range_value):
if range_value is None:
return {}
if 'choice' in self.type or 'list_dual' in self.type:
map = {}
range = self._get_backend_range()
if "choice" in self.type or "list_dual" in self.type:
map_data = {}
for var in range_value:
if not var:
continue
display_name = var[0].upper() + var[1:]
map_data[var] = display_name
return map_data
if self.type == "number":
map_data = {}
map_data["upper"] = range_value[1]
map_data["lower"] = range_value[0]
map_data["digits"] = (
len(str(range_value[0]).split(".")[-1]) if "." in str(range_value[0]) else 0
)
map_data["step"] = 10 ** -map_data["digits"] if map_data["digits"] > 0 else 0
return map_data
if range is None:
return {}
for var in range:
key = var
display_name = var[0].upper() + var[1:] if var else ''
map[key] = display_name
return map
if self.type == 'number':
map = {}
range = self._get_backend_range()
def _load_map_async(self):
if self._map_ready or self._map_loading:
return
if range is None:
return {}
if self._map_source not in {"search", "range"}:
return
map["upper"] = range[1]
map["lower"] = range[0]
self._map_loading = True
self._set_busy(True)
# Кол-во после запятой
map["digits"] = len(str(range[0]).split('.')[-1]) if '.' in str(range[0]) else 0
# Минимальное число с этим количеством
map["step"] = 10 ** -map["digits"] if map["digits"] > 0 else 0
return map
return {}
if self._map_source == "search":
def _load():
return SearcherFactory.create(self.search_target).search()
def _on_success(result):
if self._destroyed:
return
self.map = result
self._normalize_map()
self._map_ready = True
self._notify_map_updated()
else:
def _load():
return self._get_backend_range_sync()
def _on_success(result):
if self._destroyed:
return
self.map = self._build_map_from_range(result)
self._normalize_map()
self._map_ready = True
self._notify_map_updated()
def _on_error(exc):
if self._destroyed:
return
self.logger.error(f"Map load failed for {self.name}: {exc}")
def _on_done():
if self._destroyed:
return
self._map_loading = False
self._set_busy(False)
self._submit_task(_load, on_success=_on_success, on_error=_on_error, on_done=_on_done)
def _notify_map_updated(self):
if self.widget and self._widget_ready:
if hasattr(self.widget, "on_map_updated"):
self.widget.on_map_updated()
else:
self.widget.update_display()
def _ensure_value_loaded(self, force=False):
if self._current_value is None or force:
self._fetch_value_async(force=force)
def _fetch_value_async(self, force=False, skip_reload=False):
if self._value_loading:
return
self._value_loading = True
self._set_busy(True)
def _load():
return self._get_backend_value_sync()
def _on_success(value):
if self._destroyed:
return
self._current_value = value
self._update_widget()
if self.reload_page_after_get and not skip_reload:
self._reload_page()
def _on_error(exc):
if self._destroyed:
return
self.logger.error(f"Value load failed for {self.name}: {exc}")
def _on_done():
if self._destroyed:
return
self._value_loading = False
self._set_busy(False)
self._submit_task(_load, on_success=_on_success, on_error=_on_error, on_done=_on_done)
def _get_backend_value(self, force=False):
if self._current_value is None or force:
self._fetch_value_async(force=force)
return self._current_value if self._current_value is not None else self.default
def _reload_page(self):
"""
Reload all settings on the current page with scroll position preserved.
"""
if not self.widget or not self.section:
return
# Get the page name for this setting
page_name = self.section.page
# Find the PreferencesPage and its ScrolledWindow
row = self.widget.row if hasattr(self.widget, 'row') else None
if not row:
return
# Find the Adw.PreferencesPage ancestor
pref_page = row.get_ancestor(Adw.PreferencesPage)
if not pref_page:
return
# Find ScrolledWindow inside PreferencesPage to save scroll position
scroll_pos = self._get_scroll_position(pref_page)
# Refresh all settings on the same page
self._refresh_page_settings(page_name)
# Restore scroll position after a short delay to allow UI update
if scroll_pos is not None:
GLib.timeout_add(50, lambda: self._restore_scroll_position(pref_page, scroll_pos))
def _get_scroll_position(self, pref_page):
"""
Get the vertical scroll position from PreferencesPage's internal ScrolledWindow.
"""
# Adw.PreferencesPage contains a ScrolledWindow internally
# We need to traverse to find it
scrolled = self._find_scrolled_window(pref_page)
if scrolled:
vadj = scrolled.get_vadjustment()
if vadj:
return vadj.get_value()
return None
def _restore_scroll_position(self, pref_page, position):
"""
Restore the vertical scroll position.
"""
scrolled = self._find_scrolled_window(pref_page)
if scrolled:
vadj = scrolled.get_vadjustment()
if vadj:
vadj.set_value(position)
return False # Don't repeat the timeout
def _find_scrolled_window(self, widget):
"""
Recursively find a ScrolledWindow child widget.
"""
if isinstance(widget, Gtk.ScrolledWindow):
return widget
# Check children
child = widget.get_first_child() if hasattr(widget, 'get_first_child') else None
while child:
result = self._find_scrolled_window(child)
if result:
return result
child = child.get_next_sibling() if hasattr(child, 'get_next_sibling') else None
return None
def _refresh_page_settings(self, page_name):
"""
Refresh all settings that belong to the same page.
"""
for section in self.module.sections:
# Check if this section belongs to the same page
if section.page == page_name or (section.page is None and page_name == 'Default'):
for setting in section.settings:
# Skip self to avoid infinite loop
if setting is self:
continue
# Refresh the setting value (skip_reload=True to prevent cascade)
setting._fetch_value_async(force=True, skip_reload=True)
def _set_backend_value(self, value):
previous_value = self._current_value
self._current_value = value
self._update_widget()
self._set_busy(True)
def _save():
return self._set_backend_value_sync(value)
def _on_success(result):
if self._destroyed:
return
if self.reload_page_after_set:
self._reload_page()
def _on_error(exc):
if self._destroyed:
return
self.logger.error(f"Value save failed for {self.name}: {exc}")
self._current_value = previous_value
self._update_widget()
def _on_done():
if self._destroyed:
return
self._set_busy(False)
self._submit_task(_save, on_success=_on_success, on_error=_on_error, on_done=_on_done)
def _get_backend_range(self):
if not self._map_ready:
self._load_map_async()
return self.map
def _on_row_map(self, widget):
self._start_update_timer()
def _on_row_unmap(self, widget):
self._stop_update_timer()
def _start_update_timer(self):
if self._timer_id is not None:
return
try:
interval = float(self.update_interval)
except (TypeError, ValueError):
return
def _poll():
if self._destroyed:
return False
self._fetch_value_async(force=True)
return True
self._timer_id = GLib.timeout_add(int(interval * 1000), _poll)
def _stop_update_timer(self):
if self._timer_id is not None:
GLib.source_remove(self._timer_id)
self._timer_id = None
def _submit_task(self, func, on_success=None, on_error=None, on_done=None):
def _wrapped_done():
if future in self._pending_futures:
self._pending_futures.remove(future)
if on_done:
on_done()
future = self._executor.submit(func, on_success=on_success, on_error=on_error, on_done=_wrapped_done)
self._pending_futures.append(future)
return future
def destroy(self):
self._destroyed = True
if self._timer_id is not None:
GLib.source_remove(self._timer_id)
self._timer_id = None
for future in self._pending_futures:
future.cancel()
self._pending_futures.clear()
def _update_widget(self):
if self.widget and self._widget_ready:
self.widget.update_display()
return False
def _get_selected_row_index(self):
current_value = self._get_backend_value()
return list(self.map.values()).index(current_value) if current_value in self.map.values() else 0
try:
return list(self.map.values()).index(current_value)
except ValueError:
return 0
def _get_default_row_index(self):
return list(self.map.values()).index(self.default) if self.default in self.map.values() else None
try:
return list(self.map.values()).index(self.default)
except ValueError:
return None
def _start_update_thread(self):
def update_loop():
while True:
time.sleep(self.update_interval)
prev_value = self._current_value
current_value = self._get_backend_value(force=True)
if current_value != prev_value:
GLib.idle_add(self._update_widget)
def _get_backend_value_sync(self):
raise NotImplementedError
thread = threading.Thread(target=update_loop, daemon=True)
thread.start()
def _set_backend_value_sync(self, value):
raise NotImplementedError
def _update_widget(self):
if self.widget:
self.widget.update_display()
return False
def _get_backend_range_sync(self):
raise NotImplementedError
......@@ -22,24 +22,21 @@ class CustomSetting(BaseSetting):
self.widget = WidgetFactory.create_widget(self)
if self.widget:
self.row = self.widget.create_row()
self.bind_widget(self.widget)
self.mark_widget_ready()
return self.row
except Exception as e:
self.logger.error(f"Error creating row: {str(e)}")
return None
def get_value(self):
if self._current_value is None:
self._current_value = self._execute_get_command()
return self._current_value
return self._get_backend_value()
def set_value(self, value):
success = self._execute_set_command(value)
if success:
self._current_value = value
self._update_widget()
self._set_backend_value(value)
def get_range(self):
return self._execute_get_range_command()
return self._get_backend_range()
def _execute_command(self, cmd, capture_output=True):
with subprocess.Popen(
......@@ -87,8 +84,8 @@ class CustomSetting(BaseSetting):
try: value = ast.literal_eval(output)
except Exception as e:
value = output
self.logger.info(f"GET: {output} with error {e}")
self.logger.info(f"GET VALUE {value}")
return value
except subprocess.CalledProcessError as e:
self.logger.error(f"Get command failed: {e.stderr}")
......@@ -99,6 +96,7 @@ class CustomSetting(BaseSetting):
return False
try:
self.logger.info(f"SET VALUE {value}")
cmd = self._format_command(self.set_command, value)
self._execute_command(cmd, capture_output=False)
return True
......@@ -129,7 +127,9 @@ class CustomSetting(BaseSetting):
def _handle_callback(self, line):
try:
_, action, target, value = line.split(':', 3)
self.section.handle_callback(
from gi.repository import GLib
GLib.idle_add(
self.section.handle_callback,
action.strip(),
target.strip(),
value.strip()
......@@ -145,7 +145,10 @@ class CustomSetting(BaseSetting):
_, notification, seconds = parts
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._(notification),
int(seconds) if seconds else None
......@@ -159,11 +162,11 @@ class CustomSetting(BaseSetting):
def current_value(self):
return self.get_value()
def _get_backend_value(self):
return self.get_value()
def _get_backend_value_sync(self):
return self._execute_get_command()
def _set_backend_value(self, value):
self.set_value(value)
def _set_backend_value_sync(self, value):
self._execute_set_command(value)
def _get_backend_range(self):
return self.get_range()
def _get_backend_range_sync(self):
return self._execute_get_range_command()
......@@ -20,7 +20,12 @@ class Setting(BaseSetting):
self.logger.info("Root is true")
if dclient is not None:
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:
global service_stopped
......@@ -35,10 +40,14 @@ class Setting(BaseSetting):
return None
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
def _get_backend_value(self, force=False):
if self._current_value is None or force is True:
def _get_backend_value_sync(self):
backend = self._get_backend()
value = self.default
......@@ -50,20 +59,18 @@ class Setting(BaseSetting):
elif backend_value is not None:
value = backend_value
self._current_value = value
return self._current_value
return value
def _get_backend_range(self):
def _get_backend_range_sync(self):
backend = self._get_backend()
if backend:
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}")
backend = self._get_backend()
if backend:
backend.set_value(self.key, value, self.gtype)
self._current_value = value
def _get_backend(self):
if self.root is True:
......
......@@ -12,6 +12,7 @@ class AvatarWidget(BaseWidget):
activatable=False,
focusable=False
)
self.row = row
control_box = Gtk.Box(
spacing=6,
......@@ -71,7 +72,7 @@ class AvatarWidget(BaseWidget):
self.update_display()
self._update_reset_visibility()
return row
return self.row
def update_display(self):
current = self.setting._get_backend_value()
......
......@@ -23,6 +23,11 @@ class BaseWidget:
reveal_child=False,
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):
raise NotImplementedError("update_display method should be implemented in the subclass")
......@@ -30,8 +35,17 @@ class BaseWidget:
self.row.set_visible(visible)
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)
def set_busy(self, busy: bool):
self.set_enabled(not busy)
def create_row(self):
raise NotImplementedError("create_row method should be implemented in the subclass")
......
......@@ -19,7 +19,6 @@ class BooleanWidget(BaseWidget):
control_box.append(self.switch)
self.row.add_suffix(control_box)
self._update_initial_state()
return self.row
def _update_initial_state(self):
......
......@@ -3,11 +3,11 @@ from .BaseWidget import BaseWidget
class ChoiceWidget(BaseWidget):
def create_row(self):
items = list(self.setting.map.keys())
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_valign(Gtk.Align.CENTER)
......@@ -15,28 +15,39 @@ class ChoiceWidget(BaseWidget):
self.row.set_activatable_widget(self.dropdown)
self._update_dropdown_selection()
control_box = Gtk.Box(spacing=6, orientation=Gtk.Orientation.HORIZONTAL)
control_box.append(self.reset_revealer)
control_box.append(self.dropdown)
self.row.add_suffix(control_box)
self._update_reset_visibility()
return self.row
def update_display(self):
self._update_dropdown_selection()
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):
if not self.setting.map:
return
current_index = self.setting._get_selected_row_index()
with self.dropdown.handler_block(self.handler_id):
self.dropdown.set_selected(current_index)
def _on_choice_changed(self, dropdown, _):
if not self.setting.map:
return
selected = dropdown.get_selected()
if selected < 0 or selected >= len(self.setting.map):
return
......@@ -56,6 +67,9 @@ class ChoiceWidget(BaseWidget):
self._update_reset_visibility()
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()
default_value = self.setting._get_default_row_index()
......
......@@ -73,6 +73,7 @@ class DualListWidget(BaseWidget):
self.main_row = Adw.PreferencesRow(
activatable=False
)
self.row = self.main_row
content_box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL,
margin_top=8,
......@@ -212,6 +213,7 @@ class DualListWidget(BaseWidget):
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 = current or []
if not current:
return
......@@ -239,6 +241,7 @@ class DualListWidget(BaseWidget):
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 = current or []
src_item = value
dst_item = row.key
......@@ -254,7 +257,10 @@ class DualListWidget(BaseWidget):
return False
def show_dialog(self, button):
if not self.setting.map:
return
current = self.setting._get_backend_value()
current = current or []
if self.pending_changes is not None:
current = self.pending_changes
dialog = AddItemsDialog(
......@@ -268,6 +274,7 @@ class DualListWidget(BaseWidget):
if response == "add":
selected = dialog.get_selected()
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))
if new_pending != current:
self.pending_changes = new_pending
......@@ -276,6 +283,7 @@ class DualListWidget(BaseWidget):
def on_delete_item(self, button, row):
current = self.setting._get_backend_value() if self.pending_changes is None else self.pending_changes
current = current or []
if row.key in current:
new_pending = [k for k in current if k != row.key]
if new_pending != current:
......@@ -287,7 +295,9 @@ class DualListWidget(BaseWidget):
current = self.setting._get_backend_value()
if self.pending_changes is not None:
current = self.pending_changes
current = current or []
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():
self.selected_list.remove(child)
......@@ -300,6 +310,9 @@ class DualListWidget(BaseWidget):
self._update_reset_visibility()
def on_map_updated(self):
self.update_display()
def on_apply(self, button):
if self.pending_changes is not None:
self.logger.info(f"applying: {self.pending_changes}")
......@@ -322,5 +335,6 @@ class DualListWidget(BaseWidget):
def _update_reset_visibility(self):
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 [])
self.reset_revealer.set_reveal_child(not is_default)
......@@ -4,39 +4,27 @@ from .BaseWidget import BaseWidget
class EntryWidget(BaseWidget):
def create_row(self):
self.row = Adw.ActionRow(title=self.setting.name)
self.entry = Gtk.Entry()
self.entry.set_halign(Gtk.Align.CENTER)
self.entry.set_text(str(self.setting._get_backend_value() or ""))
self.entry.connect("activate", self._on_text_changed)
control_box = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL,
spacing=6,
margin_start=12,
self.row = Adw.EntryRow(title=self.setting.name)
if self.setting.default is not None:
self.reset_button = Gtk.Button(
icon_name="edit-undo-symbolic",
valign=Gtk.Align.CENTER,
halign=Gtk.Align.CENTER,
tooltip_text=_("Restore Default")
)
control_box.append(self.reset_revealer)
control_box.append(self.entry)
self.row.add_suffix(control_box)
self._update_reset_visibility()
self.reset_button.add_css_class("flat")
self.reset_button.connect("clicked", self._on_reset_clicked)
self.row.add_prefix(self.reset_button)
self.row.connect("entry-activated", self._on_text_changed)
return self.row
def update_display(self):
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()
def _on_text_changed(self, entry):
new_value = entry.get_text()
def _on_text_changed(self, row):
new_value = row.get_text()
self.setting._set_backend_value(new_value)
......@@ -46,14 +34,15 @@ class EntryWidget(BaseWidget):
default_value = self.setting.default
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()
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 ""
has_default = self.setting.default is not None
current_value = self.row.get_text()
default_value = str(self.setting.default)
is_default = current_value == default_value
self.reset_revealer.set_reveal_child(not is_default and has_default)
\ No newline at end of file
self.reset_button.set_sensitive(not is_default)
\ No newline at end of file
......@@ -11,53 +11,52 @@ class FileChooser(BaseWidget):
self.multiple_mode = self.setting.map.get('multiple', False)
self.folder_mode = 'folder' in self.setting.map.get('extensions', [])
row = Adw.ActionRow(
title=self.setting.name,
subtitle=self.setting.help,
subtitle_selectable=True
self.select_button = Gtk.Button.new_from_icon_name(
icon_name="folder-open-symbolic"
)
self.select_button.set_valign(Gtk.Align.CENTER)
self.select_button.add_css_class("flat")
self.select_button.connect("clicked", self._on_button_clicked)
control_box = Gtk.Box(
spacing=6,
margin_end=12,
halign=Gtk.Align.END
if self.setting.default is not None:
self.reset_button = Gtk.Button(
icon_name="edit-undo-symbolic",
valign=Gtk.Align.CENTER,
tooltip_text=_("Restore Default")
)
control_box.append(self.reset_revealer)
self.reset_button.add_css_class("flat")
self.reset_button.connect("clicked", self._on_reset_clicked)
if not self.multiple_mode and not self.folder_mode:
self.entry = Gtk.Entry(
placeholder_text="Enter path or click to browse",
hexpand=True,
valign=Gtk.Align.CENTER,
halign=Gtk.Align.END,
)
self.entry_handler_id = self.entry.connect("activate", self._on_entry_changed)
control_box.append(self.entry)
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:
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(
label="No selection" if self.folder_mode else "No files selected",
valign=Gtk.Align.CENTER,
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)
self.select_button.connect("clicked", self._on_button_clicked)
control_box.append(self.select_button)
row.add_suffix(control_box)
if self.setting.default is not None:
self.row.add_prefix(self.reset_button)
self.row.add_suffix(self.info_label)
self.row.add_suffix(self.select_button)
self.update_display()
self._update_reset_visibility()
return row
return self.row
def _on_reset_clicked(self, button):
default_value = self.setting.default
......@@ -73,10 +72,12 @@ class FileChooser(BaseWidget):
self._update_reset_visibility()
def _update_reset_visibility(self):
if self.setting.default is None:
return
current_value = self.setting._get_backend_value()
default_value = self.setting.default
if isinstance(current_value, str) and current_value.startswith("file://"):
current_value = current_value[7:]
......@@ -88,10 +89,7 @@ class FileChooser(BaseWidget):
default_value = os.path.expanduser(default_value)
default_value = os.path.expandvars(default_value)
self.reset_revealer.set_reveal_child(
current_value != default_value if default_value is not None
else False
)
self.reset_button.set_sensitive(current_value != default_value)
def update_display(self):
current = self.setting._get_backend_value()
......@@ -210,17 +208,15 @@ class FileChooser(BaseWidget):
self.info_label.set_label(f"{count} files selected" if count else "No files selected")
def _update_single_file_display(self, current):
with self.entry.handler_block(self.entry_handler_id):
self.entry.set_text(current or "")
self.row.set_text(current or "")
if current:
file = Gio.File.new_for_path(current)
self.entry.set_tooltip_text(file.get_parse_name())
self.row.set_tooltip_text(file.get_parse_name())
else:
self.entry.set_tooltip_text(None)
self.row.set_tooltip_text(None)
def _on_entry_changed(self, entry):
if not self.folder_mode and not self.multiple_mode:
path = entry.get_text().strip()
def _on_entry_changed(self, row):
path = row.get_text().strip()
self.setting._set_backend_value(path)
self._update_reset_visibility()
......@@ -5,6 +5,7 @@ class InfoDictWidget(BaseWidget):
def create_row(self):
main_box = Adw.PreferencesRow()
main_box.set_activatable(False)
self.row = main_box
content_box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL,
......@@ -38,7 +39,7 @@ class InfoDictWidget(BaseWidget):
content_box.append(self.main_list)
self._update_initial_state()
return main_box
return self.row
def _create_value_label(self, value, level):
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):
def _update_initial_state(self):
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):
self._update_initial_state()
......@@ -4,30 +4,33 @@ from .BaseWidget import BaseWidget
class NumStepper(BaseWidget):
def create_row(self):
map = self.setting.map
map_keys = list(map.keys())
map_data = self.setting.map or {}
map_keys = list(map_data.keys())
row = Adw.ActionRow(
title=self.setting.name,
subtitle=self.setting.help,
activatable=False
)
self.row = row
self.spin = Gtk.SpinButton(
valign=Gtk.Align.CENTER,
halign=Gtk.Align.CENTER,
)
lower, upper, step, digits = self._get_range_values(map_data)
adjustment = Gtk.Adjustment(
value=self.setting._get_backend_value(),
lower=map["lower"],
upper=map["upper"],
step_increment=map["step"],
value=lower,
lower=lower,
upper=upper,
step_increment=step,
)
self.spin.set_adjustment(adjustment)
self.spin.set_sensitive(bool(map_data))
if "digits" in map_keys:
self.spin.set_digits(map["digits"])
self.spin.set_digits(map_data["digits"])
control_box = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL,
......@@ -39,20 +42,36 @@ class NumStepper(BaseWidget):
row.add_suffix(control_box)
self.spin_handler_id = self.spin.connect("value-changed", self._on_num_changed)
self._update_reset_visibility()
return row
return self.row
def update_display(self):
if not self.setting.map:
return
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):
self.spin.set_value(float(current_value))
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):
selected_value = widget.get_value()
if self.spin.get_digits() == 0:
selected_value = int(selected_value)
self.setting._set_backend_value(selected_value)
self._update_reset_visibility()
......@@ -68,10 +87,24 @@ class NumStepper(BaseWidget):
self._update_reset_visibility()
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
self.reset_revealer.set_reveal_child(
current_value != default_value if default_value is not None
else False
)
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
class RadioChoiceWidget(BaseWidget):
def create_row(self):
main_box = Adw.PreferencesRow()
self.row = main_box
content_box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL,
......@@ -48,24 +49,48 @@ class RadioChoiceWidget(BaseWidget):
title_box.append(subtitle_label)
radio_container = Gtk.Box(
self.radio_container = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL,
spacing=8
)
content_box.append(radio_container)
content_box.append(self.radio_container)
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()
group = None
# Создаем радио-кнопки
for label, value in self.setting.map.items():
radio = Gtk.CheckButton(
label=label,
halign=Gtk.Align.START,
active=(value == current_value)
)
radio.add_css_class('selection-mode')
radio.add_css_class("selection-mode")
if group:
radio.set_group(group)
......@@ -74,15 +99,11 @@ class RadioChoiceWidget(BaseWidget):
handler_id = radio.connect("toggled", self._on_toggle, value)
self.radio_buttons[value] = (radio, handler_id)
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
self.radio_container.append(radio)
def update_display(self):
if not self.setting.map:
return
current_value = self.setting._get_backend_value()
for value, (radio, handler_id) in self.radio_buttons.items():
with GObject.signal_handler_block(radio, handler_id):
......@@ -108,6 +129,9 @@ class RadioChoiceWidget(BaseWidget):
self._update_reset_visibility()
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()
default_value = self.setting.default
......
......@@ -107,22 +107,8 @@ class ThemeChooserWidget(BaseWidget):
content_box.append(self.flowbox)
self.theme_cards = {}
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:
self.flowbox.select_child(flowbox_child)
self.handler_id = self.flowbox.connect(
"child-activated", self._on_theme_selected)
self.handler_id = self.flowbox.connect("child-activated", self._on_theme_selected)
self._build_cards()
self._update_reset_visibility()
return self.row
......@@ -173,6 +159,36 @@ class ThemeChooserWidget(BaseWidget):
card.append(card_inner)
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):
"""Create a colored preview box for the theme."""
preview = Gtk.Box(
......@@ -250,6 +266,11 @@ class ThemeChooserWidget(BaseWidget):
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):
value = child.value
self.setting._set_backend_value(value)
......
......@@ -9,6 +9,9 @@ from .FileChooser import FileChooser
from .ButtonWidget import ButtonWidget
from .InfoLabelWidget import InfoLabelWidget
from .InfoDictWidget import InfoDictWidget
from .InfoGraphWidget import InfoGraphWidget
from .InfoGraphLargeWidget import InfoGraphLargeWidget
from .InfoGraphMultiWidget import InfoGraphMultiWidget
from .DualListWidget import DualListWidget
from .ImageChooserWidget import ImageChooserWidget
from .DualImageChooserWidget import DualImageChooserWidget
......@@ -31,6 +34,9 @@ class WidgetFactory:
'button': ButtonWidget,
'info_label': InfoLabelWidget,
'info_dict': InfoDictWidget,
'info_graph': InfoGraphWidget,
'info_graph_large': InfoGraphLargeWidget,
'info_graph_multi': InfoGraphMultiWidget,
'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