Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package vdu_controls for openSUSE:Factory checked in at 2026-04-20 16:12:05 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/vdu_controls (Old) and /work/SRC/openSUSE:Factory/.vdu_controls.new.11940 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "vdu_controls" Mon Apr 20 16:12:05 2026 rev:19 rq:1348093 version:2.6.0 Changes: -------- --- /work/SRC/openSUSE:Factory/vdu_controls/vdu_controls.changes 2026-04-07 16:49:26.993882350 +0200 +++ /work/SRC/openSUSE:Factory/.vdu_controls.new.11940/vdu_controls.changes 2026-04-20 16:12:17.899625865 +0200 @@ -1,0 +2,14 @@ +Sun Apr 19 20:44:43 UTC 2026 - Michael Hamilton <[email protected]> + +- Version 2.6.0 + * Added laptop-panel support, see Setting option "laptop-panel-enabled". + Requires the commonly available "brightnessctr" command to be installed. + * Udev is used to detect laptop brightness events, such as up/down + function-keys and inactivity-dimming. + * The control-panel's icons/titles are now shortcuts to the relevant + Settings tabs. + * Fixed Settings text-input line-height on small screens. + * Cosmetic fixes to icons and spacing in the main panel layout. + * New recommended packages: brightnessctl, python3-pyudev + +------------------------------------------------------------------- Old: ---- vdu_controls-2.5.0.tar.gz New: ---- vdu_controls-2.6.0.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ vdu_controls.spec ++++++ --- /var/tmp/diff_new_pack.aEMouj/_old 2026-04-20 16:12:18.755661111 +0200 +++ /var/tmp/diff_new_pack.aEMouj/_new 2026-04-20 16:12:18.759661276 +0200 @@ -18,7 +18,7 @@ Name: vdu_controls -Version: 2.5.0 +Version: 2.6.0 Release: 0 Summary: Visual Display Unit virtual control panel License: GPL-3.0-or-later @@ -40,6 +40,8 @@ %endif Recommends: ddcutil-service Recommends: python3-pyserial +Recommends: python3-pyudev +Recommends: brightnessctl %endif %if 0%{?fedora_version} %define ext_man * @@ -49,6 +51,8 @@ Requires: python3 Requires: python3-qt5 Suggests: python3-pyserial +Suggests: python3-pyudev +Suggests: brightnessctl %endif %description ++++++ vdu_controls-2.5.0.tar.gz -> vdu_controls-2.6.0.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/vdu_controls-2.5.0/PKGBUILD new/vdu_controls-2.6.0/PKGBUILD --- old/vdu_controls-2.5.0/PKGBUILD 2026-04-06 22:33:45.000000000 +0200 +++ new/vdu_controls-2.6.0/PKGBUILD 2026-04-20 01:49:48.000000000 +0200 @@ -1,5 +1,5 @@ pkgname=vdu_controls -pkgver=2.5.0 +pkgver=2.6.0 pkgrel=1 pkgdesc="Visual Display Unit virtual control panel" arch=('i686' 'x86_64') diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/vdu_controls-2.5.0/README.md new/vdu_controls-2.6.0/README.md --- old/vdu_controls-2.5.0/README.md 2026-04-06 22:33:45.000000000 +0200 +++ new/vdu_controls-2.6.0/README.md 2026-04-20 01:49:48.000000000 +0200 @@ -9,15 +9,18 @@ > *scheduled-presets* and *ambient-light-control*. The relevant KDE 6 options > can > be found under ***System Settings -> System -> Energy Saving***. +> [!TIP] +> Laptop-Panels are supported in version 2.6 (see below). + Description ----------- -<img src="screen-shots/ambient-slider-example.png" alt="vdu_controls v2.5" width="580"> +<img src="screen-shots/ambient-slider-example.png" alt="vdu_controls v2.6" width="50%"> ``vdu_controls`` is a virtual control panel for external Visual Display Units (VDUs, monitors, displays). It supports displays connected via DisplayPort, -DVI, HDMI, or USB - but not built-in laptop panels (though laptop integration -is possible via plugin scripting; see below). +DVI, HDMI, USB, and built-in laptop-panels (laptop-panel integration +is provided by ``brightnessctrl`` for brightness only). A subset of controls is shown by default - these include brightness, contrast, and audio controls - with additional options available in the @@ -82,6 +85,15 @@ > Several language translations are provided, but with no apparent demand, > they > are currently unmaintained and will be updated on request. +#### Laptop-Panel brightness controls + +Starting with version 2.6, laptop panels are supported for brightness-only control. +is enabled, the widely available command line utility [brightnessctl](https://github.com/Hummer12007/brightnessctl) +is used to emulate DDC control of brightness. +Additionally, ``vdu_controls`` will react to laptop brightness-function-keys or +inactivity-dimming by using the ``python3-pyudev`` library to monitor udev +for _brightness_ events. + #### Technical background Historically, there was little need to frequently adjust display brightness. @@ -109,8 +121,7 @@ many different OEM DDC implementations and GPU drivers. `Vdu_controls` supports a _virtual-DDC plugin_ for interfacing to non DDC -displays, such as laptops. No complete plugins are currently available (a -sample incomplete template bash script is included). +displays. A sample script wrapper is included. Does adjusting a VDU affect its lifespan or health? --------------------------------------------------- @@ -358,9 +369,10 @@ * Christopher Laws ([claws](https://github.com/claws)) for the [BH1750 library](https://github.com/claws/BH1750) and [example build](https://github.com/claws/BH1750#example) (lux-metering). * Viktor Sharga ([ViktorSharga](https://github.com/ViktorSharga)) for assisting with UI enhancements. -* Plus others who have supplied feedback and suggestions. +* Mykyta Holuakha ([Hummer12007](https://github.com/Hummer12007)) for [brightnessctl](https://github.com/Hummer12007/brightnessctl) * E. Elvegård and G. Sjöstedt, "The Calculation of Illumination from Sun and Sky," _Illuminating Engineering_, Apr. 1940. [Illuminating Engineering Society, 100 Significant Papers](https://www.ies.org/research/publications/100-significant-papers/) +* Plus others who have supplied feedback and suggestions. Author ------ @@ -369,6 +381,14 @@ Version History --------------- +* 2.6.0 + * Added laptop-panel support, see Setting option "laptop-panel-enabled". + Requires the commonly available "brightnessctr" command to be installed. + * Udev is used to detect laptop brightness events, such as up/down function-keys and inactivity-dimming. + * The control-panel's icons/titles are now shortcuts to the relevant Settings tabs. + * Fixed _Settings_ text-input line-height on small screens. + * Cosmetic fixes to icons and spacing in the main panel layout. + * 2.5.0 * Visual refresh of the Main-panel. Inspired by [a recent fork](https://github.com/ViktorSharga/vdu_controls_vibecodedUI) by @ViktorSharga. diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/vdu_controls-2.5.0/docs/_build/man/vdu_controls.1 new/vdu_controls-2.6.0/docs/_build/man/vdu_controls.1 --- old/vdu_controls-2.5.0/docs/_build/man/vdu_controls.1 2026-04-06 22:33:45.000000000 +0200 +++ new/vdu_controls-2.6.0/docs/_build/man/vdu_controls.1 2026-04-20 01:49:48.000000000 +0200 @@ -1,5 +1,5 @@ .\" Man page generated from reStructuredText -.\" by the Docutils 0.22.3 manpage writer. +.\" by the Docutils 0.22.4 manpage writer. . . .nr rst2man-indent-level 0 @@ -28,9 +28,9 @@ .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]] .in \\n[rst2man-indent\\n[rst2man-indent-level]]u .. -.TH "VDU_CONTROLS" "1" "Apr 02, 2026" "" "vdu_controls" +.TH "VDU_CONTROLS" "1" "Apr 19, 2026" "" "vdu_controls" .SH NAME -vdu_controls \- vdu_controls 2.5.0 +vdu_controls \- vdu_controls 2.6.0 .SH VDU_CONTROLS - A DDC CONTROL PANEL FOR MONITORS .sp A control panel for DisplayPort, DVI, HDMI, or USB\-connected VDUs (\fIVisual Display Units\fP). @@ -51,6 +51,7 @@ [\-\-tray\-follows\-theme|\-\-no\-tray\-follows\-theme] [\-\-toolbar\-at\-top|\-no\-toolbar\-at\-top] [\-\-separate\-status\-bar|\-\-separate\-status\-bar] +[\-\-laptop\-panels|\-\-no\-laptop\-panels] [\-\-protect\-nvram|\-\-no\-protect\-nvram] [\-\-lux\-options|\-\-no\-lux\-options] [\-\-schedule|\-\-no\-schedule] [\-\-weather|\-\-no\-weather] @@ -130,6 +131,10 @@ separate the status\-bar from the toolbar \fB\-\-no\-separate\-status\-bar\fP is the default .TP +.B \-\-laptop\-panels|\-\-no\-laptop\-panels +allow laptop panels to be controlled +\fB\-\-no\-laptop\-panels\fP is the default +.TP .B \-\-protect\-nvram|\-\-no\-protect\-nvram alter options and defaults to minimize VDU NVRAM writes. .TP @@ -181,7 +186,7 @@ .INDENT 0.0 .TP .BI \-\-ddcutil\-emulator \ emulator\-path -additional command\-line ddcutil emulator for a laptop panel +additional command\-line ddcutil emulator for a special cases. .TP .B \-\-sleep\-multiplier set the default ddcutil sleep multiplier. @@ -412,6 +417,14 @@ .sp With this annotation, when ever \fIPicture Mode\fP is altered, vdu_controls will reload all configuration files and refresh all control values from the VDUs. +.SS Laptop\-Panel brightness control +.sp +Starting with version 2.6, laptop panels are supported for brightness\-only control. +When laptop support is enabled, the widely available command line utility \fBbrightnessctl\fP +is used to emulate DDC control of brightness (\%<https://\:github\:.com/\:Hummer12007/\:brightnessctl>). +Additionally, \fBvdu_controls\fP will react to laptop brightness\-function\-keys or +inactivity\-dimming by using the \fBpython3\-pyudev\fP library to monitor udev +for _brightness_ events. .SS DBUS ddcutil\-service .sp When available, \fBvdu_controls\fP defaults to interacting with VDUs via the DBUS \fBddcutil\-service\fP @@ -914,15 +927,6 @@ whether a tray or dock is differently themed. As a result the application includes several manual settings that can alter the tray/dock icon theming between colored, monochrome\-dark and monochrome\-light. -.SS Laptops -.sp -A laptop\(aqs builtin\-panel normally doesn\(aqt implement DDC and cannot be controlled -by \fBddcutil\fP or \fBddcutil\-service\fP\&. Laptop panel brightness is controlled -by a variety of methods that vary by vendor and hardware. If you have a laptop -where such adjustments can be scripted, you can use the \fB\-\-ddcutil\-emulator\fP -option and provide \fBvdu_controls\fP with a ddcutil\-like script for getting and -setting the panel brightness; then \fBvdu_controls\fP will treat the laptop panel -just like any other VDU. A template script is provided in the \fBsample\-scripts\fP\&. .SS Other concerns .sp The power\-supplies in some older VDUs may buzz/squeel audibly when the brightness is @@ -989,6 +993,8 @@ zypper install python3 python3\-qt5 noto\-sans\-math\-fonts noto\-sans\-symbols2\-fonts zypper install ddcutil zypper install libddcutil ddcutil\-service # optional, but recommended if available +zypper install brightnessctl # optional, needed for controlling laptop\-panels +zypper install python3\-udev # optional, needed for detecting brighntess changes on laptop\-panels .EE .UNINDENT .UNINDENT diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/vdu_controls-2.5.0/docs/_build/man/vdu_controls.1.html new/vdu_controls-2.6.0/docs/_build/man/vdu_controls.1.html --- old/vdu_controls-2.5.0/docs/_build/man/vdu_controls.1.html 2026-04-06 22:33:45.000000000 +0200 +++ new/vdu_controls-2.6.0/docs/_build/man/vdu_controls.1.html 2026-04-20 01:49:48.000000000 +0200 @@ -15,6 +15,7 @@ [--tray-follows-theme|--no-tray-follows-theme] [--toolbar-at-top|-no-toolbar-at-top] [--separate-status-bar|--separate-status-bar] + [--laptop-panels|--no-laptop-panels] [--protect-nvram|--no-protect-nvram] [--lux-options|--no-lux-options] [--schedule|--no-schedule] [--weather|--no-weather] @@ -66,6 +67,9 @@ --separate-status-bar|--no-separate-status-bar separate the status-bar from the toolbar ``--no-separate-status-bar`` is the default + --laptop-panels|--no-laptop-panels + allow laptop panels to be controlled + ``--no-laptop-panels`` is the default --protect-nvram|--no-protect-nvram alter options and defaults to minimize VDU NVRAM writes. --order-by-name|--no-order-by-name @@ -99,7 +103,7 @@ local latitude and longitude for triggering presets by solar elevation. --ddcutil-emulator emulator-path - additional command-line ddcutil emulator for a laptop panel + additional command-line ddcutil emulator for a special cases. --sleep-multiplier set the default ddcutil sleep multiplier. protocol reliability multiplier for ddcutil (typically 0.1 .. 2.0, default is 1.0) @@ -274,6 +278,16 @@ <p>With this annotation, when ever <em>Picture Mode</em> is altered, vdu_controls will reload all configuration files and refresh all control values from the VDUs.</p> +<h2 id="laptop-panel-brightness-control">Laptop-Panel brightness +control</h2> +<p>Starting with version 2.6, laptop panels are supported for +brightness-only control. When laptop support is enabled, the widely +available command line utility <code>brightnessctl</code> is used to +emulate DDC control of brightness +(https://github.com/Hummer12007/brightnessctl). Additionally, +<code>vdu_controls</code> will react to laptop brightness-function-keys +or inactivity-dimming by using the <code>python3-pyudev</code> library +to monitor udev for <em>brightness</em> events.</p> <h2 id="dbus-ddcutil-service">DBUS ddcutil-service</h2> <p>When available, <code>vdu_controls</code> defaults to interacting with VDUs via the DBUS <code>ddcutil-service</code> service rather than @@ -730,16 +744,6 @@ the application includes several manual settings that can alter the tray/dock icon theming between colored, monochrome-dark and monochrome-light.</p> -<h2 id="laptops">Laptops</h2> -<p>A laptop’s builtin-panel normally doesn’t implement DDC and cannot be -controlled by <code>ddcutil</code> or <code>ddcutil-service</code>. -Laptop panel brightness is controlled by a variety of methods that vary -by vendor and hardware. If you have a laptop where such adjustments can -be scripted, you can use the <code>--ddcutil-emulator</code> option and -provide <code>vdu_controls</code> with a ddcutil-like script for getting -and setting the panel brightness; then <code>vdu_controls</code> will -treat the laptop panel just like any other VDU. A template script is -provided in the <code>sample-scripts</code>.</p> <h2 id="other-concerns">Other concerns</h2> <p>The power-supplies in some older VDUs may buzz/squeel audibly when the brightness is turned way down. This may not be a major issue @@ -794,7 +798,9 @@ <p>Software::</p> <pre><code> zypper install python3 python3-qt5 noto-sans-math-fonts noto-sans-symbols2-fonts zypper install ddcutil - zypper install libddcutil ddcutil-service # optional, but recommended if available</code></pre> + zypper install libddcutil ddcutil-service # optional, but recommended if available + zypper install brightnessctl # optional, needed for controlling laptop-panels + zypper install python3-udev # optional, needed for detecting brighntess changes on laptop-panels</code></pre> <p>If you wish to use a serial-port lux metering device, the <code>pyserial</code> module is a runtime requirement.</p> <p>Get ddcutil working first. Check that the detect command detects your diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/vdu_controls-2.5.0/docs/conf.py new/vdu_controls-2.6.0/docs/conf.py --- old/vdu_controls-2.5.0/docs/conf.py 2026-04-06 22:33:45.000000000 +0200 +++ new/vdu_controls-2.6.0/docs/conf.py 2026-04-20 01:49:48.000000000 +0200 @@ -24,7 +24,7 @@ author = 'Michael Hamilton' # The full version, including alpha/beta/rc tags -release = '2.5.0' +release = '2.6.0' # -- General configuration --------------------------------------------------- diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/vdu_controls-2.5.0/icons/laptop.svg new/vdu_controls-2.6.0/icons/laptop.svg --- old/vdu_controls-2.5.0/icons/laptop.svg 1970-01-01 01:00:00.000000000 +0100 +++ new/vdu_controls-2.6.0/icons/laptop.svg 2026-04-20 01:49:48.000000000 +0200 @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright 2024 Michael Hamilton License Creative Commons - Attribution CC BY --> +<svg viewBox="0 0 24 24" width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"> + <style type="text/css" id="current-color-scheme"> .ColorScheme-Text { color:#232629; } </style> + <g class="ColorScheme-Text" stroke="currentColor" stroke-linejoin="round" stroke-linecap="round" stroke-width="2" transform=""> + <path fill="None" d="M 20 18 L 1 18 1 5 20 5 20 18 M 1 21 L 20 21"/> + </g> +</svg> \ No newline at end of file diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/vdu_controls-2.5.0/icons/vdu-power-on.svg new/vdu_controls-2.6.0/icons/vdu-power-on.svg --- old/vdu_controls-2.5.0/icons/vdu-power-on.svg 2026-04-06 22:33:45.000000000 +0200 +++ new/vdu_controls-2.6.0/icons/vdu-power-on.svg 2026-04-20 01:49:48.000000000 +0200 @@ -2,7 +2,7 @@ <!-- Copyright 2024 Michael Hamilton License Creative Commons - Attribution CC BY --> <svg viewBox="0 0 24 24" width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"> <style type="text/css" id="current-color-scheme"> .ColorScheme-Text { color:#232629; } </style> - <g class="ColorScheme-Text" stroke="currentColor" stroke-linecap="round" stroke-width="2" transform=""> + <g class="ColorScheme-Text" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" transform=""> <path fill="None" d="M14 12 A 5 5 0 1 0 20 12 M 17 11 L 17 16.5 M 9 20 L 1 20 1 5 20 5 20 8"/> </g> </svg> \ No newline at end of file diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/vdu_controls-2.5.0/icons/vdu_ambient.svg new/vdu_controls-2.6.0/icons/vdu_ambient.svg --- old/vdu_controls-2.5.0/icons/vdu_ambient.svg 2026-04-06 22:33:45.000000000 +0200 +++ new/vdu_controls-2.6.0/icons/vdu_ambient.svg 2026-04-20 01:49:48.000000000 +0200 @@ -2,7 +2,7 @@ <!-- Copyright 2024 Michael Hamilton License Creative Commons - Attribution CC BY --> <svg viewBox="0 0 24 24" width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"> <style type="text/css" id="current-color-scheme"> .ColorScheme-Text { color:#232629; } </style> - <g class="ColorScheme-Text" stroke="currentColor" stroke-linecap="round" stroke-width="2"> + <g class="ColorScheme-Text" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"> <path fill="none" d="M9 20 L1 20 1 5 20 5 20 7" /> <circle cx="17" cy="16" r="5" stroke="currentColor" fill="none" /> <rect x="11" y="21.5" width="1" height="1" rx="5" ry="5" stroke-width="1" fill="currentColor" /> diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/vdu_controls-2.5.0/icons/vdu_connected.svg new/vdu_controls-2.6.0/icons/vdu_connected.svg --- old/vdu_controls-2.5.0/icons/vdu_connected.svg 2026-04-06 22:33:45.000000000 +0200 +++ new/vdu_controls-2.6.0/icons/vdu_connected.svg 2026-04-20 01:49:48.000000000 +0200 @@ -2,7 +2,7 @@ <!-- Copyright 2024 Michael Hamilton License Creative Commons - Attribution CC BY --> <svg viewBox="0 0 24 24" width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"> <style type="text/css" id="current-color-scheme"> .ColorScheme-Text { color:#232629; } </style> - <g class="ColorScheme-Text" stroke="currentColor" stroke-linecap="round" stroke-width="2" transform=""> + <g class="ColorScheme-Text" stroke="currentColor" stroke-linejoin="round" stroke-linecap="round" stroke-width="2" transform=""> <path fill="None" d="M 20 18 L 1 18 1 5 20 5 20 18 M 6.5 21 L 15 21"/> </g> </svg> \ No newline at end of file diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/vdu_controls-2.5.0/sample-scripts/laptop-ddcutil-emulator.bash new/vdu_controls-2.6.0/sample-scripts/laptop-ddcutil-emulator.bash --- old/vdu_controls-2.5.0/sample-scripts/laptop-ddcutil-emulator.bash 2026-04-06 22:33:45.000000000 +0200 +++ new/vdu_controls-2.6.0/sample-scripts/laptop-ddcutil-emulator.bash 2026-04-20 01:49:48.000000000 +0200 @@ -9,19 +9,15 @@ # This template needs editing to create an implementation specific to # actual hardware, such as Intel or AMD driven laptop-panels. # -# The bash getvcp and setvcp functions need completing with what ever -# command line code is appropriate for getting and setting the brightness -# on the targeted laptop. The capabilities function can optionally be -# edited to reduce/increase the capabilities offered to vdu_controls. +# The script is currently coded to use the widely available brightnessctl +# utility which can get and set laptop panel brightness. # # The script needs to be executable, and can be tested on the command # line as follows: # # chmod u+x laptop-ddcutil-emulator.bash -# ./laptop-ddcutil-emulator.bash getvcp 10 12 +# ./laptop-ddcutil-emulator.bash getvcp 10 # ./laptop-ddcutil-emulator.bash setvcp 10 75 -# ./laptop-ddcutil-emulator.bash setvcp 12 60 -# ./laptop-ddcutil-emulator.bash setvcp 12 x3C # ./laptop-ddcutil-emulator.bash detect # ./laptop-ddcutil-emulator.bash capabilities # @@ -44,23 +40,17 @@ # See https://wiki.archlinux.org/title/Backlight function getvcp() { # get the brightness and/or contrast. - shift for vcp_code in "$@" do if [[ "$vcp_code" =~ ^[0-9a-zA-Z][0-9a-zA-Z]$ ]] then if [ "$vcp_code" == "10" ] then - # TODO Add code to get brightness - brightness=90 - max_brightness=100 - echo "VCP 10 C $brightness $max_brightness" - elif [ "$vcp_code" == "12" ] - then - # TODO Add code to get contrast - optional, only if using? - contrast=80 - max_contrast=100 - echo "VCP 12 C $contrast $max_contrast" + brightness=$(brightnessctl get) + max_brightness=$(brightnessctl max) + # For maximum compatibility with vdu_controls, represent brightness as a percentage + percent=$[100*brightness/max_brightness] + echo "VCP 10 C $percent 100" else echo "WARN: getvcp vcp-code $vcp_code is unsupported" 1>&2 exit 1 @@ -79,12 +69,7 @@ fi if [ "$vcp_code" == "10" ] then - # TODO Add code to set brightness - echo "do what ever changes brightness to $vcp_value" 2>&1 - elif [ "$vcp_code" == "12" ] - then - # TODO Add code to set contrast - optional, only if using - echo "do what ever changes contrast to $vcp_value" 2>&1 + brightnessctl set "$vcp_value%" else echo "ERROR: setvcp $vcp_code is unsupported" 1>&2 exit 1 @@ -106,7 +91,6 @@ Op Code: F3 (Capabilities Request) VCP Features: Feature: 10 (Brightness) - Feature: 12 (Contrast) Feature: FF (Dummy to finish) EOF exit 0 Binary files old/vdu_controls-2.5.0/screen-shots/ambient-slider-example.png and new/vdu_controls-2.6.0/screen-shots/ambient-slider-example.png differ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/vdu_controls-2.5.0/setup.cfg new/vdu_controls-2.6.0/setup.cfg --- old/vdu_controls-2.5.0/setup.cfg 2026-04-06 22:33:45.000000000 +0200 +++ new/vdu_controls-2.6.0/setup.cfg 2026-04-20 01:49:48.000000000 +0200 @@ -1,6 +1,6 @@ [metadata] name = vdu_controls-digitaltrails -version = 2.5.0 +version = 2.6.0 author = Michael Hamilton author_email = [email protected] description = A GUI for controlling Visual Display Units diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/vdu_controls-2.5.0/vdu_controls.py new/vdu_controls-2.6.0/vdu_controls.py --- old/vdu_controls-2.5.0/vdu_controls.py 2026-04-06 22:33:45.000000000 +0200 +++ new/vdu_controls-2.6.0/vdu_controls.py 2026-04-20 01:49:48.000000000 +0200 @@ -20,6 +20,7 @@ [--tray-follows-theme|--no-tray-follows-theme] [--toolbar-at-top|-no-toolbar-at-top] [--separate-status-bar|--separate-status-bar] + [--laptop-panels|--no-laptop-panels] [--protect-nvram|--no-protect-nvram] [--lux-options|--no-lux-options] [--schedule|--no-schedule] [--weather|--no-weather] @@ -74,6 +75,9 @@ --separate-status-bar|--no-separate-status-bar separate the status-bar from the toolbar ``--no-separate-status-bar`` is the default + --laptop-panels|--no-laptop-panels + allow laptop panels to be controlled + ``--no-laptop-panels`` is the default --protect-nvram|--no-protect-nvram alter options and defaults to minimize VDU NVRAM writes. --order-by-name|--no-order-by-name @@ -107,7 +111,7 @@ local latitude and longitude for triggering presets by solar elevation. --ddcutil-emulator emulator-path - additional command-line ddcutil emulator for a laptop panel + additional command-line ddcutil emulator for a special cases. --sleep-multiplier set the default ddcutil sleep multiplier. protocol reliability multiplier for ddcutil (typically 0.1 .. 2.0, default is 1.0) @@ -282,6 +286,16 @@ With this annotation, when ever *Picture Mode* is altered, vdu_controls will reload all configuration files and refresh all control values from the VDUs. +Laptop-Panel brightness control +------------------------------- + +Starting with version 2.6, laptop panels are supported for brightness-only control. +When laptop support is enabled, the widely available command line utility ``brightnessctl`` +is used to emulate DDC control of brightness (https://github.com/Hummer12007/brightnessctl). +Additionally, ``vdu_controls`` will react to laptop brightness-function-keys or +inactivity-dimming by using the ``python3-pyudev`` library to monitor udev +for _brightness_ events. + DBUS ddcutil-service -------------------- @@ -718,17 +732,6 @@ several manual settings that can alter the tray/dock icon theming between colored, monochrome-dark and monochrome-light. -Laptops -------- - -A laptop's builtin-panel normally doesn't implement DDC and cannot be controlled -by ``ddcutil`` or ``ddcutil-service``. Laptop panel brightness is controlled -by a variety of methods that vary by vendor and hardware. If you have a laptop -where such adjustments can be scripted, you can use the ``--ddcutil-emulator`` -option and provide ``vdu_controls`` with a ddcutil-like script for getting and -setting the panel brightness; then ``vdu_controls`` will treat the laptop panel -just like any other VDU. A template script is provided in the ``sample-scripts``. - Other concerns -------------- @@ -792,6 +795,8 @@ zypper install python3 python3-qt5 noto-sans-math-fonts noto-sans-symbols2-fonts zypper install ddcutil zypper install libddcutil ddcutil-service # optional, but recommended if available + zypper install brightnessctl # optional, needed for controlling laptop-panels + zypper install python3-udev # optional, needed for detecting brighntess changes on laptop-panels If you wish to use a serial-port lux metering device, the ``pyserial`` module is a runtime requirement. @@ -884,7 +889,6 @@ from __future__ import annotations import argparse -import base64 import configparser import glob import inspect @@ -933,25 +937,25 @@ from PyQt6 import QtCore, QtNetwork from PyQt6.QtCore import Qt, QCoreApplication, QThread, pyqtSignal, QProcess, QPoint, QObject, QEvent, \ QSettings, QSize, QTimer, QTranslator, QLocale, QT_TR_NOOP, QVariant, pyqtSlot, QMetaType, QDir, \ - QRegularExpression, QPointF, QRect + QRegularExpression, QPointF, QRect, QSocketNotifier, QMargins from PyQt6.QtDBus import QDBusConnection, QDBusInterface, QDBusMessage, QDBusArgument, QDBusVariant from PyQt6.QtGui import QAction, QShortcut, QPixmap, QIcon, QCursor, QImage, QPainter, QRegularExpressionValidator, \ QPalette, QGuiApplication, QColor, QValidator, QPen, QFont, QFontMetrics, QMouseEvent, QResizeEvent, QKeySequence, QPolygon, \ - QDoubleValidator, QScreen + QDoubleValidator from PyQt6.QtSvg import QSvgRenderer from PyQt6.QtSvgWidgets import QSvgWidget from PyQt6.QtWidgets import QApplication, QWidget, QVBoxLayout, QHBoxLayout, QSlider, QMessageBox, QLineEdit, QLabel, \ QSplashScreen, QPushButton, QComboBox, QSystemTrayIcon, QMenu, QStyle, QTextEdit, QDialog, QTabWidget, \ QCheckBox, QPlainTextEdit, QGridLayout, QSizePolicy, QMainWindow, QToolBar, QToolButton, QFileDialog, \ QWidgetItem, QScrollArea, QGroupBox, QFrame, QSplitter, QSpinBox, QDoubleSpinBox, QInputDialog, QStatusBar, \ - QSpacerItem, QListWidget, QListWidgetItem + QSpacerItem, QListWidget, QListWidgetItem, QLayout QT5_USE_HIGH_DPI_PIXMAPS = None QT5_QPAINTER_HIGH_QUALITY_ANTIALIASING = None elif qt_version == 5: # Covers all other values. from PyQt5 import QtCore, QtNetwork from PyQt5.QtCore import Qt, QCoreApplication, QThread, pyqtSignal, QProcess, QPoint, QObject, QEvent, \ QSettings, QSize, QTimer, QTranslator, QLocale, QT_TR_NOOP, QVariant, pyqtSlot, QMetaType, QDir, \ - QRegularExpression, QPointF, QRect + QRegularExpression, QPointF, QRect, QSocketNotifier, QMargins from PyQt5.QtDBus import QDBusConnection, QDBusInterface, QDBusMessage, QDBusArgument, QDBusVariant from PyQt5.QtGui import QPixmap, QIcon, QCursor, QImage, QPainter, QRegularExpressionValidator, \ QPalette, QGuiApplication, QColor, QValidator, QPen, QFont, QFontMetrics, QMouseEvent, QResizeEvent, QKeySequence, QPolygon, \ @@ -961,7 +965,7 @@ QSplashScreen, QPushButton, QComboBox, QSystemTrayIcon, QMenu, QStyle, QTextEdit, QDialog, QTabWidget, \ QCheckBox, QPlainTextEdit, QGridLayout, QSizePolicy, QAction, QMainWindow, QToolBar, QToolButton, QFileDialog, \ QWidgetItem, QScrollArea, QGroupBox, QFrame, QSplitter, QSpinBox, QDoubleSpinBox, QInputDialog, QStatusBar, QShortcut, \ - QSpacerItem, QListWidget, QListWidgetItem + QSpacerItem, QListWidget, QListWidgetItem, QLayout QT5_USE_HIGH_DPI_PIXMAPS = Qt.ApplicationAttribute.AA_UseHighDpiPixmaps QT5_QPAINTER_HIGH_QUALITY_ANTIALIASING = QPainter.RenderHint.HighQualityAntialiasing break @@ -980,7 +984,7 @@ APPNAME = "VDU Controls" -VDU_CONTROLS_VERSION = '2.5.0' +VDU_CONTROLS_VERSION = '2.6.0' VDU_CONTROLS_VERSION_TUPLE = tuple(int(i) for i in VDU_CONTROLS_VERSION.split('.')) assert sys.version_info >= (3, 8), f'{APPNAME} utilises python version 3.8 or greater (your python is {sys.version}).' @@ -1036,6 +1040,10 @@ return type_id.value if isinstance(type_id, Enum) else type_id # awfulness of enums in pyqt6 +def is_subwin_desktop() -> bool: + return os.environ.get('XDG_CURRENT_DESKTOP', default='unknown').lower() in ['gnome', 'cosmic'] + + def is_running_in_gui_thread() -> bool: return QThread.currentThread() == gui_thread @@ -1166,19 +1174,21 @@ </quote> """ +VDU_CONTROLS_DEVELOPER = os.getenv('VDU_CONTROLS_DEVELOPER', default="no") == 'yes' RELEASE_WELCOME = QT_TR_NOOP("Welcome to vdu_controls {}").format(VDU_CONTROLS_VERSION) RELEASE_NOTE = QT_TR_NOOP("Please read the online release notes:") RELEASE_ANNOUNCEMENT = """<h3>{WELCOME}</h3>{NOTE}<br/> <a href="https://github.com/digitaltrails/vdu_controls/releases/tag/v{VERSION}"> https://github.com/digitaltrails/vdu_controls/releases/tag/v{VERSION}</a> <br/>___________________________________________________________________________""" -RELEASE_INFO = QT_TR_NOOP('<b>Modernity</b>: Appearance Refresh. <span style="font-size: 50px;">🎉</span>"' - '<br/>Relocatable toolbar and status-bar - see Settings.') +RELEASE_INFO = QT_TR_NOOP('<b>Road Warrior: Support for Laptop-Panels</b><br/>' + '<br/>Laptop-panel support is optional - see Settings - ' + ' and requires the brightnessctl command and python3-udev library.') CURRENT_PRESET_NAME_FILE = CONFIG_DIR_PATH.joinpath('current_preset.txt') CUSTOM_TRAY_ICON_FILE = CONFIG_DIR_PATH.joinpath('tray_icon.svg') LOCALE_TRANSLATIONS_PATHS = [ - Path.cwd().joinpath('translations')] if os.getenv('VDU_CONTROLS_DEVELOPER', default="no") == 'yes' else [] + [ + Path.cwd().joinpath('translations')] if VDU_CONTROLS_DEVELOPER else [] + [ Path(CONFIG_DIR_PATH).joinpath('translations'), Path("/usr/share/vdu_controls/translations"), ] STANDARD_ICON_PATHS = (Path("/usr/share/vdu_controls/icons"), Path("/usr/share/icons/breeze/actions/24"), Path("/usr/share/icons"),) @@ -1317,16 +1327,26 @@ VDU_CONNECTED_ICON_SOURCE = b""" <svg viewBox="0 0 24 24" width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"> <style type="text/css" id="current-color-scheme"> .ColorScheme-Text { color:#232629; } </style> - <g class="ColorScheme-Text" stroke="currentColor" stroke-linecap="round" stroke-width="2" transform=""> + <g class="ColorScheme-Text" stroke="currentColor" stroke-linejoin="round" stroke-linecap="round" + stroke-width="2" transform=""> <path fill="None" d="M 20 18 L 1 18 1 5 20 5 20 18 M 6.5 21 L 15 21"/> </g> </svg> """ +PANEL_CONNECTED_ICON_SOURCE = b""" +<svg viewBox="0 0 24 24" width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"> + <style type="text/css" id="current-color-scheme"> .ColorScheme-Text { color:#232629; } </style> + <g class="ColorScheme-Text" stroke="currentColor" stroke-linejoin="round" stroke-linecap="round" stroke-width="2" transform=""> + <path fill="None" d="M 20 18 L 1 18 1 5 20 5 20 18 M 1 21 L 20 21"/> + </g> +</svg> +""" + VDU_POWER_ON_ICON_SOURCE = b""" <svg viewBox="0 0 24 24" width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"> <style type="text/css" id="current-color-scheme"> .ColorScheme-Text { color:#232629; } </style> - <g class="ColorScheme-Text" stroke="currentColor" stroke-linecap="round" stroke-width="2" transform=""> + <g class="ColorScheme-Text" stroke="currentColor" stroke-linejoin="round" stroke-linecap="round" stroke-width="2" transform=""> <path fill="None" d="M14 12 A 5 5 0 1 0 20 12 M 17 11 L 17 16.5 M 9 20 L 1 20 1 5 20 5 20 8"/> </g> </svg> @@ -1335,7 +1355,7 @@ AMBIENT_PANEL_ICON_SOURCE = b""" <svg viewBox="0 0 24 24" width="24" height="24" fill="none" xmlns="http://www.w3.org/2000/svg"> <style type="text/css" id="current-color-scheme"> .ColorScheme-Text { color:#232629; } </style> - <g class="ColorScheme-Text" stroke="currentColor" stroke-linecap="round" stroke-width="2"> + <g class="ColorScheme-Text" stroke="currentColor" stroke-linejoin="round" stroke-linecap="round" stroke-width="2"> <path fill="none" d="M9 20 L1 20 1 5 20 5 20 7" /> <circle cx="17" cy="16" r="5" stroke="currentColor" fill="none" /> <rect x="11" y="21.5" width="1" height="1" rx="5" ry="5" stroke-width="1" fill="currentColor" /> @@ -1506,6 +1526,21 @@ return create_pixmap_from_svg_bytes(FALLBACK_SPLASH_SVG, 256, 256) +def alter_margins(target: QWidget | QLayout, + left: int | None = None, top: int | None = None, right: int | None = None, bottom: int | None = None, + default: QStyle | None = None) -> None: + current = target.contentsMargins() + if left is None: + left = default.pixelMetric(QStyle.PixelMetric.PM_LayoutLeftMargin) if default else current.left() + if top is None: + top = default.pixelMetric(QStyle.PixelMetric.PM_LayoutTopMargin) if default else current.top() + if right is None: + right = default.pixelMetric(QStyle.PixelMetric.PM_LayoutRightMargin) if default else current.right() + if bottom is None: + bottom = default.pixelMetric(QStyle.PixelMetric.PM_LayoutBottomMargin) if default else current.bottom() + target.setContentsMargins(QMargins(left, top, right, bottom)) + + def clamp(v: int, min_v: int, max_v: int) -> int: return max(min(max_v, v), min_v) @@ -1625,13 +1660,11 @@ def ddcutil_version_info(self) -> Tuple[str, str]: return self.ddcutil_impl.get_interface_version_string(), self.ddcutil_impl.get_ddcutil_version_string() - def add_ddcutil_emulator(self, emulator_executable: str, common_args: List[str] | None = None): - log_info(f"add_ddcutil_emulator: {emulator_executable} {common_args}") + def add_ddcutil_emulator(self, emulator: DdcutilPanelImpl | DdcutilEmulatorImpl): try: - custom_imp = DdcutilEmulatorImpl(emulator_executable, common_args) - for attr in custom_imp.detect(1): + for attr in emulator.detect(1): log_info(f"add_ddcutil_emulator: VDU edid={attr.edid_txt}") - self.ddcutil_emulators_by_edid[attr.edid_txt] = custom_imp + self.ddcutil_emulators_by_edid[attr.edid_txt] = emulator except Exception as e: log_error(f"add_ddcutil_emulator exception: {e}") @@ -1671,8 +1704,9 @@ """Return a list of (vdu_number, desc) tuples.""" result_list = [] vdu_list = self.ddcutil_impl.detect(0) - for emulator_impl in self.ddcutil_emulators_by_edid.values(): - vdu_list += emulator_impl.detect(1) + log_info(f"dectecting using {len(self.ddcutil_emulators_by_edid)} emulators") + for emulator_impl in set(self.ddcutil_emulators_by_edid.values()): # Use set() to only use each emulator once. + vdu_list += emulator_impl.detect(0) # Going to get rid of anything that is not a-z A-Z 0-9 as potential rubbish rubbish = re.compile('[^a-zA-Z0-9]+') # This isn't efficient, it doesn't need to be, so I'm keeping re-defs close to where they are used. @@ -1694,8 +1728,8 @@ if candidate.strip() != '': possibly_unique = (model_name, candidate) if possibly_unique in key_prospects: # Not unique - it has already been encountered. - log_info(f"Ignoring non-unique key {possibly_unique[0]}_{possibly_unique[1]}" - f" - it matches displays {vdu_number} and {key_prospects[possibly_unique][0]}") + log_info(f"Ignoring non-unique key {possibly_unique=}" + f" - it matches display {vdu_number=} allready in {possibly_unique}") del key_prospects[possibly_unique] else: key_prospects[possibly_unique] = vdu_number, manufacturer @@ -1806,7 +1840,8 @@ class DdcEventType(Enum): # Has to correspond to what the service supports - UNKNOWN = -1 + UNKNOWN = -2 + LAPTOP_BRIGHTNESS_CHANGE = -1 DPMS_AWAKE = 0 DPMS_ASLEEP = 1 DISPLAY_CONNECTED = 2 @@ -2017,6 +2052,151 @@ super().__init__(common_args) self.ddcutil_exe = ddcutil_exe +class DdcutilPanelImpl: # Laptop/builtin panel + + def __init__(self, _: List[str] | None = None, callback: Callable | None = None): + self.include_leds = VDU_CONTROLS_DEVELOPER # Test using desktop controllable LEDs + self.brightness_vcp_code_int = int(BRIGHTNESS_VCP_CODE, 16) + self.ddcutil_access_lock = Lock() + self.brightnessctl_exe = 'brightnessctl' + self.max_brightness: Dict[str, int] = {} + version_check = self.__run__('-V').stdout.decode('utf-8') + log_info(f"{self.brightnessctl_exe} version {version_check}") + self.set_vcp_time: datetime = datetime.now() - timedelta(seconds=60) # Last time set_vcp was called + self.callback = callback + if self.callback: # --- udev setup --- + import pyudev # Don't make non-laptop users import this. + self.context = pyudev.Context() + self.monitor = pyudev.Monitor.from_netlink(self.context) + self.monitor.filter_by(subsystem='backlight') + self.monitor.start() # Start receiving events + + def _on_udev_event(event): + while True: + try: + dev = self.monitor.poll(0.1) + if dev is None: + break + except (BlockingIOError, pyudev.DeviceNotFoundError): + break + self.debounce_timer.start(50) # Debounce: restart timer + + def _invoke_callback(): + if datetime.now() - self.set_vcp_time > timedelta(seconds=1): + for edid_txt in self.max_brightness.keys(): + self.callback(edid_txt, DdcEventType.LAPTOP_BRIGHTNESS_CHANGE.value, 0) + + fd = self.monitor.fileno() # Get the file descriptor and create a QSocketNotifier + self.notifier = QSocketNotifier(fd, QSocketNotifier.Type.Read, None) + self.notifier.activated.connect(_on_udev_event) + self.debounce_timer = QTimer() + self.debounce_timer.setSingleShot(True) + self.debounce_timer.timeout.connect(_invoke_callback) + + def refresh_connection(self): + pass + + def set_sleep_multiplier(self, edid_txt: str, sleep_multiplier: float): + pass + + def set_vdu_specific_args(self, edid_txt: str, extra_args: List[str]): + pass + + def _get_max_brightness(self, edid_txt: str) -> int: + if not edid_txt in self.max_brightness: + self.max_brightness[edid_txt] = int(self.__run__("max").stdout) + return self.max_brightness[edid_txt] + + def __run__(self, *args) -> subprocess.CompletedProcess: + log_id = self.brightnessctl_exe + process_args = [self.brightnessctl_exe] + list(args) + try: + with self.ddcutil_access_lock: + now = time.time() + result = subprocess.run(process_args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True) + elapsed = time.time() - now + log_debug(f"subprocess result: success {log_id} [{result.args}] " + f"rc={result.returncode} elapsed={elapsed:.2f} " + f"stdout={result.stdout.decode('utf-8', errors='surrogateescape')}") if log_debug_enabled else None + except subprocess.SubprocessError as spe: + error_text = spe.stderr.decode('utf-8', errors='surrogateescape') + log_debug("subprocess result: error ", log_id, process_args, + f"stderr='{error_text}', exception={str(spe)}", trace=True) if log_debug_enabled else None + raise + return result + + def vcp_info(self) -> str: + return '' + + def get_ddcutil_version_string(self) -> str: + return '2.2.5' + + def get_interface_version_string(self) -> str: + return f"Command Line - {self.brightnessctl_exe}" + + def detect(self, _: int) -> List[Tuple[Any, ...]]: + results = [] + cmd_result = self.__run__('-m', 'i') + for item_number, line in enumerate(cmd_result.stdout.splitlines(), start=1): + parts = str(line, 'utf-8').split(',') + if len(parts) > 1 and (parts[1] == 'backlight') or (self.include_leds and parts[1] == 'leds'): + display_number = -item_number + usb_bus, usb_device = '', '' + manufacturer_id, model_name, product_code = 'Unknown', 'Panel', 'Unknown' + edid_txt = parts[0] + binary_sn = f"BSN#{edid_txt}".encode('utf-8') + serial_number = re.sub(r'[^A-Za-z0-9]', '_', parts[0]).title() + log_info(f"Detected panel {model_name=} {edid_txt=} detected") + vdu_attributes = DdcutilExeImpl.DetectedAttributes( + display_number, usb_bus, usb_device, + manufacturer_id, model_name, serial_number, product_code, edid_txt, binary_sn) + results.append(vdu_attributes) + return results + + def get_capabilities(self, _: str) -> Tuple[ + str, int, int, Dict[bytes, str], Dict[bytes, Tuple[bytes, str, Dict[bytes, str]]], str]: + capability_text = ('Model: AB_12345\n' + 'MCCS version: 2.2\n' + 'Commands:\n' + ' Op Code: 01 (VCP Request)\n' + ' Op Code: 02 (VCP Response)\n' + ' Op Code: 03 (VCP Set)\n' + ' Op Code: 07 (Timing Request)\n' + ' Op Code: 0C (Save Settings)\n' + ' Op Code: F3 (Capabilities Request)\n' + 'VCP Features:\n' + ' Feature: 10 (Brightness)\n' + ' Feature: FF (Dummy to finish)\n') + return '', 0, 0, {}, {}, capability_text + + def get_type(self, _: str, vcp_code_int: int) -> Tuple[bool, bool] | None: # edid_txt isn't currently used/supported + assert vcp_code_int == self.brightness_vcp_code_int # nothing else supported + return False, True + + def set_vcp(self, edid_txt: str, vcp_code_int: int, new_value_int: int) -> None: + assert vcp_code_int == self.brightness_vcp_code_int # nothing else supported + try: + new_value = f"{new_value_int * self._get_max_brightness(edid_txt) // 100}" + log_info(f"laptop set {new_value}") + self.__run__('set', '-d', edid_txt, new_value) + finally: + self.set_vcp_time = datetime.now() + + def get_vcp_values(self, edid_txt: str, vcp_code_int_list: List[int]) -> List[Tuple[int, int, int, str]]: + assert vcp_code_int_list[0] == self.brightness_vcp_code_int and len(vcp_code_int_list) == 1 + for attempt_count in range(DDCUTIL_RETRIES): + try: + brightness = int(self.__run__('get', '-d', edid_txt).stdout) + max_brightness = self._get_max_brightness(edid_txt) + percent = (100 * brightness) // max_brightness + log_info(f"Panel {brightness=} {max_brightness=} {percent=}") + return [(self.brightness_vcp_code_int, percent, 100, CONTINUOUS_TYPE)] + except (subprocess.SubprocessError, ValueError, DdcutilDisplayNotFound): + if attempt_count + 1 == DDCUTIL_RETRIES: # Don't log here, it creates too much noise in the logs + raise # Too many failures, pass the buck upstairs + time.sleep(attempt_count * 0.25) + raise ValueError(f"Exceeded {DDCUTIL_RETRIES} attempts to get vcp values.") + class DdcutilDBusImpl(QObject): RETURN_RAW_VALUES = 2 @@ -2295,8 +2475,8 @@ # implementations. Plus, the user might not be able to reset to factory for some of them? SUPPORT_ALL_VCP = False -BRIGHTNESS_VCP_CODE = BRIT = '10' -CONTRAST_VCP_CODE = CONT = '12' +BRIGHTNESS_VCP_CODE = BRIT = '10' # This is HEX +CONTRAST_VCP_CODE = CONT = '12' # Also HEX CON = CONTINUOUS_TYPE # Shorter abbreviation SNC = SIMPLE_NON_CONTINUOUS_TYPE CNC = COMPLEX_NON_CONTINUOUS_TYPE @@ -2620,6 +2800,8 @@ tip=QT_TR_NOOP('use the D-Bus ddcutil-server if available')) DBUS_EVENTS_ENABLED = _def(cname=QT_TR_NOOP('dbus-events-enabled'), default="yes", tip=QT_TR_NOOP('enable D-Bus ddcutil-server events'), requires='dbus-client-enabled') + LAPTOP_PANEL_ENABLED = _def(cname=QT_TR_NOOP('laptop-panel-enabled'), default="no", + tip=QT_TR_NOOP('use brightnessctl utility for laptop panel control')) SYSLOG_ENABLED = _def(cname=QT_TR_NOOP('syslog-enabled'), default="no", tip=QT_TR_NOOP('divert diagnostic output to the syslog')) DEBUG_ENABLED = _def(cname=QT_TR_NOOP('debug-enabled'), default="no", tip=QT_TR_NOOP('output extra debug information')) @@ -2970,7 +3152,7 @@ _async_setvcp_task: VduControllerAsyncSetter | None = None def __init__(self, vdu_number: str, vdu_model_name: str, serial_number: str, manufacturer: str, - default_config: VduControlsConfig, ddcutil: Ddcutil, + default_config: VduControlsConfig, ddcutil: Ddcutil, edit_config: Callable, vdu_exception_handler: Callable, remedy: int = 0) -> None: super().__init__() self.no_longer_in_use = False @@ -2983,6 +3165,7 @@ self.serial_number = serial_number self.manufacturer = manufacturer self.ddcutil = ddcutil + self.edit_config_callable = edit_config self.vdu_exception_handler = vdu_exception_handler def _handle_async_setvcp_exception(vcp_code: str, value: int, origin: VcpOrigin, e: VduException): @@ -3041,6 +3224,9 @@ if remedy == VduController.DISCARD_VDU: self.write_template_config_files() # Persist the discard + def edit_config(self): + self.edit_config_callable(self.config.config_name) + def write_template_config_files(self) -> None: """Write template config files to $HOME/.config/vdu_controls/""" config_name = self.vdu_stable_id @@ -3166,42 +3352,34 @@ class SubWinDialog(QDialog): # Fix for gnome: QDialog must be a subwindow, otherwise it will always stay on top of other windows. def __init__(self, parent: QWidget | None = None) -> None: - super().__init__(parent, Qt.WindowType.SubWindow) + # On gnome this allows the subwindow to surface properly, on others it may anoyingly keep + # the window on top - which is not always desirable. + super().__init__(parent, Qt.WindowType.SubWindow if is_subwin_desktop() else Qt.WindowType.Window) +class ThemedSvgWidget(QSvgWidget): -class IconLabel(QWidget): + def __init__(self, icon_source: bytes, width: int, height: int, parent: QWidget | None = None) -> None: + super().__init__(parent=parent) + self.icon_source = icon_source + self.setFixedSize(width, height) + self.load_from_icon_source(self.icon_source) - def __init__(self, icon_source: bytes, main_text: str, sub_text) -> None: - super().__init__() - layout = QHBoxLayout() - layout.setAlignment(Qt.AlignmentFlag.AlignTop) - self.setLayout(layout) - self.svg_icon: QSvgWidget | None = None + def load_from_icon_source(self, icon_source: bytes): self.icon_source = icon_source - svg_icon = QSvgWidget() - svg_icon.load(handle_theme(icon_source, polychrome_light_or_dark())) - svg_icon.setFixedSize(native_font_height(scaled=1.8), native_font_height(scaled=1.8)) - self.svg_icon = svg_icon - layout.addWidget(svg_icon) - self.label = QLabel(f"<span style='font-weight:bold;'>{main_text}<br/>" - f"<span style='font-size:{native_font_height(0.5)}px; font-weight:normal;'>{sub_text}</span>") - self.label.setTextFormat(Qt.TextFormat.RichText) - self.label.setWordWrap(True) - layout.addWidget(self.label) + self.load(handle_theme(self.icon_source, polychrome_light_or_dark())) def event(self, event: QEvent | None) -> bool: if event and event.type() == QEvent.Type.PaletteChange: # PalletChange happens after the new style sheet is in use. - self.svg_icon.load(handle_theme(self.icon_source, polychrome_light_or_dark())) + self.load_from_icon_source(self.icon_source) return super().event(event) - class StdButton(QPushButton): # Reduce some repetitiveness in the code def __init__(self, icon: QIcon | None = None, title: str = '', clicked: Callable | None = None, auto_default=True, - tip: str | None = None, flat: bool = False, margins: bool = True, icon_size: QSize | None = None): - super().__init__() + tip: str | None = None, flat: bool = False, margins: bool = True, icon_size: QSize | None = None, parent: QWidget | None = None) -> None: + super().__init__(parent=parent) self.setIcon(icon) if icon else None self.setIconSize(icon_size) if icon_size else None self.setText(title) if title else None @@ -3212,6 +3390,39 @@ self.setAutoDefault(auto_default) +class ThemedSvgButton(StdButton): + + def __init__(self, icon_source: bytes, title: str = '', clicked: Callable | None = None, auto_default=True, + tip: str | None = None, flat: bool = False, margins: bool = True, icon_size: QSize | None = None, parent: QWidget | None = None) -> None: + super().__init__(icon := create_icon_from_svg_bytes(icon_source), title, clicked, auto_default, tip, flat, margins, icon_size, parent) + self.icon_source = icon_source + + def event(self, event: QEvent | None) -> bool: + if event and event.type() == QEvent.Type.PaletteChange: # PalletChange happens after the new style sheet is in use. + self.setIcon(create_icon_from_svg_bytes(self.icon_source)) + return super().event(event) + + +class TitleButton(StdButton): + def __init__(self, icon_source: bytes, main_text: str, sub_text: str, clicked: Callable | None = None, parent: QWidget | None = None) -> None: + super().__init__(icon=None, title=None, clicked=clicked, flat=True, parent=parent) + layout = QHBoxLayout() + self.setLayout(layout) + layout.setAlignment(Qt.AlignmentFlag.AlignTop) + self.svg_icon = ThemedSvgWidget(icon_source, native_font_height(scaled=1.8), native_font_height(scaled=1.8), parent=self) + layout.addWidget(self.svg_icon) + self.label = QLabel(f"<span style='font-weight:bold;'>{main_text}<br/>" + f"<span style='font-size:{native_font_height(0.5)}px; font-weight:normal;'>{sub_text}</span>") + self.label.setTextFormat(Qt.TextFormat.RichText) + self.label.setWordWrap(True) + self.label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) # Prevent label from swollowing clicks + self.label.adjustSize() # Adjust down to actual text height before accessing its height + layout.addWidget(self.label) + self.setMinimumHeight(max(self.svg_icon.height(), self.label.height()) + # Avoids size issues if embedded in a layout + self.style().pixelMetric(QStyle.PixelMetric.PM_LayoutTopMargin) + + self.style().pixelMetric(QStyle.PixelMetric.PM_LayoutBottomMargin)) + + class SettingsDialog(SubWinDialog, DialogSingletonMixin): """ Application Settings Editor, edits a default global settings file, and a settings file for each VDU. @@ -3227,6 +3438,14 @@ def reconfigure_instance(vdu_config_list: List[VduControlsConfig]) -> None: SettingsDialog.get_instance().reconfigure(vdu_config_list) if SettingsDialog.exists() else None + @staticmethod + def edit_config(config_name: str) -> None: + if instance := SettingsDialog.get_instance(): + for tab_number, tab in enumerate(instance.editor_tab_list): + if tab.config_path == ConfIni.get_path(config_name): + instance.tabs_widget.setCurrentIndex(tab_number) + instance.make_visible() + def __init__(self, default_config: VduControlsConfig, vdu_config_list: List[VduControlsConfig], change_callback) -> None: super().__init__() self.setWindowTitle(tr('Settings')) @@ -3282,7 +3501,7 @@ self.setMinimumSize(npx(1024), npx(800)) self.reconfigure([default_config, *vdu_config_list]) self.make_visible() - + def status_message(self, message: str, msecs: int = 0): # Display a message on the visible tab. self.bottom_status_bar.showMessage(message, msecs) @@ -3506,8 +3725,7 @@ tooltip: str, related: str, requires: str) -> None: super().__init__(section_editor, option, section, tooltip) self.setLayout(widget_layout := QHBoxLayout()) - # Squish up, save space, stay closer to parent label - widget_layout.setContentsMargins(widget_layout.contentsMargins().left(), 0, widget_layout.contentsMargins().right(), 0) + alter_margins(widget_layout, top=0, bottom=0) # Squish up, save space, stay closer to parent label checkbox = QCheckBox(self.translate_option()) checkbox.setChecked(section_editor.ini_editable.getboolean(section, option)) @@ -3676,23 +3894,39 @@ text_label = QLabel(self.translate_option()) layout.addWidget(text_label) text_editor = QPlainTextEdit(section_editor.ini_editable[section][option]) + text_editor.setMinimumHeight(native_font_height(100)) + text_editor.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) def _text_changed() -> None: section_editor.ini_editable[section][option] = text_editor.toPlainText().strip() text_editor.textChanged.connect(_text_changed) - layout.addWidget(text_editor) + layout.addWidget(text_editor, stretch=1) self.text_editor = text_editor def reset(self) -> None: self.text_editor.setPlainText(self.section_editor.ini_before[self.section][self.option]) -class SettingsEditorTextWidget(SettingsEditorLongTextWidget): +class SettingsEditorTextWidget(SettingsEditorFieldBase): def __init__(self, section_editor: SettingsEditorTab, option: str, section: str, tooltip: str) -> None: super().__init__(section_editor, option, section, tooltip) - self.setMaximumHeight(native_font_height(scaled=3)) + layout = QVBoxLayout() + self.setLayout(layout) + text_label = QLabel(self.translate_option()) + layout.addWidget(text_label) + text_editor = QLineEdit(section_editor.ini_editable[section][option]) + + def _text_changed() -> None: + section_editor.ini_editable[section][option] = text_editor.text().strip() + + text_editor.textChanged.connect(_text_changed) + layout.addWidget(text_editor) + self.text_editor = text_editor + + def reset(self) -> None: + self.text_editor.setText(self.section_editor.ini_before[self.section][self.option]) class SettingsEditorPathValidator(QValidator): @@ -3826,14 +4060,13 @@ super().__init__(controller, vcp_capability) layout = QHBoxLayout() self.setLayout(layout) - self.svg_icon: QSvgWidget | None = None + self.svg_icon: ThemedSvgWidget | None = None self.setToolTip(tr(vcp_capability.name)) self.setToolTipDuration(TOOLTIP_DURATION_MSEC) if (vcp_capability.vcp_code in SUPPORTED_VCP_BY_CODE and SUPPORTED_VCP_BY_CODE[vcp_capability.vcp_code].icon_source is not None): - svg_icon = QSvgWidget() - svg_icon.load(handle_theme(SUPPORTED_VCP_BY_CODE[vcp_capability.vcp_code].icon_source, polychrome_light_or_dark())) - svg_icon.setFixedSize(native_font_height(scaled=1.8), native_font_height(scaled=1.8)) + svg_icon = ThemedSvgWidget(SUPPORTED_VCP_BY_CODE[vcp_capability.vcp_code].icon_source, + native_font_height(scaled=1.8), native_font_height(scaled=1.8)) self.svg_icon = svg_icon layout.addWidget(svg_icon) else: @@ -3886,14 +4119,6 @@ if self.current_value is not None: # Copy the internally cached current value onto the GUI view. self.slider.setValue(clamp(int(self.current_value), self.slider.minimum(), self.slider.maximum())) - def event(self, event: QEvent | None) -> bool: - if event and event.type() == QEvent.Type.PaletteChange: # PalletChange happens after the new style sheet is in use. - icon_source = SUPPORTED_VCP_BY_CODE[self.vcp_capability.vcp_code].icon_source - if icon_source is not None: - assert self.svg_icon is not None # Because it must have been loaded from source earlier - self.svg_icon.load(handle_theme(icon_source, polychrome_light_or_dark())) - return super().event(event) - class VduControlComboBox(VduControlBase): """GUI control for a DDC non-continuously variable attribute, one that has a list of choices.""" @@ -3961,12 +4186,21 @@ def __init__(self, controller: VduController, vdu_exception_handler: Callable) -> None: super().__init__() - layout = QVBoxLayout() - create_icon_from_svg_bytes(VDU_CONNECTED_ICON_SOURCE) - self.label = IconLabel(VDU_CONNECTED_ICON_SOURCE, - controller.get_vdu_preferred_name(), tr("Monitor {}".format(controller.vdu_number))) - layout.addWidget(self.label) self.controller: VduController = controller + layout = QVBoxLayout() + alter_margins(layout, top=0, bottom=0, default=self.style()) + if int(controller.vdu_number) < 1: + self.title_button = TitleButton(PANEL_CONNECTED_ICON_SOURCE, + controller.get_vdu_preferred_name(), + tr("Panel {}".format(-int(controller.vdu_number))), + clicked=controller.edit_config) + else: + self.title_button = TitleButton(VDU_CONNECTED_ICON_SOURCE, + controller.get_vdu_preferred_name(), + tr("Monitor {}".format(controller.vdu_number)), + clicked=controller.edit_config) + layout.addWidget(self.title_button, alignment=Qt.AlignmentFlag.AlignTop) # other params fix Qt5 theme changes + self.vcp_controls: List[VduControlBase] = [] self.vdu_exception_handler = vdu_exception_handler @@ -4033,9 +4267,14 @@ def update_stats(self): name, sid = self.controller.get_vdu_preferred_name(), self.controller.vdu_stable_id - self.label.setToolTip(tr("{}\nSet-VCP writes: {}\nMonitor {}").format(sid if id == name else f"{name}\n({sid})", - self.controller.get_write_count(), - self.controller.vdu_number)) + title_txt = sid if id == name else f"{name}\n({sid})" + writes_txt = tr("Set-VCP writes: {}").format(self.controller.get_write_count()) + if (disp_number := int(self.controller.vdu_number)) >= 0: + disp_numumber_txt = tr("Monitor {}").format(disp_number) + else: + disp_numumber_txt = tr("Panel {}").format(-disp_number) + click_txt = tr("(Click for Settings)") + self.title_button.setToolTip(f"{title_txt}\n{writes_txt}\n{disp_numumber_txt}\n{click_txt}") class Preset: @@ -4497,8 +4736,7 @@ item.widget().deleteLater() controllers_layout = QVBoxLayout() controllers_layout.setSpacing(npx(5)) - cl_margins = controllers_layout.contentsMargins() - controllers_layout.setContentsMargins(cl_margins.left(), npx(5), cl_margins.right(), npx(5)) + alter_margins(controllers_layout, top=npx(5), bottom=npx(5)) self.setLayout(controllers_layout) warnings_enabled = main_config.is_set(ConfOpt.WARNINGS_ENABLED) @@ -4516,9 +4754,9 @@ controller.get_vdu_preferred_name()), info=tr('The monitor will be omitted from the control panel.')).exec() - controllers_layout.addStretch(0) for control in extra_controls: - controllers_layout.addWidget(control, 0, Qt.AlignmentFlag.AlignBottom) + controllers_layout.addWidget(control) + controllers_layout.addStretch(0) if len(self.vdu_control_panels) == 0: no_vdu_widget = QWidget() @@ -4588,7 +4826,7 @@ def status_message(self, message: str, timeout: int): if message.strip(): # Only non-empty messages, ignore blank messages, they're just clearing the status bar. - self.message_history.append(f"\n{datetime.now().strftime("%H:%M:%S")}{MESSAGE_SYMBOL} {message}") + self.message_history.append(f"\n{datetime.now().strftime('%H:%M:%S')}{MESSAGE_SYMBOL} {message}") self.message_history = self.message_history[-9:] if self.main_controller.main_config.is_set(ConfOpt.SEPARATE_STATUS_BAR): self.main_controller.main_window.statusBar().showMessage(message, timeout) @@ -4825,8 +5063,7 @@ self.preset = preset line_layout = QHBoxLayout() line_layout.setSpacing(0) - ll_margins = line_layout.contentsMargins() - line_layout.setContentsMargins(ll_margins.left(), 0, ll_margins.right(), npx(1)) # Why? + alter_margins(line_layout, top=0, bottom=npx(1)) # Why? self.setLayout(line_layout) self.preset_name_button = PresetActivationButton(preset) @@ -7752,7 +7989,7 @@ if self.main_controller.main_config.get_location() is None: MBox(MIcon.Critical, msg=tr("Cannot configure a solar lux calculator, no location is defined."), info=tr("Please set a location in the main Settings-Dialog.")).exec() - self.main_controller.edit_config(tab_number=0) + self.main_controller.edit_config(self.main_controller.main_config.config_name) return False MBox(MIcon.Information, msg=tr("Semi-automatic lux adjustment: quick start instructions.\n" @@ -7887,8 +8124,8 @@ LuxDialog.get_instance().close() else: LuxDialog.invoke(self.main_controller) - - self.lux_slider.status_icon_pressed_qtsignal.connect(_toggle_lux_dialog) + self.lux_slider.title_button_pressed_qtsignal.connect(_toggle_lux_dialog) + self.lux_slider.status_icon_pressed_qtsignal.connect(self.adjust_brightness_now) return self.lux_slider def get_lux_zone(self) -> LuxZone | None: @@ -8093,6 +8330,7 @@ new_lux_value_qtsignal = pyqtSignal(int) status_icon_changed_qtsignal = pyqtSignal() status_icon_pressed_qtsignal = pyqtSignal() + title_button_pressed_qtsignal = pyqtSignal() def __init__(self, controller: LuxAutoController) -> None: super().__init__() @@ -8106,32 +8344,32 @@ LuxZone(tr("Subdued"), LUX_SUBDUED_SVG, 15, 100, 20, column_span=3), LuxZone(tr("Dark"), LUX_DARK_SVG, 0, 15, 2, column_span=4), ] self.current_value = 10_000 - - self.status_icon = StdButton(icon_size=QSize(native_font_height(scaled=1.8), native_font_height(scaled=1.8)), flat=True, - clicked=self.status_icon_pressed_qtsignal) + self.status_icon = ThemedSvgWidget(self.zones[0].icon_svg, native_font_height(scaled=2.0), native_font_height(scaled=2.0), self) self.current_name: str | None = None self.current_zone: LuxZone | None = None top_layout = QVBoxLayout() self.setLayout(top_layout) top_layout.setSpacing(0) - tl_margins = top_layout.contentsMargins() - top_layout.setContentsMargins(tl_margins.left(), 0, tl_margins.right(), 0) - - label = IconLabel(AMBIENT_PANEL_ICON_SOURCE, tr("Ambient Light Level"), tr("lux")) - label.setToolTip(tr("Set the ambient light level to adjust all monitors.")) - top_layout.addWidget(label, - alignment=Qt.AlignmentFlag.AlignBottom) + alter_margins(top_layout, top=0, bottom=0, default=self.style()) + label = TitleButton(AMBIENT_PANEL_ICON_SOURCE, tr("Ambient Light Level"), tr("lux"), + clicked=self.title_button_pressed_qtsignal) + label.setToolTip(tr("Ambient light level control for adjusting all monitors.\n(Click for Light-Meter Dialog)")) + top_layout.addWidget(label, stretch=0, alignment=Qt.AlignmentFlag.AlignTop) input_panel = QWidget() input_panel_layout = QHBoxLayout() + alter_margins(input_panel_layout, top=0, bottom=0, default=self.style()) input_panel.setLayout(input_panel_layout) input_panel_layout.addWidget(self.status_icon) - lux_slider_panel = QWidget() - lux_slider_panel_layout = QGridLayout() - lux_slider_panel.setLayout(lux_slider_panel_layout) + slider_panel = QWidget() + slider_panel_layout = QGridLayout() + slider_panel.setLayout(slider_panel_layout) + slider_panel_layout.setSpacing(0) + # Move the slider up a little to line up with left and right elements + alter_margins(slider_panel_layout, top=0, bottom=slider_panel_layout.contentsMargins().bottom(), default=self.style()) self.slider = ClickableSlider() self.slider.setToolTip(tr("Ambient light level input (lux value)")) @@ -8144,16 +8382,16 @@ self.slider.setTickPosition(QSlider.TickPosition.TicksBelow) self.slider.setOrientation(Qt.Orientation.Horizontal) # type: ignore self.slider.setTracking(False) # Don't rewrite the ddc value too often - not sure of the implications - lux_slider_panel_layout.addWidget(self.slider, 1, 0, 1, 15, alignment=Qt.AlignmentFlag.AlignTop) + slider_panel_layout.addWidget(self.slider, 1, 0, 1, 15, alignment=Qt.AlignmentFlag.AlignTop) # A hacky way to get custom labels without redefining paint for col_num, span, value in ((0, 3, 1), (3, 3, 10), (6, 3, 100), (9, 3, 1000), (12, 3, 10000), (14, 1, 100000)): - log10_button = QLabel(f"{value:2d}") + log10_label = QLabel(f"{value:2d}") app_font = QApplication.font() - log10_button.setFont(QFont(app_font.family(), round(app_font.pointSize() * .66), QFont.Weight.Normal)) - lux_slider_panel_layout.addWidget(log10_button, 2, col_num, 1, span, alignment=Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop) + log10_label.setFont(QFont(app_font.family(), round(app_font.pointSize() * .66), QFont.Weight.Normal)) + slider_panel_layout.addWidget(log10_label, 2, col_num, 1, span, alignment=Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop) - input_panel_layout.addWidget(lux_slider_panel, stretch=100) + input_panel_layout.addWidget(slider_panel, stretch=100) self.lux_input_field = QSpinBox() self.lux_input_field.setLineEdit(LineEditAll()) @@ -8164,7 +8402,8 @@ self.lux_input_field.setValue(self.current_value) input_panel_layout.addWidget(self.lux_input_field) - top_layout.addWidget(input_panel, alignment=Qt.AlignmentFlag.AlignTop) + top_layout.addWidget(input_panel, stretch=0, alignment=Qt.AlignmentFlag.AlignTop) + top_layout.addStretch(0) def _lux_slider_change(new_value: int) -> None: actual_value = round(10 ** (new_value / 1000)) @@ -8193,10 +8432,10 @@ log10_icon_size = QSize(native_font_height(scaled=1), native_font_height(scaled=1)) self.label_map: Dict[StdButton, bytes] = {} for zone in reversed(self.zones): - log10_button = StdButton(icon=create_icon_from_svg_bytes(zone.icon_svg), icon_size=log10_icon_size, - clicked=partial(self.lux_input_field.setValue, zone.icon_svg_lux), flat=True, tip=zone.name) - lux_slider_panel_layout.addWidget(log10_button, 0, col, 1, zone.column_span, alignment=Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignHCenter) - self.label_map[log10_button] = zone.icon_svg + zone_button = ThemedSvgButton(zone.icon_svg, icon_size=log10_icon_size, + clicked=partial(self.lux_input_field.setValue, zone.icon_svg_lux), flat=True, tip=zone.name) + slider_panel_layout.addWidget(zone_button, 0, col, 1, zone.column_span, alignment=Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignHCenter) + self.label_map[zone_button] = zone.icon_svg col += zone.column_span self.set_current_value(round(controller.lux_meter.get_value()) if controller.lux_meter else 1000) # don't trigger side-effects. @@ -8213,8 +8452,8 @@ if zone.min_lux < value <= zone.max_lux: if self.current_zone != zone: self.current_zone = zone - self.status_icon.setIcon(create_icon_from_svg_bytes(zone.icon_svg)) - self.status_icon.setToolTip(tr("Open/Close Light-Meter Dialog")) + self.status_icon.load_from_icon_source(zone.icon_svg) + self.status_icon.setToolTip(zone.name) icon_changed = True self.current_value = max(1, value) # restrict to non-negative and something valid for log10 if source != self.slider: @@ -8234,14 +8473,6 @@ if icon_changed: self.status_icon_changed_qtsignal.emit() - def event(self, event: QEvent | None) -> bool: - if event and event.type() == QEvent.Type.PaletteChange: # PalletChange happens after the new style sheet is in use. - if self.current_zone: - self.status_icon.setIcon(create_icon_from_svg_bytes(self.current_zone.icon_svg)) - for slider_button, svg_bytes in self.label_map.items(): - slider_button.setIcon(create_icon_from_svg_bytes(svg_bytes)) - return super().event(event) - class GreyScaleDialog(SubWinDialog): """Creates a dialog with a grey scale VDU calibration image. Non-model. Have as many as you like - one per VDU.""" @@ -8516,13 +8747,19 @@ if self.main_config.is_set(ConfOpt.DBUS_CLIENT_ENABLED) and self.main_config.is_set(ConfOpt.DBUS_EVENTS_ENABLED): def _vdu_connectivity_changed_callback(edid_encoded: str, event_type: int, flags: int): + values_only = False if not DdcEventType.UNKNOWN.value <= event_type <= DdcEventType.DISPLAY_DISCONNECTED.value: log_warning(f"Connected VDUs event - unknown {event_type=} treating as DPMS_UNKNOWN.") event_type = DdcEventType.UNKNOWN.value - log_info(f"Connected VDUs event {DdcEventType(event_type)} {flags=} {edid_encoded:.30}...") if event_type == DdcEventType.DPMS_ASLEEP.value: + log_info(f"Connected VDUs event {DdcEventType(event_type)} {flags=} {edid_encoded:.30}...") return # Don't do anything, the VDUs are just asleep. - self.start_refresh(external_event=True) + elif event_type == DdcEventType.LAPTOP_BRIGHTNESS_CHANGE.value: + log_info(f"Laptop event {edid_encoded:.30}...") + values_only = True + # self.lux_auto_controller.set_auto(False) - we don't do this for any other manual change - so do nothing? + # could do something specific here - but the following refresh will cover it. + self.start_refresh(external_event=True, values_only=values_only) change_handler = _vdu_connectivity_changed_callback log_debug("Enabled callback for VDU-connectivity-change D-Bus signals") @@ -8533,9 +8770,17 @@ self.ddcutil = Ddcutil(common_args=self.main_config.get_ddcutil_extra_args(), prefer_dbus_client=self.main_config.is_set(ConfOpt.DBUS_CLIENT_ENABLED), connected_vdus_changed_callback=change_handler) + if self.main_config.is_set(ConfOpt.LAPTOP_PANEL_ENABLED): + try: + self.ddcutil.add_ddcutil_emulator(DdcutilPanelImpl(callback=change_handler)) + except Exception as e: + MBox(MIcon.Critical, msg=tr('Laptop Support: brightessctrl command failed'), + info='Check that brightessctrl is installed and is working.', details=str(e)).exec() if emulator := self.main_config.ini_content.get(ConfOpt.DDCUTIL_EMULATOR.conf_section, ConfOpt.DDCUTIL_EMULATOR.conf_name, fallback=None): - self.ddcutil.add_ddcutil_emulator(emulator, common_args=self.main_config.get_ddcutil_extra_args()) + common_args = self.main_config.get_ddcutil_extra_args() + log_info(f"add_ddcutil_emulator: {emulator} {common_args}") + self.ddcutil.add_ddcutil_emulator(DdcutilEmulatorImpl(emulator, common_args)) except (subprocess.SubprocessError, ValueError, re.error, OSError, DdcutilServiceNotFound) as e: self.main_window.show_no_controllers_error_dialog(e) @@ -8575,7 +8820,7 @@ while True: try: controller = VduController(vdu_number, model_name, vdu_serial, manufacturer, self.main_config, - self.ddcutil, main_panel_error_handler, VduController.NORMAL_VDU) + self.ddcutil, self.edit_config, main_panel_error_handler, VduController.NORMAL_VDU) except (subprocess.SubprocessError, ValueError, re.error, OSError, DdcutilDisplayNotFound) as e: log_error(f"Problem creating controller for {vdu_number=} {model_name=} {vdu_serial=} exception={e}", trace=True) @@ -8584,7 +8829,7 @@ time.sleep(1.0) # Slow things down in case something is wrong with the GUI or VDU interactions. continue # Loop and retry as a normal VDU controller = VduController(vdu_number, model_name, vdu_serial, manufacturer, self.main_config, - self.ddcutil, main_panel_error_handler, remedy) + self.ddcutil, self.edit_config, main_panel_error_handler, remedy) controller.write_template_config_files() break # Normally expect to just pass through the loop once if controller is not None: @@ -8613,9 +8858,9 @@ log_info("Reconfiguring due to settings change.") self.configure_application() - def edit_config(self, tab_number: int | None = None) -> None: + def edit_config(self, config_name: str | None = None) -> None: SettingsDialog.invoke(self.main_config, self.get_vdu_configs(), self.settings_changed) - SettingsDialog.get_instance().tabs_widget.setCurrentIndex(tab_number) if tab_number is not None else None + SettingsDialog.edit_config(config_name if config_name else self.main_config.config_name) def show_presets_dialog(self, preset: Preset | None = None) -> None: PresetsDialog.invoke(self, self.main_config) @@ -8642,7 +8887,7 @@ if self.lux_auto_controller and self.lux_auto_controller.is_auto_enabled(): self.lux_auto_controller.adjust_brightness_now() - def start_refresh(self, external_event: bool = False) -> None: + def start_refresh(self, external_event: bool = False, values_only: bool = False) -> None: def _update_from_vdu(worker: WorkerThread) -> None: if self.ddcutil is not None: @@ -8650,10 +8895,13 @@ if acquired_lock: # If acquired_lock is not None, then we have successfully acquired the lock. log_debug(f"_update_from_vdu: holding application_lock") if log_debug_enabled else None try: - log_info(f"Refresh commences: {external_event=}") if log_debug_enabled else None - self.ddcutil.refresh_connection() - self.detected_vdu_list = self.ddcutil.detect_vdus() - self.restore_vdu_initialization_presets() + log_info(f"Refresh commences: {external_event=} {values_only=}") if log_debug_enabled else None + if values_only: + self.detected_vdu_list = self.ddcutil.detect_vdus() # TODO Not sure why this is necessary. + else: + self.ddcutil.refresh_connection() + self.detected_vdu_list = self.ddcutil.detect_vdus() + self.restore_vdu_initialization_presets() for control_panel in self.main_window.get_main_panel().vdu_control_panels.values(): if control_panel.controller.get_full_id() in self.detected_vdu_list: control_panel.refresh_from_vdu() @@ -8673,23 +8921,24 @@ return # in this case, this means the worker never started anything try: # No need for locking in here - running in the GUI thread effectively single threads the operation. assert self.refresh_data_task is not None and is_running_in_gui_thread() - log_debug(f"Refresh - update UI view {external_event=}") if log_debug_enabled else None + log_debug(f"Refresh - update UI view {external_event=} {values_only=}") if log_debug_enabled else None main_panel = self.main_window.get_main_panel() if self.refresh_data_task.vdu_exception is not None: log_debug(f"Refresh - update UI view - exception {self.refresh_data_task.vdu_exception} {external_event=}") if not external_event: main_panel.show_vdu_exception(self.refresh_data_task.vdu_exception, can_retry=False) - if len(self.detected_vdu_list) == 0 or self.detected_vdu_list != self.previously_detected_vdu_list or ( - external_event and False): - log_info(f"Reconfiguring: detected={self.detected_vdu_list} previously={self.previously_detected_vdu_list}") - self.configure_application(check_schedule=False) # May cause a further refresh? - self.previously_detected_vdu_list = self.detected_vdu_list - ScheduleWorker.check() # immediately active the currently applicable preset - if self.lux_auto_controller: - if LuxDialog.exists(): - LuxDialog.get_instance().reconfigure() # Incase the number of connected monitors has changed. - if self.lux_auto_controller.is_auto_enabled(): - self.lux_auto_controller.adjust_brightness_now() + if not values_only: + if len(self.detected_vdu_list) == 0 or self.detected_vdu_list != self.previously_detected_vdu_list or ( + external_event and False): + log_info(f"Reconfiguring: detected={self.detected_vdu_list} previously={self.previously_detected_vdu_list}") + self.configure_application(check_schedule=False) # May cause a further refresh? + self.previously_detected_vdu_list = self.detected_vdu_list + ScheduleWorker.check() # immediately active the currently applicable preset + if self.lux_auto_controller: + if LuxDialog.exists(): + LuxDialog.get_instance().reconfigure() # Incase the number of connected monitors has changed. + if self.lux_auto_controller.is_auto_enabled(): + self.lux_auto_controller.adjust_brightness_now() finally: self.main_window.indicate_busy(False) @@ -9120,6 +9369,8 @@ global gui_thread app = QApplication.instance() assert app + if os.getenv('VDU_CONTROLS_DEBUG_LAYOUT', default='no') == 'yes': + app.setStyleSheet("QWidget { border: 1px solid red; margin: 1px; padding: 1px; }") gui_thread = app.thread() self.main_controller: VduAppController = main_controller self.setObjectName('main_window') @@ -9161,6 +9412,10 @@ quit_action=self.quit_app, hide_shortcuts=self.hide_shortcuts, parent=self) + # Don't do this - it creates a titlebar inside the application + #self.app_context_menu.setTitle("VDU Controls ") # Populate titlebar-menu (if it's enabled for Plasma Titlebars). + #self.menuBar().addMenu(self.app_context_menu) # TODO - make a proper menu - this will be a submenu. + splash_pixmap = get_splash_image() splash = QSplashScreen( splash_pixmap.scaledToWidth(native_font_height(scaled=26)).scaledToHeight(native_font_height(scaled=13)), diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/vdu_controls-2.5.0/vdu_controls.spec new/vdu_controls-2.6.0/vdu_controls.spec --- old/vdu_controls-2.5.0/vdu_controls.spec 2026-04-06 22:33:45.000000000 +0200 +++ new/vdu_controls-2.6.0/vdu_controls.spec 2026-04-20 01:49:48.000000000 +0200 @@ -18,7 +18,7 @@ Name: vdu_controls -Version: 2.5.0 +Version: 2.6.0 Release: 0 Summary: Visual Display Unit virtual control panel License: GPL-3.0-or-later @@ -40,6 +40,8 @@ %endif Recommends: ddcutil-service Recommends: python3-pyserial +Recommends: python3-pyudev +Recommends: brightnessctl %endif %if 0%{?fedora_version} %define ext_man * @@ -49,6 +51,8 @@ Requires: python3 Requires: python3-qt5 Suggests: python3-pyserial +Suggests: python3-pyudev +Suggests: brightnessctl %endif %description
