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;">&#x1F389;</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

Reply via email to