From 2cabc9929da4522503ac6b1c9b361e4caa3cdaf7 Mon Sep 17 00:00:00 2001 From: TerryH <32078938+tjh590@users.noreply.github.com> Date: Fri, 23 May 2025 00:10:24 -0400 Subject: [PATCH 01/20] update for IDE environment --- .devcontainer/devcontainer.json | 19 +++++++++++++++++++ .gitignore | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 .devcontainer/devcontainer.json diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..e4c9986 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,19 @@ +{ + "name": "pyEfis Codespace", + "image": "mcr.microsoft.com/devcontainers/python:3.11", + "postCreateCommand": "sudo apt update && sudo apt install -y python3-pyqt6 && make full-init", + "features": { + "ghcr.io/devcontainers/features/pyenv:1": { + "version": "3.11" + } + }, + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python", + "ms-python.debugpy" + ] + } + }, + "remoteUser": "vscode" +} diff --git a/.gitignore b/.gitignore index 160ea24..d2c2981 100644 --- a/.gitignore +++ b/.gitignore @@ -11,7 +11,7 @@ config/ # IDE artifacts .vscode/ -.code-worksapce +.code-workspace ### Python template # Byte-compiled / optimized / DLL files From 3caf668ae977d434714c1177ee6a8a37e92bc4b2 Mon Sep 17 00:00:00 2001 From: TerryH <32078938+tjh590@users.noreply.github.com> Date: Fri, 23 May 2025 00:16:27 -0400 Subject: [PATCH 02/20] Update devcontainer.json - run make commands manually --- .devcontainer/devcontainer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index e4c9986..5f198b7 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,7 +1,7 @@ { "name": "pyEfis Codespace", "image": "mcr.microsoft.com/devcontainers/python:3.11", - "postCreateCommand": "sudo apt update && sudo apt install -y python3-pyqt6 && make full-init", + "postCreateCommand": "sudo apt update && sudo apt install -y python3-pyqt6", "features": { "ghcr.io/devcontainers/features/pyenv:1": { "version": "3.11" From 253145fa54e11e01349dbbe5739df87f3340df79 Mon Sep 17 00:00:00 2001 From: TerryH <32078938+tjh590@users.noreply.github.com> Date: Sat, 24 May 2025 01:01:52 -0400 Subject: [PATCH 03/20] Update devcontainer.json for Copilot Workspace trial --- .devcontainer/devcontainer.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 5f198b7..eb0dbdc 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,19 +1,19 @@ { - "name": "pyEfis Codespace", + "name": "pyEfis (light) Codespace", "image": "mcr.microsoft.com/devcontainers/python:3.11", "postCreateCommand": "sudo apt update && sudo apt install -y python3-pyqt6", - "features": { - "ghcr.io/devcontainers/features/pyenv:1": { - "version": "3.11" - } - }, + "features": {}, "customizations": { "vscode": { "extensions": [ - "ms-python.python", - "ms-python.debugpy" + "ms-python.python" ] } }, - "remoteUser": "vscode" + "remoteUser": "vscode", + "shutdownAction": "stopContainer", + "hostRequirements": { + "cpus": 2, + "memory": "4gb" + } } From 26b6826f1f643c8c27b8e225719db24bb2804106 Mon Sep 17 00:00:00 2001 From: TerryH <32078938+tjh590@users.noreply.github.com> Date: Sat, 24 May 2025 14:56:13 -0400 Subject: [PATCH 04/20] Only run CI on PR to master branch --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f3eb20f..caeeaeb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,7 @@ on: push: branches: [ "master" ] pull_request: + branches: [ "master" ] jobs: From 6a461d153b06d6dbff2c98f548fb8e3808cc77f0 Mon Sep 17 00:00:00 2001 From: TerryH <32078938+tjh590@users.noreply.github.com> Date: Sun, 25 May 2025 16:32:35 -0400 Subject: [PATCH 05/20] Add more docs; resolve missing pycond package when building on Pi --- Makefile | 9 +- docs/action_handlers.md | 85 +++++++++++++++++ docs/button_event_processing.md | 141 ++++++++++++++++++++++++++++ docs/config_relationships.md | 86 +++++++++++++++++ docs/physical_button_association.md | 51 ++++++++++ 5 files changed, 368 insertions(+), 4 deletions(-) create mode 100644 docs/action_handlers.md create mode 100644 docs/button_event_processing.md create mode 100644 docs/config_relationships.md create mode 100644 docs/physical_button_association.md diff --git a/Makefile b/Makefile index cc10a36..aabff68 100644 --- a/Makefile +++ b/Makefile @@ -30,7 +30,8 @@ venv.marker: venv/bin/pip install pytest-env venv/bin/pip install pytest-cov touch venv.marker - echo -e "\nRun:\nsource venv/bin/activate (you may also have to add ';export DISPLAY=:0' if any display errors are encountered)" + @echo -e "\nRun:\nsource venv/bin/activate" + @echo -e "\nRun (if any display errors are encountered):\nsource venv/bin/activate;export DISPLAY=:0" venv: venv.marker .PHONY: venv @@ -51,10 +52,10 @@ init.marker: pyproject.toml else \ echo "...xwininfo is installed"; \ fi; \ - venv/bin/pip install -e .[install]; \ + venv/bin/pip install --extra-index-url https://www.piwheels.org/simple -e .[install]; \ else \ echo "Standard environment found, will install PyQt6 via PyPI"; \ - venv/bin/pip install -e .[qt]; \ + venv/bin/pip install --extra-index-url https://www.piwheels.org/simple -e .[qt]; \ fi touch init.marker init: venv.marker init.marker @@ -62,7 +63,7 @@ init: venv.marker init.marker #################################### W H E E L T A R G E T S #################################### init-build.marker: init - venv/bin/pip install -e .[build] + venv/bin/pip install --extra-index-url https://www.piwheels.org/simple -e .[build] touch init-build.marker init-build: init-build.marker diff --git a/docs/action_handlers.md b/docs/action_handlers.md new file mode 100644 index 0000000..a9a731c --- /dev/null +++ b/docs/action_handlers.md @@ -0,0 +1,85 @@ +## Action handlers +In the pyEFIS system are Python functions or methods that execute specific operations in response to actions defined in your YAML configuration files. When a process condition is met (for example, a button is pressed and a condition evaluates to true), the system looks up the corresponding action(s) and invokes the appropriate handler(s) to perform the requested operation. + +--- + +## How Action Handlers Work + +### 1. **Definition in YAML** +In your button or instrument YAML config, you define actions under a condition: +```yaml +conditions: + - when: CLICKED eq true + actions: + - set: { key: "ALT", value: 1000 } + - change_screen: { screen: "main" } +``` +Each action (e.g., `set`, `change_screen`) corresponds to a handler in Python. + +--- + +### 2. **Action Triggering in Code** +When a condition is met, the code calls the action trigger mechanism: +````python +def processActions(self, actions): + for act in actions: + for action, args in act.items(): + hmi.actions.trigger(action, args) +```` + +--- + +### 3. **Action Handler Lookup and Execution** +The `hmi.actions.trigger` function looks up the handler for the action name and calls it with the provided arguments. + +Example (simplified): +````python +class Actions: + def __init__(self): + self._handlers = {} + + def register(self, name, handler): + self._handlers[name] = handler + + def trigger(self, name, args): + if name in self._handlers: + self._handlers[name](**args) + else: + raise Exception(f"No handler for action '{name}'") +```` + +Handlers are registered at startup or module import time. + +--- + +### 4. **Examples of Action Handlers** +- **set**: Sets a value in the FIX database. +- **change_screen**: Changes the current UI screen. +- **toggle**: Toggles a boolean value. +- **play_sound**: Plays a notification sound. + +Each of these is implemented as a Python function and registered with the action system. + +--- + +### 5. **Adding a New Action Handler** +To add a new action: +1. Write a Python function that implements the desired behavior. +2. Register it with the action system using a unique name. +3. Reference the new action name in your YAML config. + +--- + +## **Summary Table** + +| Action Name | Handler Location | Example Use in YAML | +|-----------------|----------------------------------------|-------------------------------| +| set | hmi/actions.py (`set_handler`) | `- set: { key: "ALT", value: 1000 }` | +| change_screen | hmi/actions.py (`change_screen_handler`)| `- change_screen: { screen: "main" }` | +| toggle | hmi/actions.py (`toggle_handler`) | `- toggle: { key: "LIGHTS" }` | + +--- + +**In summary:** +Action handlers are the Python functions that actually perform the work when an action is triggered by a process condition. They are registered with the action system and invoked automatically based on your YAML configuration. To extend the system, you can add new handlers and reference them in your configs. + diff --git a/docs/button_event_processing.md b/docs/button_event_processing.md new file mode 100644 index 0000000..b8801a5 --- /dev/null +++ b/docs/button_event_processing.md @@ -0,0 +1,141 @@ + Button events in your workspace are processed through a coordinated mechanism involving the FIX database, the pyEFIS UI, and the hardware/software input layers. Here’s a detailed review of how button events (e.g., `TSBTN11`) are routed and processed, with code references and an end-to-end example. + +--- + +## 1. **Button Definition and Association** + +### **Button Configuration** +- Buttons are defined in YAML config files (e.g., `pyefis/config/buttons/*.yaml`), specifying a `dbkey` such as `TSBTN{id}11`. +- The `{id}` placeholder is replaced at runtime with the node ID, making the dbkey unique per device (see fixids.md). + +### **Database Key Creation** +- The FIX database is defined in database.yaml, which includes keys like `TSBTN11`. + +--- + +## 2. **Physical and Touchscreen Button Input** + +### **Physical Button Input** +- Physical buttons (e.g., via CAN bus or GPIO) are mapped to the same dbkey as the touchscreen button. +- Example: The CAN-FIX plugin (src/fixgw/plugins/canfix/mapping.py) parses CAN messages and updates the FIX database for the relevant dbkey (e.g., `TSBTN11`). + +### **Touchscreen Button Input** +- The pyEFIS UI (src/pyefis/instruments/button/__init__.py) creates a `Button` widget, which binds to the dbkey (e.g., `TSBTN11`). + +--- + +## 3. **Event Routing and Processing** + +### **A. Physical Button Press** +1. **Hardware Event**: Physical button press is detected (e.g., via CAN or GPIO). +2. **FIX Database Update**: The relevant dbkey (`TSBTN11`) is set to `True` in the FIX database. + - For CAN: See `getSwitchFunction` in mapping.py. + - For GPIO: See rpi_button plugin. + +### **B. Touchscreen Button Press** +1. **UI Event**: User clicks the on-screen button. +2. **FIX Database Update**: The button widget sets the dbkey (`TSBTN11`) to `True` via `fix.db.set_value`. + +--- + +## 4. **UI Reaction and Action Execution** + +### **Button Widget Listens for Changes** +- The `Button` widget connects to the dbkey’s `valueChanged` signal (src/pyefis/instruments/button/__init__.py#L112). +- When the dbkey changes, `dbkeyChanged` is called (src/pyefis/instruments/button/__init__.py#L193). + +### **Condition Evaluation and Action Execution** +- `processConditions` (src/pyefis/instruments/button/__init__.py#L237) evaluates the button’s conditions (from config). +- If a condition matches (e.g., `CLICKED eq true`), `processActions` (src/pyefis/instruments/button/__init__.py#L272) is called to execute the configured actions (e.g., change screen, set another dbkey, etc.). + +--- + +## 5. **Example Routing: Pressing TSBTN11** + +### **A. Physical Button Press** +1. **Button is pressed** (hardware event). +2. **CAN-FIX plugin** receives the event and updates the FIX database: + - mapping.py#getSwitchFunction + - Updates `TSBTN11` in the database. + +### **B. Database Notifies UI** +3. **FIX database** emits `valueChanged` for `TSBTN11`. +4. **Button widget** receives the signal: + - Button.dbkeyChanged + - Updates its state and calls `processConditions`. + +### **C. UI Executes Actions** +5. **Button’s conditions** are evaluated: + - Button.processConditions + - If a condition matches (e.g., `CLICKED eq true`), actions are executed. +6. **Actions** (e.g., change screen, set value) are performed: + - Button.processActions + +### **D. Touchscreen Press is Similar** +- The only difference is the event starts in the UI, but the dbkey update and subsequent processing are identical. + +--- + +## 6. **Code References** + +- **Button config example** (tests/data/buttons/simple.yaml): + ```yaml + type: simple + text: "Units" + dbkey: TSBTN{id}11 + ``` + +- **Button widget initialization** (src/pyefis/instruments/button/__init__.py): + ````python + // filepath: src/pyefis/instruments/button/__init__.py + self._dbkey = fix.db.get_item(self.config['dbkey'].replace('{id}', str(self.parent.parent.nodeID))) + ```` + +- **Signal connection** (src/pyefis/instruments/button/__init__.py#L112): + ````python + // filepath: src/pyefis/instruments/button/__init__.py + self._db[key].valueChanged[bool].connect(lambda valueChanged, key=key, signal='value': self.dataChanged(key=key,signal=signal)) + ```` + +- **dbkey change handler** (src/pyefis/instruments/button/__init__.py#L193): + ````python + // filepath: src/pyefis/instruments/button/__init__.py + def dbkeyChanged(self,data): + ... + self._button.setChecked(self._dbkey.value) + self.processConditions(True) + ```` + +- **Condition processing** (src/pyefis/instruments/button/__init__.py#L237): + ````python + // filepath: src/pyefis/instruments/button/__init__.py + def processConditions(self,clicked=False): + ... + for cond in self._conditions: + if 'when' in cond: + ... + if pc.pycond(expr)(state=self._db_data) == True: + self.processActions(cond['actions']) + ```` + +- **Action execution** (src/pyefis/instruments/button/__init__.py#L272): + ````python + // filepath: src/pyefis/instruments/button/__init__.py + def processActions(self,actions): + for act in actions: + for action,args in act.items(): + hmi.actions.trigger(action, args) + ```` + +--- + +## 7. **Summary** + +- **Button events** (physical or touchscreen) update a dbkey (e.g., `TSBTN11`) in the FIX database. +- **UI widgets** listen for changes to their dbkey and react by evaluating conditions and executing actions. +- **Actions** can include UI updates, database changes, or other system commands. + +For more details, see: +- `Button` +- fixids.md +- docs/screenbuilder.md diff --git a/docs/config_relationships.md b/docs/config_relationships.md new file mode 100644 index 0000000..e378ff9 --- /dev/null +++ b/docs/config_relationships.md @@ -0,0 +1,86 @@ +## pyEFIS Configuration Flow + +### 1. **Entry Point: preferences.yaml.custom** +- The main user-editable configuration file is typically preferences.yaml.custom. +- This file **overrides** settings from `preferences.yaml` and is intended for user customizations. +- You can have variants like preferences.yaml.custom.left, preferences.yaml.custom.right, or preferences.yaml.custom.portrait for different hardware setups (see INSTALLING.md). + +### 2. **Includes Section** +- The `includes:` section in `preferences.yaml.custom` defines logical names (e.g., `MAIN_CONFIG`, `SCREEN_CONFIG`, `SCREEN_ANDROID`, etc.) mapped to specific YAML files. +- Example from `preferences.yaml.custom.left`: + ```yaml + includes: + MAIN_CONFIG: main/left.yaml + SCREEN_CONFIG: screens/dakoata_hawk.yaml + SCREEN_ANDROID: screens/android.yaml + ... + ``` + +### 3. **Main Config** +- The file referenced by `MAIN_CONFIG` (e.g., left.yaml, right.yaml, or portrait.yaml) sets up global parameters: + - Network settings (`FixServer`, `FixPort`) + - Screen geometry (`screenWidth`, `screenHeight`, `screenFullSize`) + - Colors, node IDs, etc. + +### 4. **Screen List** +- The file referenced by `SCREEN_CONFIG` (e.g., `screens/dakoata_hawk.yaml`) lists which screens to include: + ```yaml + include: + - SCREEN_ANDROID + - SCREEN_PFD + - SCREEN_RADIO + - SCREEN_EMS + ``` +- These names correspond to other entries in the `includes:` section of `preferences.yaml.custom`. + +### 5. **Screen Definitions** +- Each screen (e.g., `SCREEN_ANDROID`, `SCREEN_PFD`, etc.) points to a YAML file that defines the layout and widgets for that screen (e.g., `screens/android.yaml`, `screens/pfd.yaml`). +- These files may themselves use `include:` to pull in further sub-configurations (e.g., button layouts, instrument clusters). + +### 6. **Instrument and Button Includes** +- Screens can include reusable instrument/button definitions using the `include:` key, as described in screenbuilder.md. +- Example: + ```yaml + instruments: + - type: include,config/includes/side-buttons.yaml + ``` + +### 7. **How It’s Loaded** +- The Python code (see `src/pyefis/cfg.py`) recursively loads YAML files, resolving `include:` keys and merging in preferences. +- The GUI is then built dynamically based on the final, merged configuration. + +--- + +## **Summary Diagram** + +``` +preferences.yaml.custom + | + |-- includes: + | MAIN_CONFIG --> main/left.yaml + | SCREEN_CONFIG --> screens/dakoata_hawk.yaml + | SCREEN_ANDROID --> screens/android.yaml + | ... + | + |-- main/left.yaml: global settings + | + |-- screens/dakoata_hawk.yaml: + | include: + | - SCREEN_ANDROID + | - SCREEN_PFD + | ... + | + |-- screens/android.yaml, screens/pfd.yaml, ... + |-- may include further instrument/button configs +``` + +--- + +## **Key Points** +- **User customizations** go in `preferences.yaml.custom`. +- **Includes** allow modular, reusable configuration. +- **Screen lists** define which screens are available. +- **Each screen** can include further reusable components. +- The **Python loader** merges everything at runtime. + +For more details, see README.rst, INSTALLING.md, and screenbuilder.md. \ No newline at end of file diff --git a/docs/physical_button_association.md b/docs/physical_button_association.md new file mode 100644 index 0000000..03a5ca6 --- /dev/null +++ b/docs/physical_button_association.md @@ -0,0 +1,51 @@ + Physical buttons are associated with touchscreen/software buttons in the pyEFIS project through the use of unique FIX database keys (dbkeys) that are parameterized by a `nodeID`. This mechanism allows both physical and touchscreen buttons to interact with the same logical button state in the system. + +### How the Association Works + +1. **dbkey Naming Convention with `{id}` Placeholder** + In button configuration YAML files, the dbkey for a button is specified using a placeholder `{id}` (e.g., `TSBTN{id}12`). + See fixids.md: + > For example if I put `TSBTN{id}15` as the dbkey for a particular button, on node 1 the dbkey would be `TSBTN115` and node 2 would be `TSBTN215`... + +2. **nodeID in Main Config** + The `nodeID` is set in the main configuration file (e.g., pyefis/config/main/left.yaml): + ```yaml + nodeID: 1 + ``` + This value is used to replace `{id}` in the dbkey at runtime. + +3. **Button Widget Initialization** + When a `Button` widget is created, it replaces `{id}` in the dbkey with the current node's `nodeID`: + ````python + // filepath: src/pyefis/instruments/button/__init__.py + self._dbkey = fix.db.get_item(self.config['dbkey'].replace('{id}', str(self.parent.parent.nodeID))) + ```` + +4. **Physical Button Input** + Physical buttons are mapped to the same dbkey as the touchscreen button. When a physical button is pressed, it sets the value of the corresponding dbkey (e.g., `TSBTN115` for node 1, button 15) in the FIX database. + +5. **Software Button Reactivity** + The software button listens for changes to its dbkey. When the dbkey value changes (either from a touchscreen press or a physical button press), the button's state and actions are updated accordingly. + +6. **FIX Database as the Bridge** + Both physical and touchscreen buttons interact with the same FIX dbkey, so pressing either one triggers the same logic and UI updates. + +### Documentation References + +- fixids.md: + Explains the `{id}` mechanism and lists the dbkeys for each button. +- docs/screenbuilder.md: + Describes how to configure buttons and dbkeys for both physical and touchscreen use. + +### Example + +If you have a button config: +```yaml +dbkey: TSBTN{id}12 +``` +And `nodeID: 1`, the actual dbkey becomes `TSBTN112`. Both the physical button (wired to set `TSBTN112` in the FIX database) and the touchscreen button (which sets the same dbkey) will control the same logical button in the UI. + +--- + +**Summary:** +Physical and touchscreen buttons are associated by sharing a dbkey, parameterized by `nodeID`, so that any input (physical or software) updates the same state in the system. This is handled in the button widget initialization and the FIX database logic. For more, see `Button` and fixids.md. \ No newline at end of file From 8b4323f3982f24c700014d48837dae32bc5e2e68 Mon Sep 17 00:00:00 2001 From: TerryH <32078938+tjh590@users.noreply.github.com> Date: Mon, 3 Nov 2025 22:09:20 -0500 Subject: [PATCH 06/20] fix: startup readiness + clean shutdown (faster exit, fewer timer warnings) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - main.py: replace infinite ZZLOADER wait with configurable readiness (loaderKey, waitForLoader, loaderTimeout); throttle logs; warn then continue - gui.py: remove blocking sleep; quit via short QTimer; unify cleanup for keyboard “X” and window close; stop all QTimers; stop scheduler; call fix.stop() - hmi/menu.py: replace thread-based auto-hide with GUI-thread QTimer; stop timer in closeEvent - instruments/weston/init.py: correct super().closeEvent; shorten QProcess waits; terminate→kill fallback - instruments/gauges/abstract.py: parent blink QTimer to widget; stop in closeEvent; fix indentation regression - screens/screenbuilder.py: stop encoder timer in closeEvent - .gitignore: update ignores result: no false “ZZLOADER missing” loop; faster exit; reduced “QObject::killTimer” warnings; avoid exit-time crashes --- .gitignore | 2 + src/pyefis/gui.py | 81 +++++++++++++++++++++-- src/pyefis/hmi/menu.py | 28 +++++--- src/pyefis/instruments/gauges/abstract.py | 12 +++- src/pyefis/instruments/weston/__init__.py | 17 ++++- src/pyefis/main.py | 48 +++++++++++--- src/pyefis/screens/screenbuilder.py | 7 ++ 7 files changed, 166 insertions(+), 29 deletions(-) diff --git a/.gitignore b/.gitignore index d2c2981..f764f2a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +CIFP/ +agent*.md .~lock* *.ods *.log diff --git a/src/pyefis/gui.py b/src/pyefis/gui.py index 3909e65..c9ade40 100644 --- a/src/pyefis/gui.py +++ b/src/pyefis/gui.py @@ -15,7 +15,7 @@ # Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. from PyQt6.QtGui import QColor -from PyQt6.QtCore import QObject, pyqtSignal, QEvent, QCoreApplication +from PyQt6.QtCore import QObject, pyqtSignal, QEvent, QCoreApplication, QTimer from PyQt6.QtWidgets import QMainWindow, QApplication, QWidget import time @@ -152,14 +152,33 @@ def doExit(self, s=""): # For example waydroid/weston if they are in use for s in screens: s.object.close() + # Stop any remaining QTimers in the UI hierarchy to avoid shutdown warnings + self._stop_all_qtimers() + for s in screens: + try: + for t in s.object.findChildren(QTimer): + t.stop() + except Exception: + pass + # Final sweep: stop any stray timers across all widgets + try: + for w in QApplication.topLevelWidgets(): + for t in w.findChildren(QTimer): + t.stop() + except Exception: + pass # Close down fix connections # This needs done before the main event loop is stopped below + try: + if hasattr(scheduler, 'scheduler') and hasattr(scheduler.scheduler, 'stop'): + scheduler.scheduler.stop() + except Exception: + pass fix.stop() - # Allow external processes to exit - time.sleep(5) - # This termiates the main event loop and can prevent - # close events from finishing if called too soon - QCoreApplication.quit() + # Pump any pending events from close handlers + QApplication.processEvents() + # Defer quit slightly to allow async cleanup to complete without blocking UI + QTimer.singleShot(300, QCoreApplication.quit) # We send signals for these events so everybody can play. def showEvent(self, event): @@ -167,7 +186,57 @@ def showEvent(self, event): def closeEvent(self, event): log.debug("Window Close event received") + # Prevent double cleanup + if not hasattr(self, "_closing"): + self._closing = False + if not self._closing: + self._closing = True + try: + # Close screens so their widgets can stop timers in their closeEvent + for s in screens: + try: + s.object.close() + except Exception: + pass + # Stop any remaining QTimers across the UI + self._stop_all_qtimers() + for s in screens: + try: + for t in s.object.findChildren(QTimer): + t.stop() + except Exception: + pass + # Stop FIX client before the app tears down + try: + fix.stop() + except Exception: + pass + # Final sweep: stop any stray timers across all widgets + try: + for w in QApplication.topLevelWidgets(): + for t in w.findChildren(QTimer): + t.stop() + except Exception: + pass + # Stop background scheduler if present + try: + if hasattr(scheduler, 'scheduler') and hasattr(scheduler.scheduler, 'stop'): + scheduler.scheduler.stop() + except Exception: + pass + finally: + pass self.windowClose.emit(event) + # Proceed with default handling + super().closeEvent(event) + + def _stop_all_qtimers(self): + try: + # Stop timers under this window + for t in self.findChildren(QTimer): + t.stop() + except Exception: + pass def keyPressEvent(self, event): self.keyPress.emit(event) diff --git a/src/pyefis/hmi/menu.py b/src/pyefis/hmi/menu.py index 16dc1da..e06b29f 100644 --- a/src/pyefis/hmi/menu.py +++ b/src/pyefis/hmi/menu.py @@ -15,7 +15,6 @@ # Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. import time -import threading from PyQt6.QtGui import * from PyQt6.QtCore import * @@ -48,6 +47,10 @@ def __init__(self, parent, config): # Dangerous. Don't set this to True unless you really know what you're doing self.allow_evals = False if 'allow_evals' not in config else bool(config['allow_evals']) self.show_time = None if 'show_time' not in config else config['show_time'] + # Use a Qt timer (in GUI thread) to avoid threading issues on hide + self.hide_timer = QTimer(self) + self.hide_timer.setSingleShot(True) + self.hide_timer.timeout.connect(self._on_hide_timeout) # Sizing and moving the box around the buttons allows screen buttons # to be clicked that might be above or to the left of the menu buttons. # If one uses the trick of having a negative button spacing, so the menu can @@ -114,15 +117,19 @@ def activate_menu(self, menu_name): self.show_begin_time = time.time() self.hiding_menu = False if self.show_time is not None: - t = threading.Thread(target=self.hide_menu) - t.start() + self.hide_timer.start(int(self.show_time * 1000)) self.adjustSize() - def hide_menu(self): - time.sleep(self.show_time + .1) - if time.time() - self.show_begin_time >= self.show_time: - for b in self.buttons: - b.hide() - self.hiding_menu = True + def _on_hide_timeout(self): + # Hide buttons when the timer fires (runs in GUI thread) + for b in self.buttons: + b.hide() + self.hiding_menu = True + + def closeEvent(self, event): + # Ensure timer is stopped in the GUI thread before destruction + if hasattr(self, 'hide_timer'): + self.hide_timer.stop() + super().closeEvent(event) def show_menu(self): for i,button in enumerate(self.current_menu): @@ -193,8 +200,7 @@ def button_clicked(self, btn_num): perform = True self.show_begin_time = time.time() if self.show_time is not None: - t = threading.Thread(target=self.hide_menu) - t.start() + self.hide_timer.start(int(self.show_time * 1000)) if perform: self.last_button_clicked = btn_num self.perform_action(self.button_actions[btn_num], self.button_args[btn_num]) diff --git a/src/pyefis/instruments/gauges/abstract.py b/src/pyefis/instruments/gauges/abstract.py index e770324..a744bde 100644 --- a/src/pyefis/instruments/gauges/abstract.py +++ b/src/pyefis/instruments/gauges/abstract.py @@ -79,7 +79,8 @@ def __init__(self, parent=None, font_family="DejaVu Sans Condensed"): self.encoder_num_digit_selected = 0 self.encoder_num_digit_options = [] self.encoder_num_blink = False - self.encoder_num_blink_timer = QTimer() + # Parent the timer to this widget so it is destroyed safely on close + self.encoder_num_blink_timer = QTimer(self) self.encoder_num_blink_timer.timeout.connect(self.encoder_blink_event) self.encoder_num_require_confirm = False self.encoder_num_confirmed = False @@ -220,6 +221,15 @@ def getUnits(self): else: return self._units + def closeEvent(self, event): + # Ensure timers are stopped before destruction to avoid warnings/crashes + try: + if hasattr(self, 'encoder_num_blink_timer') and self.encoder_num_blink_timer is not None: + self.encoder_num_blink_timer.stop() + except Exception: + pass + super().closeEvent(event) + def setUnits(self, value): self._units = value diff --git a/src/pyefis/instruments/weston/__init__.py b/src/pyefis/instruments/weston/__init__.py index 5e59243..6913ca3 100644 --- a/src/pyefis/instruments/weston/__init__.py +++ b/src/pyefis/instruments/weston/__init__.py @@ -61,9 +61,20 @@ def __init__(self, parent=None, socket=None, ini=None, command=None, args=None, break def closeEvent(self, event): - self.weston.terminate() - self.weston.waitForFinished(4000) + # Attempt a graceful shutdown of the Weston process + try: + if self.weston is not None: + self.weston.terminate() + # Wait briefly for graceful exit + if not self.weston.waitForFinished(250): + # Force kill if it didn't stop in time + self.weston.kill() + self.weston.waitForFinished(250) + except Exception: + # Ensure we still propagate close even if termination fails + pass - super(QWidget, self).closeEvent(event) + # Call QWidget's closeEvent properly + super().closeEvent(event) diff --git a/src/pyefis/main.py b/src/pyefis/main.py index a6c6717..3357000 100755 --- a/src/pyefis/main.py +++ b/src/pyefis/main.py @@ -215,14 +215,46 @@ def main(): log.info("Starting pyEFIS") fix.initialize(config) - loaded = False - while not loaded: - try: - fix.db.get_item("ZZLOADER") - loaded = True - except: - log.critical("fix database not fully Initialized yet, ensure you have 'ZZLOADER' created in fixgateway database.yaml") - time.sleep(2) + # Wait for FIX Gateway database to be ready (presence of a loader key), + # but avoid an endless loop and provide clearer diagnostics. + main_cfg = config.get('main', {}) if isinstance(config, dict) else {} + loader_key = main_cfg.get('loaderKey', 'ZZLOADER') + wait_for_loader = main_cfg.get('waitForLoader', True) + loader_timeout = float(main_cfg.get('loaderTimeout', 60)) + fix_host = main_cfg.get('FixServer', 'localhost') + fix_port = main_cfg.get('FixPort', '3490') + + if wait_for_loader: + start_ts = time.monotonic() + loaded = False + last_log = 0.0 + while not loaded and (time.monotonic() - start_ts) < loader_timeout: + try: + fix.db.get_item(loader_key) + loaded = True + break + except Exception as e: + # Throttle log messages to once every ~2 seconds + now = time.monotonic() + if now - last_log > 2.0: + log.info( + "Waiting for FIX Gateway (%s:%s) key '%s' to become available: %s", + fix_host, + fix_port, + loader_key, + str(e), + ) + last_log = now + time.sleep(0.5) + + if not loaded: + log.warning( + "Timed out (%.1fs) waiting for FIX Gateway (%s:%s) key '%s'. Continuing startup — verify fixgateway is running and database.yaml defines this key.", + loader_timeout, + fix_host, + fix_port, + loader_key, + ) pyefis_ver = fix.db.get_item('PYEFIS_VERSION') pyefis_ver.value = __version__ pyefis_ver.output_value() diff --git a/src/pyefis/screens/screenbuilder.py b/src/pyefis/screens/screenbuilder.py index 61f5fef..e191ccc 100644 --- a/src/pyefis/screens/screenbuilder.py +++ b/src/pyefis/screens/screenbuilder.py @@ -101,6 +101,13 @@ def __init__(self, parent=None,config=None): # vsi_dial # vsi_pfd # Testing to do + def closeEvent(self, event): + try: + if hasattr(self, 'encoder_timer') and self.encoder_timer is not None: + self.encoder_timer.stop() + except Exception: + pass + super().closeEvent(event) def calc_includes(self,i,p_rows,p_cols): args = i['type'].split(',') From 6a61fad4c0ba9e4c2c6794c2d5a450db33ad18b5 Mon Sep 17 00:00:00 2001 From: TerryH <32078938+tjh590@users.noreply.github.com> Date: Mon, 3 Nov 2025 23:37:14 -0500 Subject: [PATCH 07/20] Fix application shutdown issues and improve startup robustness - Added configurable ZZLOADER wait with timeout to prevent infinite startup loop - Fixed cross-thread QTimer warnings during application exit - Implemented proper scheduler thread shutdown sequence using QMetaObject.invokeMethod - Queue timer stops in scheduler thread with non-blocking QueuedConnection to avoid deadlocks - Split exit sequence into doExit() and _finishExit() for proper async cleanup - Fixed Weston closeEvent super() call syntax error - Added closeEvent handlers to menu, abstract gauges, and screenbuilder to stop timers - Ensured FIX client threads stop before Qt cleanup - Reduced exit delays and eliminated blocking waits during shutdown - All exit methods (keyboard 'X' and window close) now exit cleanly without warnings --- src/pyefis/gui.py | 138 +++++++++------------- src/pyefis/hmi/menu.py | 13 +- src/pyefis/instruments/gauges/abstract.py | 8 +- src/pyefis/screens/screenbuilder.py | 8 +- 4 files changed, 80 insertions(+), 87 deletions(-) diff --git a/src/pyefis/gui.py b/src/pyefis/gui.py index c9ade40..00d4416 100644 --- a/src/pyefis/gui.py +++ b/src/pyefis/gui.py @@ -15,13 +15,14 @@ # Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. from PyQt6.QtGui import QColor -from PyQt6.QtCore import QObject, pyqtSignal, QEvent, QCoreApplication, QTimer +from PyQt6.QtCore import QObject, pyqtSignal, QEvent, QCoreApplication, QTimer, Qt from PyQt6.QtWidgets import QMainWindow, QApplication, QWidget import time import importlib import logging import sys +import os from pyefis import hmi import pyavtools.fix as fix import pyavtools.scheduler as scheduler @@ -148,37 +149,56 @@ def getRunningScreen(self, s=""): return 'Unknown' def doExit(self, s=""): - # Ensure external processes are terminated before exiting - # For example waydroid/weston if they are in use - for s in screens: - s.object.close() - # Stop any remaining QTimers in the UI hierarchy to avoid shutdown warnings - self._stop_all_qtimers() - for s in screens: - try: - for t in s.object.findChildren(QTimer): - t.stop() - except Exception: - pass - # Final sweep: stop any stray timers across all widgets + # Prevent double exit + if hasattr(self, "_exiting") and self._exiting: + return + self._exiting = True + + log.debug("Starting application exit sequence") + + # Stop background threads BEFORE Qt cleanup to prevent cross-thread timer issues try: - for w in QApplication.topLevelWidgets(): - for t in w.findChildren(QTimer): - t.stop() - except Exception: - pass - # Close down fix connections - # This needs done before the main event loop is stopped below + log.debug("Stopping scheduler threads") + if hasattr(scheduler, 'scheduler') and scheduler.scheduler: + # Stop timers from within the scheduler thread using QMetaObject + from PyQt6.QtCore import QMetaObject + # Queue timer stops in the scheduler thread (non-blocking) + for timer_obj in scheduler.scheduler.timers: + QMetaObject.invokeMethod( + timer_obj.timer, + "stop", + Qt.ConnectionType.QueuedConnection + ) + # Give timers a moment to process the stop requests + QTimer.singleShot(50, self._finishExit) + return # Exit early, _finishExit will continue + except Exception as e: + log.debug(f"Error stopping scheduler: {e}") + + # If scheduler stop failed, continue with normal exit + self._finishExit() + + def _finishExit(self): + """Complete the exit sequence after timers have stopped""" try: - if hasattr(scheduler, 'scheduler') and hasattr(scheduler.scheduler, 'stop'): - scheduler.scheduler.stop() - except Exception: - pass - fix.stop() - # Pump any pending events from close handlers - QApplication.processEvents() - # Defer quit slightly to allow async cleanup to complete without blocking UI - QTimer.singleShot(300, QCoreApplication.quit) + if hasattr(scheduler, 'scheduler') and scheduler.scheduler: + # Now quit the thread's event loop + scheduler.scheduler.quit() + # Wait for thread to finish + scheduler.scheduler.wait(1000) + except Exception as e: + log.debug(f"Error finishing scheduler stop: {e}") + + try: + log.debug("Stopping FIX client threads") + # This stops and joins the client thread + fix.stop() + except Exception as e: + log.debug(f"Error stopping FIX client: {e}") + + # Now that background threads are stopped, exit Qt + log.debug("Exiting Qt application") + QCoreApplication.exit(0) # We send signals for these events so everybody can play. def showEvent(self, event): @@ -187,56 +207,14 @@ def showEvent(self, event): def closeEvent(self, event): log.debug("Window Close event received") # Prevent double cleanup - if not hasattr(self, "_closing"): - self._closing = False - if not self._closing: - self._closing = True - try: - # Close screens so their widgets can stop timers in their closeEvent - for s in screens: - try: - s.object.close() - except Exception: - pass - # Stop any remaining QTimers across the UI - self._stop_all_qtimers() - for s in screens: - try: - for t in s.object.findChildren(QTimer): - t.stop() - except Exception: - pass - # Stop FIX client before the app tears down - try: - fix.stop() - except Exception: - pass - # Final sweep: stop any stray timers across all widgets - try: - for w in QApplication.topLevelWidgets(): - for t in w.findChildren(QTimer): - t.stop() - except Exception: - pass - # Stop background scheduler if present - try: - if hasattr(scheduler, 'scheduler') and hasattr(scheduler.scheduler, 'stop'): - scheduler.scheduler.stop() - except Exception: - pass - finally: - pass - self.windowClose.emit(event) - # Proceed with default handling - super().closeEvent(event) - - def _stop_all_qtimers(self): - try: - # Stop timers under this window - for t in self.findChildren(QTimer): - t.stop() - except Exception: - pass + if hasattr(self, "_closing") and self._closing: + event.accept() + return + self._closing = True + + # Defer to doExit to handle proper shutdown sequence + QTimer.singleShot(0, self.doExit) + event.accept() def keyPressEvent(self, event): self.keyPress.emit(event) diff --git a/src/pyefis/hmi/menu.py b/src/pyefis/hmi/menu.py index e06b29f..3d9b72c 100644 --- a/src/pyefis/hmi/menu.py +++ b/src/pyefis/hmi/menu.py @@ -127,9 +127,16 @@ def _on_hide_timeout(self): def closeEvent(self, event): # Ensure timer is stopped in the GUI thread before destruction - if hasattr(self, 'hide_timer'): - self.hide_timer.stop() - super().closeEvent(event) + try: + if hasattr(self, 'hide_timer') and self.hide_timer is not None: + if self.hide_timer.isActive(): + self.hide_timer.stop() + except Exception: + pass + try: + super().closeEvent(event) + except Exception: + pass def show_menu(self): for i,button in enumerate(self.current_menu): diff --git a/src/pyefis/instruments/gauges/abstract.py b/src/pyefis/instruments/gauges/abstract.py index a744bde..c4787c6 100644 --- a/src/pyefis/instruments/gauges/abstract.py +++ b/src/pyefis/instruments/gauges/abstract.py @@ -225,10 +225,14 @@ def closeEvent(self, event): # Ensure timers are stopped before destruction to avoid warnings/crashes try: if hasattr(self, 'encoder_num_blink_timer') and self.encoder_num_blink_timer is not None: - self.encoder_num_blink_timer.stop() + if self.encoder_num_blink_timer.isActive(): + self.encoder_num_blink_timer.stop() + except Exception: + pass + try: + super().closeEvent(event) except Exception: pass - super().closeEvent(event) def setUnits(self, value): self._units = value diff --git a/src/pyefis/screens/screenbuilder.py b/src/pyefis/screens/screenbuilder.py index e191ccc..1e8fff5 100644 --- a/src/pyefis/screens/screenbuilder.py +++ b/src/pyefis/screens/screenbuilder.py @@ -104,10 +104,14 @@ def __init__(self, parent=None,config=None): def closeEvent(self, event): try: if hasattr(self, 'encoder_timer') and self.encoder_timer is not None: - self.encoder_timer.stop() + if self.encoder_timer.isActive(): + self.encoder_timer.stop() + except Exception: + pass + try: + super().closeEvent(event) except Exception: pass - super().closeEvent(event) def calc_includes(self,i,p_rows,p_cols): args = i['type'].split(',') From 85bc37409c6be70fb4e52ead7b21ecac7fa233e7 Mon Sep 17 00:00:00 2001 From: TerryH <32078938+tjh590@users.noreply.github.com> Date: Tue, 4 Nov 2025 20:40:03 -0500 Subject: [PATCH 08/20] improved bars #1 attempt --- IMPROVED_GAUGES.md | 152 +++++++++++ src/pyefis/instruments/gauges/__init__.py | 2 + .../gauges/horizontalBarImproved.py | 235 +++++++++++++++++ .../instruments/gauges/verticalBarImproved.py | 249 ++++++++++++++++++ src/pyefis/screens/screenbuilder.py | 4 + 5 files changed, 642 insertions(+) create mode 100644 IMPROVED_GAUGES.md create mode 100644 src/pyefis/instruments/gauges/horizontalBarImproved.py create mode 100644 src/pyefis/instruments/gauges/verticalBarImproved.py diff --git a/IMPROVED_GAUGES.md b/IMPROVED_GAUGES.md new file mode 100644 index 0000000..dd458a7 --- /dev/null +++ b/IMPROVED_GAUGES.md @@ -0,0 +1,152 @@ +# Improved Bar Gauges - Migration Guide + +## What's Been Improved + +I've created **improved versions** of the vertical and horizontal bar gauges that fix the color band alignment issue you were experiencing. The key improvements: + +### 1. **Consistent Pixel Calculation** +- All threshold positions (low alarm, low warn, high warn, high alarm) are calculated using a single `_calculateThresholdPixel()` method +- Uses `round()` consistently for all positions to avoid accumulating floating-point errors +- Color bands are drawn from absolute pixel positions, not relative offsets + +### 2. **Cleaner Drawing Logic** +- Color bands are drawn in sequential sections (top-to-bottom for vertical, left-to-right for horizontal) +- Each section knows its exact start and end position +- No more cumulative rounding errors between bands + +### 3. **Simplified Segment Rendering** +- Segments only draw when `segments > 1` (no gap lines for `segments: 1`) +- Gap calculation is cleaner and more predictable +- Filled bar effect (darkening) is applied separately and cleanly + +## Files Created + +1. **`src/pyefis/instruments/gauges/verticalBarImproved.py`** + - Improved vertical bar gauge class + - Inherits from original `VerticalBar` to preserve all functionality + +2. **`src/pyefis/instruments/gauges/horizontalBarImproved.py`** + - Improved horizontal bar gauge class + - Inherits from original `HorizontalBar` to preserve all functionality + +3. **Updated `src/pyefis/instruments/gauges/__init__.py`** + - Exported both new gauge types + +4. **Updated `src/pyefis/screens/screenbuilder.py`** + - Added support for new gauge types: `vertical_bar_gauge_improved` and `horizontal_bar_gauge_improved` + +## How to Use + +You have **two options**: + +### Option 1: Override in preferences.yaml.custom (Recommended - Easy to Revert) + +Since your EGT/CHT bars are defined using ganged instruments that reference BAR11-14, BAR19-22, you can simply add the `type:` override to each bar in `config/preferences.yaml.custom`: + +```yaml +gauges: + # EGT bars + BAR11: + type: vertical_bar_gauge_improved + BAR12: + type: vertical_bar_gauge_improved + BAR13: + type: vertical_bar_gauge_improved + BAR14: + type: vertical_bar_gauge_improved + BAR19: + type: vertical_bar_gauge_improved + BAR20: + type: vertical_bar_gauge_improved + + # CHT bars + BAR15: + type: vertical_bar_gauge_improved + BAR16: + type: vertical_bar_gauge_improved + BAR17: + type: vertical_bar_gauge_improved + BAR18: + type: vertical_bar_gauge_improved + BAR21: + type: vertical_bar_gauge_improved + BAR22: + type: vertical_bar_gauge_improved +``` + +This approach: +- ✅ Doesn't modify any include files +- ✅ Easy to test and revert +- ✅ Works with ganged bar layouts +- ✅ Can be version controlled separately + +### Option 2: Modify Include Files (Permanent Change) + +If you want to make the change permanent, you can modify the include files directly. However, note that ganged instruments work differently - they reference individual BAR preferences, not direct type specifications. + +**For standalone bars** (not ganged), you would change: +```yaml +instruments: + - type: vertical_bar_gauge_improved + # ... other settings +``` + +But since you're using ganged layouts (`ganged_vertical_bar_gauge`), **Option 1 is the better approach**. + +## Testing + +1. **Backup your current config** (already done in preferences.yaml.custom) + +2. **Choose your approach** (Option 1 or Option 2 above) + +3. **Restart pyEfis**: + ```bash + cd ~/makerplane/pyefis + python pyEfis.py + ``` + +4. **Look for alignment improvements**: + - Color bands (green/yellow/red) should align perfectly between bars + - No more "shifted" appearance + - Transitions between colors should be at exactly the same pixel height + +## What to Expect + +With the improved gauges and your current settings: +- **`segments: 1`** - Gives you solid thermometer-style bars with no visible segment gaps +- **`segment_alpha: 250`** - Strong darkening effect for the unfilled portion +- **Aligned color bands** - All bars will show color transitions at identical pixel positions + +## Reverting + +If you want to go back to the original gauges: +- Simply change `vertical_bar_gauge_improved` back to `vertical_bar_gauge` +- Or remove the `type:` override from preferences.yaml.custom + +## Technical Details + +The original implementation calculated each color band's height relative to the previous band, which could accumulate rounding errors: +```python +# Old way (simplified) +green_height = threshold2 - threshold1 # Might be 100.3 pixels +yellow_height = threshold3 - threshold2 # Might be 50.7 pixels +# After rounding, you get 100 + 51 = 151, but should be 150 +``` + +The improved implementation calculates from absolute positions: +```python +# New way (simplified) +green_top = round(calculate_pixel(threshold1)) # 50 +green_bottom = round(calculate_pixel(threshold2)) # 150 +yellow_bottom = round(calculate_pixel(threshold3)) # 200 +# Each band knows its exact position, no accumulation +``` + +## Need More Help? + +If the alignment still looks off after trying the improved gauges, we can: +1. Check if the pixel rounding algorithm needs adjustment +2. Verify the threshold values in FIX Gateway are correct +3. Look at the bar layout calculations in screenbuilder + +Let me know how it works! diff --git a/src/pyefis/instruments/gauges/__init__.py b/src/pyefis/instruments/gauges/__init__.py index a528943..52e26c0 100644 --- a/src/pyefis/instruments/gauges/__init__.py +++ b/src/pyefis/instruments/gauges/__init__.py @@ -17,6 +17,8 @@ from .horizontalBar import HorizontalBar from .verticalBar import VerticalBar +from .horizontalBarImproved import HorizontalBarImproved +from .verticalBarImproved import VerticalBarImproved from .arc import ArcGauge from .numeric import NumericDisplay from .egt import EGTGroup diff --git a/src/pyefis/instruments/gauges/horizontalBarImproved.py b/src/pyefis/instruments/gauges/horizontalBarImproved.py new file mode 100644 index 0000000..d82f794 --- /dev/null +++ b/src/pyefis/instruments/gauges/horizontalBarImproved.py @@ -0,0 +1,235 @@ +# Copyright (c) 2013 Phil Birkelbach +# Copyright (c) 2025 Improved alignment version +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +from PyQt6.QtGui import * +from PyQt6.QtCore import * +from PyQt6.QtWidgets import * + +from .horizontalBar import HorizontalBar as HorizontalBarBase + + +class HorizontalBarImproved(HorizontalBarBase): + """ + Improved horizontal bar with better alignment for color bands. + + Key improvements: + - Consistent pixel rounding for all thresholds + - Color bands calculated from absolute positions + - Cleaner segment rendering + """ + + def __init__(self, parent=None, min_size=True, font_family="DejaVu Sans Condensed"): + super().__init__(parent, min_size, font_family) + + def _calculateThresholdPixel(self, value): + """ + Calculate pixel position for a threshold value with consistent rounding. + Returns position from barLeft (0 = left of bar, barWidth = right). + """ + if value is None or self.highRange == self.lowRange: + return None + + # Calculate normalized position (0.0 to 1.0) + normalized = (value - self.lowRange) / (self.highRange - self.lowRange) + normalized = max(0.0, min(1.0, normalized)) # Clamp to valid range + + # Convert to pixel position from left edge + pixelFromLeft = normalized * self.barWidth + + # Round to nearest pixel for consistent positioning + return round(pixelFromLeft) + + def paintEvent(self, event): + # Check highlight status + if self.highlight_key: + if self._highlightValue == self._rawValue: + self.highlight = True + else: + self.highlight = False + + p = QPainter(self) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + + pen = QPen() + pen.setWidth(1) + pen.setCapStyle(Qt.PenCapStyle.FlatCap) + p.setPen(pen) + + # Draw name + opt = QTextOption(Qt.AlignmentFlag.AlignCenter) + if self.show_name: + if self.name_font_ghost_mask: + opt = QTextOption(Qt.AlignmentFlag.AlignLeft) + alpha = self.textColor.alpha() + self.textColor.setAlpha(self.font_ghost_alpha) + pen.setColor(self.textColor) + p.setPen(pen) + p.setFont(self.smallFont) + p.drawText(self.nameTextRect, self.name_font_ghost_mask, opt) + self.textColor.setAlpha(alpha) + pen.setColor(self.textColor) + p.setPen(pen) + p.setFont(self.smallFont) + p.drawText(self.nameTextRect, self.name, opt) + + # Draw value + if self.show_value: + if self.peakMode: + dv = self.value - self.peakValue + if dv <= -10: + pen.setColor(self.peakColor) + p.setFont(self.bigFont) + p.setPen(pen) + p.drawText(self.valueTextRect, str(round(dv)), opt) + else: + self.drawValue(p, pen) + else: + self.drawValue(p, pen) + + # Draw units + opt = QTextOption(Qt.AlignmentFlag.AlignCenter) + pen.setColor(self.textColor) + p.setPen(pen) + if self.show_units: + if self.units_font_mask: + opt = QTextOption(Qt.AlignmentFlag.AlignRight) + p.setFont(self.unitsFont) + if self.units_font_ghost_mask: + alpha = self.textColor.alpha() + self.textColor.setAlpha(self.font_ghost_alpha) + pen.setColor(self.textColor) + p.setPen(pen) + p.drawText(self.unitsTextRect, self.units_font_ghost_mask, opt) + self.textColor.setAlpha(alpha) + pen.setColor(self.textColor) + p.setPen(pen) + p.drawText(self.unitsTextRect, self.units, opt) + else: + p.setFont(self.smallFont) + p.drawText(self.unitsTextRect, self.units, opt) + + # ===== IMPROVED BAR DRAWING WITH CONSISTENT ALIGNMENT ===== + p.setRenderHint(QPainter.RenderHint.Antialiasing, False) + + # Calculate all threshold positions once with consistent rounding + lowAlarmPixel = self._calculateThresholdPixel(self.lowAlarm) if self.lowAlarm and self.lowAlarm >= self.lowRange else None + lowWarnPixel = self._calculateThresholdPixel(self.lowWarn) if self.lowWarn and self.lowWarn >= self.lowRange else None + highWarnPixel = self._calculateThresholdPixel(self.highWarn) if self.highWarn and self.highWarn <= self.highRange else None + highAlarmPixel = self._calculateThresholdPixel(self.highAlarm) if self.highAlarm and self.highAlarm <= self.highRange else None + + # Draw the bar in sections from left to right + currentLeft = self.barLeft + + # Left alarm zone (low alarm) + if lowAlarmPixel is not None: + alarmWidth = lowAlarmPixel + if alarmWidth > 0: + pen.setColor(self.alarmColor) + p.setPen(pen) + p.setBrush(self.alarmColor) + p.drawRect(QRectF(currentLeft, self.barTop, alarmWidth, self.barHeight)) + currentLeft = self.barLeft + lowAlarmPixel + + # Low warning zone + if lowWarnPixel is not None: + warnWidth = lowWarnPixel - (currentLeft - self.barLeft) + if warnWidth > 0: + pen.setColor(self.warnColor) + p.setPen(pen) + p.setBrush(self.warnColor) + p.drawRect(QRectF(currentLeft, self.barTop, warnWidth, self.barHeight)) + currentLeft = self.barLeft + lowWarnPixel + + # Safe zone (middle) + safeRight = (self.barLeft + highWarnPixel) if highWarnPixel is not None else self.barRight + safeWidth = safeRight - currentLeft + if safeWidth > 0: + pen.setColor(self.safeColor) + p.setPen(pen) + p.setBrush(self.safeColor) + p.drawRect(QRectF(currentLeft, self.barTop, safeWidth, self.barHeight)) + currentLeft = safeRight + + # High warning zone + if highWarnPixel is not None: + highWarnRight = (self.barLeft + highAlarmPixel) if highAlarmPixel is not None else self.barRight + warnWidth = highWarnRight - currentLeft + if warnWidth > 0: + pen.setColor(self.warnColor) + p.setPen(pen) + p.setBrush(self.warnColor) + p.drawRect(QRectF(currentLeft, self.barTop, warnWidth, self.barHeight)) + currentLeft = highWarnRight + + # Right alarm zone (high alarm) + if highAlarmPixel is not None: + alarmWidth = self.barRight - currentLeft + if alarmWidth > 0: + pen.setColor(self.alarmColor) + p.setPen(pen) + p.setBrush(self.alarmColor) + p.drawRect(QRectF(currentLeft, self.barTop, alarmWidth, self.barHeight)) + + # Draw segments if needed (simplified) + if self.segments > 1: # Only draw if > 1 + segment_gap = self.barWidth * self.segment_gap_percent + segment_size = (self.barWidth - (segment_gap * (self.segments - 1))) / self.segments + pen.setColor(Qt.GlobalColor.black) + p.setPen(pen) + p.setBrush(Qt.GlobalColor.black) + + for segment in range(self.segments - 1): + seg_left = self.barLeft + round((segment + 1) * segment_size + segment * segment_gap) + gap_width = max(1, round(segment_gap)) # At least 1 pixel + p.drawRect(QRectF(seg_left, self.barTop, gap_width, self.barHeight)) + + # Highlight ball + if self.highlight: + pen.setColor(Qt.GlobalColor.black) + pen.setWidth(1) + p.setPen(pen) + p.setBrush(self.highlightColor) + p.drawEllipse(self.ballCenter, self.ballRadius, self.ballRadius) + + # Peak value line + if self.peakMode: + pen.setColor(QColor(Qt.GlobalColor.white)) + brush = QBrush(self.peakColor) + pen.setWidth(1) + p.setPen(pen) + p.setBrush(brush) + x = self.barLeft + self.interpolate(self.peakValue, self.barWidth) + x = max(self.barLeft, min(self.barRight, x)) + p.drawRect(qRound(x - 2), qRound(self.lineTop), qRound(4), qRound(self.lineHeight)) + + # Indicator (filled bar effect or line) + brush = QBrush(self.penColor) + pen.setWidth(1) + p.setBrush(brush) + + pen.setColor(QColor(Qt.GlobalColor.darkGray)) + p.setPen(pen) + valuePixel = self.barLeft + self.interpolate(self._value, self.barWidth) + valuePixel = max(self.barLeft, min(self.barRight, valuePixel)) + + if self.segments > 0: + # Filled bar effect - darken to the right of the value + pen.setColor(QColor(0, 0, 0, self.segment_alpha)) + p.setPen(pen) + p.setBrush(QColor(0, 0, 0, self.segment_alpha)) + darkenWidth = self.barRight - valuePixel + if darkenWidth > 0: + p.drawRect(QRectF(valuePixel, self.barTop, darkenWidth, self.barHeight)) + else: + # Traditional line indicator + p.drawRect(QRectF(valuePixel - 2, self.lineTop, 4, self.lineHeight)) diff --git a/src/pyefis/instruments/gauges/verticalBarImproved.py b/src/pyefis/instruments/gauges/verticalBarImproved.py new file mode 100644 index 0000000..0bc6b20 --- /dev/null +++ b/src/pyefis/instruments/gauges/verticalBarImproved.py @@ -0,0 +1,249 @@ +# Copyright (c) 2013 Phil Birkelbach +# Copyright (c) 2025 Improved alignment version +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +from PyQt6.QtGui import * +from PyQt6.QtCore import * +from PyQt6.QtWidgets import * + +from .verticalBar import VerticalBar as VerticalBarBase + + +class VerticalBarImproved(VerticalBarBase): + """ + Improved vertical bar with better alignment for color bands. + + Key improvements: + - Consistent pixel rounding for all thresholds + - Color bands calculated from absolute positions + - Cleaner segment rendering + """ + + def __init__(self, parent=None, min_size=True, font_family="DejaVu Sans Condensed"): + super().__init__(parent, min_size, font_family) + + def _calculateThresholdPixel(self, value): + """ + Calculate pixel position for a threshold value with consistent rounding. + Returns position from barTop (0 = top of bar, barHeight = bottom). + """ + if value is None or self.highRange == self.lowRange: + return None + + # Calculate normalized position (0.0 to 1.0) + normalized = (value - self.lowRange) / (self.highRange - self.lowRange) + normalized = max(0.0, min(1.0, normalized)) # Clamp to valid range + + # Convert to pixel position (inverted: high values = low pixels) + pixelFromBottom = normalized * self.barHeight + pixelFromTop = self.barHeight - pixelFromBottom + + # Round to nearest pixel for consistent positioning + return round(pixelFromTop) + + def paintEvent(self, event): + # Check highlight status + if self.highlight_key: + if self._highlightValue == self._rawValue: + self.highlight = True + else: + self.highlight = False + + p = QPainter(self) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + + pen = QPen() + pen.setWidth(1) + pen.setCapStyle(Qt.PenCapStyle.FlatCap) + p.setPen(pen) + + # Draw name + opt = QTextOption(Qt.AlignmentFlag.AlignCenter) + if self.show_name: + if self.name_font_ghost_mask: + opt = QTextOption(Qt.AlignmentFlag.AlignLeft) + alpha = self.textColor.alpha() + self.textColor.setAlpha(self.font_ghost_alpha) + pen.setColor(self.textColor) + p.setPen(pen) + p.setFont(self.smallFont) + p.drawText(self.nameTextRect, self.name_font_ghost_mask, opt) + self.textColor.setAlpha(alpha) + pen.setColor(self.textColor) + p.setPen(pen) + p.setFont(self.smallFont) + p.drawText(self.nameTextRect, self.name, opt) + + # Draw value + if self.show_value: + if self.peakMode: + dv = self.value - self.peakValue + if dv <= -10: + pen.setColor(self.peakColor) + p.setFont(self.bigFont) + p.setPen(pen) + p.drawText(self.valueTextRect, str(round(dv)), opt) + else: + self.drawValue(p, pen) + else: + self.drawValue(p, pen) + + # Draw units + opt = QTextOption(Qt.AlignmentFlag.AlignCenter) + pen.setColor(self.textColor) + p.setPen(pen) + if self.show_units: + if self.units_font_mask: + opt = QTextOption(Qt.AlignmentFlag.AlignRight) + p.setFont(self.unitsFont) + if self.units_font_ghost_mask: + alpha = self.textColor.alpha() + self.textColor.setAlpha(self.font_ghost_alpha) + pen.setColor(self.textColor) + p.setPen(pen) + p.drawText(self.unitsTextRect, self.units_font_ghost_mask, opt) + self.textColor.setAlpha(alpha) + pen.setColor(self.textColor) + p.setPen(pen) + p.drawText(self.unitsTextRect, self.units, opt) + else: + p.setFont(self.smallFont) + p.drawText(self.unitsTextRect, self.units, opt) + + # ===== IMPROVED BAR DRAWING WITH CONSISTENT ALIGNMENT ===== + p.setRenderHint(QPainter.RenderHint.Antialiasing, False) + + # Calculate all threshold positions once with consistent rounding + lowAlarmPixel = self._calculateThresholdPixel(self.lowAlarm) if self.lowAlarm and self.lowAlarm >= self.lowRange else None + lowWarnPixel = self._calculateThresholdPixel(self.lowWarn) if self.lowWarn and self.lowWarn >= self.lowRange else None + highWarnPixel = self._calculateThresholdPixel(self.highWarn) if self.highWarn and self.highWarn <= self.highRange else None + highAlarmPixel = self._calculateThresholdPixel(self.highAlarm) if self.highAlarm and self.highAlarm <= self.highRange else None + + # Draw the bar in sections from top to bottom + currentTop = self.barTop + + # Top alarm zone (high alarm) + if highAlarmPixel is not None: + alarmHeight = highAlarmPixel - currentTop + if alarmHeight > 0: + pen.setColor(self.alarmColor) + p.setPen(pen) + p.setBrush(self.alarmColor) + p.drawRect(QRectF(self.barLeft, currentTop, self.barWidth, alarmHeight)) + currentTop = highAlarmPixel + + # High warning zone + if highWarnPixel is not None: + warnHeight = highWarnPixel - currentTop + if warnHeight > 0: + pen.setColor(self.warnColor) + p.setPen(pen) + p.setBrush(self.warnColor) + p.drawRect(QRectF(self.barLeft, currentTop, self.barWidth, warnHeight)) + currentTop = highWarnPixel + + # Safe zone (middle) + safeBottom = lowWarnPixel if lowWarnPixel is not None else self.barBottom + safeHeight = safeBottom - currentTop + if safeHeight > 0: + pen.setColor(self.safeColor) + p.setPen(pen) + p.setBrush(self.safeColor) + p.drawRect(QRectF(self.barLeft, currentTop, self.barWidth, safeHeight)) + currentTop = safeBottom + + # Low warning zone + if lowWarnPixel is not None: + lowWarnBottom = lowAlarmPixel if lowAlarmPixel is not None else self.barBottom + warnHeight = lowWarnBottom - currentTop + if warnHeight > 0: + pen.setColor(self.warnColor) + p.setPen(pen) + p.setBrush(self.warnColor) + p.drawRect(QRectF(self.barLeft, currentTop, self.barWidth, warnHeight)) + currentTop = lowWarnBottom + + # Bottom alarm zone (low alarm) + if lowAlarmPixel is not None: + alarmHeight = self.barBottom - currentTop + if alarmHeight > 0: + pen.setColor(self.alarmColor) + p.setPen(pen) + p.setBrush(self.alarmColor) + p.drawRect(QRectF(self.barLeft, currentTop, self.barWidth, alarmHeight)) + + # Draw segments if needed (simplified) + if self.segments > 1: # Only draw if > 1 + segment_gap = self.barHeight * self.segment_gap_percent + segment_size = (self.barHeight - (segment_gap * (self.segments - 1))) / self.segments + pen.setColor(Qt.GlobalColor.black) + p.setPen(pen) + p.setBrush(Qt.GlobalColor.black) + + for segment in range(self.segments - 1): + seg_top = self.barTop + round((segment + 1) * segment_size + segment * segment_gap) + gap_height = max(1, round(segment_gap)) # At least 1 pixel + p.drawRect(QRectF(self.barLeft, seg_top, self.barWidth, gap_height)) + + # Highlight ball + if self.highlight: + pen.setColor(Qt.GlobalColor.black) + pen.setWidth(1) + p.setPen(pen) + p.setBrush(self.highlightColor) + p.drawEllipse(self.ballCenter, self.ballRadius, self.ballRadius) + + # Peak value line + if self.peakMode: + pen.setColor(QColor(Qt.GlobalColor.white)) + brush = QBrush(self.peakColor) + pen.setWidth(1) + p.setPen(pen) + p.setBrush(brush) + if self.normalizeMode and self.normalize_range > 0: + nval = self.peakValue - self.normalizeReference + start = self.barTop + self.barHeight / 2 + y = start - (nval * self.barHeight / self.normalize_range) + else: + y = self.barTop + (self.barHeight - self.interpolate(self.peakValue, self.barHeight)) + y = max(self.barTop, min(self.barBottom, y)) + p.drawRect(qRound(self.lineLeft), qRound(y - 2), qRound(self.lineWidth), qRound(4)) + + # Indicator (filled bar effect or line) + brush = QBrush(self.penColor) + pen.setWidth(1) + p.setBrush(brush) + + if self.normalizeMode and self.normalize_range > 0: + pen.setColor(QColor(Qt.GlobalColor.gray)) + p.setPen(pen) + nval = self._value - self.normalizeReference + start = self.barTop + self.barHeight / 2 + valuePixel = start - (nval * self.barHeight / self.normalize_range) + else: + pen.setColor(QColor(Qt.GlobalColor.darkGray)) + p.setPen(pen) + valuePixel = self.barTop + (self.barHeight - self.interpolate(self._value, self.barHeight)) + + valuePixel = max(self.barTop, min(self.barBottom, valuePixel)) + + if self.segments > 0: + # Filled bar effect - darken above the value + pen.setColor(QColor(0, 0, 0, self.segment_alpha)) + p.setPen(pen) + p.setBrush(QColor(0, 0, 0, self.segment_alpha)) + darkenHeight = valuePixel - self.barTop + if darkenHeight > 0: + p.drawRect(QRectF(self.barLeft, self.barTop, self.barWidth, darkenHeight)) + else: + # Traditional line indicator + p.drawRect(QRectF(self.lineLeft, valuePixel - 2, self.lineWidth, 4)) diff --git a/src/pyefis/screens/screenbuilder.py b/src/pyefis/screens/screenbuilder.py index 1e8fff5..500f3ba 100644 --- a/src/pyefis/screens/screenbuilder.py +++ b/src/pyefis/screens/screenbuilder.py @@ -510,8 +510,12 @@ def setup_instruments(self,count,i,ganged=False,replace=None,state=False): self.instruments[count] = gauges.ArcGauge(self,min_size=False,font_family=font_family) elif i['type'] == 'horizontal_bar_gauge': self.instruments[count] = gauges.HorizontalBar(self,min_size=False,font_family=font_family) + elif i['type'] == 'horizontal_bar_gauge_improved': + self.instruments[count] = gauges.HorizontalBarImproved(self,min_size=False,font_family=font_family) elif i['type'] == 'vertical_bar_gauge': self.instruments[count] = gauges.VerticalBar(self,min_size=False,font_family=font_family) + elif i['type'] == 'vertical_bar_gauge_improved': + self.instruments[count] = gauges.VerticalBarImproved(self,min_size=False,font_family=font_family) elif i['type'] == 'virtual_vfr': self.instruments[count] = VirtualVfr(self,font_percent=font_percent,font_family=font_family) From 01df9faea9e81b0b3f8bf53fb28c6186e7b4e504 Mon Sep 17 00:00:00 2001 From: TerryH <32078938+tjh590@users.noreply.github.com> Date: Tue, 4 Nov 2025 21:23:04 -0500 Subject: [PATCH 09/20] improved bars #2 --- IMPROVED_GAUGES.md | 299 ++++++++++++------ src/pyefis/instruments/gauges/__init__.py | 2 + .../instruments/gauges/horizontalBarSimple.py | 170 ++++++++++ .../instruments/gauges/verticalBarSimple.py | 181 +++++++++++ src/pyefis/screens/screenbuilder.py | 4 + 5 files changed, 555 insertions(+), 101 deletions(-) create mode 100644 src/pyefis/instruments/gauges/horizontalBarSimple.py create mode 100644 src/pyefis/instruments/gauges/verticalBarSimple.py diff --git a/IMPROVED_GAUGES.md b/IMPROVED_GAUGES.md index dd458a7..247fe76 100644 --- a/IMPROVED_GAUGES.md +++ b/IMPROVED_GAUGES.md @@ -1,152 +1,249 @@ -# Improved Bar Gauges - Migration Guide +# Simplified Bar Gauges - Clean Single-Color Design -## What's Been Improved +## The Simple Approach -I've created **improved versions** of the vertical and horizontal bar gauges that fix the color band alignment issue you were experiencing. The key improvements: +I've created **truly simplified versions** of the vertical and horizontal bar gauges that eliminate ALL alignment issues by using a **single-color approach**. -### 1. **Consistent Pixel Calculation** -- All threshold positions (low alarm, low warn, high warn, high alarm) are calculated using a single `_calculateThresholdPixel()` method -- Uses `round()` consistently for all positions to avoid accumulating floating-point errors -- Color bands are drawn from absolute pixel positions, not relative offsets +### 🎯 How It Works -### 2. **Cleaner Drawing Logic** -- Color bands are drawn in sequential sections (top-to-bottom for vertical, left-to-right for horizontal) -- Each section knows its exact start and end position -- No more cumulative rounding errors between bands +Instead of drawing multiple color bands (green/yellow/red zones), the **entire bar changes color** based on the current value: -### 3. **Simplified Segment Rendering** -- Segments only draw when `segments > 1` (no gap lines for `segments: 1`) -- Gap calculation is cleaner and more predictable -- Filled bar effect (darkening) is applied separately and cleanly +- **🟢 Green bar** = Value is in the safe range +- **🟡 Yellow bar** = Value is in warning range (high or low) +- **🔴 Red bar** = Value is in alarm range (high or low) -## Files Created +This is like a modern "traffic light" gauge - the whole bar tells you the status at a glance. -1. **`src/pyefis/instruments/gauges/verticalBarImproved.py`** - - Improved vertical bar gauge class - - Inherits from original `VerticalBar` to preserve all functionality +### ✅ Benefits + +1. **Zero alignment issues** - No color bands to align! +2. **Cleaner appearance** - Modern, minimalist design +3. **Easier to read** - Color indicates status instantly +4. **Simpler code** - No complex segment or threshold calculations +5. **Better performance** - Fewer drawing operations + +## Files Created & Modified + +### New Gauge Classes: +1. **`src/pyefis/instruments/gauges/verticalBarSimple.py`** ✅ + - Simplified vertical bar with single-color approach + - 182 lines, complete implementation -2. **`src/pyefis/instruments/gauges/horizontalBarImproved.py`** - - Improved horizontal bar gauge class - - Inherits from original `HorizontalBar` to preserve all functionality +2. **`src/pyefis/instruments/gauges/horizontalBarSimple.py`** ✅ + - Simplified horizontal bar with single-color approach + - 171 lines, complete implementation -3. **Updated `src/pyefis/instruments/gauges/__init__.py`** - - Exported both new gauge types +### Updated Files: +3. **`src/pyefis/instruments/gauges/__init__.py`** ✅ + - Added imports for `HorizontalBarSimple` and `VerticalBarSimple` -4. **Updated `src/pyefis/screens/screenbuilder.py`** - - Added support for new gauge types: `vertical_bar_gauge_improved` and `horizontal_bar_gauge_improved` +4. **`src/pyefis/screens/screenbuilder.py`** ✅ + - Added support for `vertical_bar_gauge_simple` type + - Added support for `horizontal_bar_gauge_simple` type -## How to Use +5. **`config/preferences.yaml.custom`** ✅ + - All 12 EGT/CHT bars (BAR11-22) configured to use `vertical_bar_gauge_simple` -You have **two options**: +## Visual Comparison + +### Old Multi-Band Approach: +``` +EGT Bar: +┌───┐ +│RED│ ← High alarm zone (>650°C) +├───┤ +│YEL│ ← High warn zone (620-650°C) +├───┤ +│GRN│ ← Safe zone +│███│ ← Current value (filled) +│GRN│ +├───┤ +│YEL│ ← Low warn zone +├───┤ +│RED│ ← Low alarm zone +└───┘ + +Problem: Color bands can misalign between bars +``` + +### New Single-Color Approach: +``` +EGT at 550°C (safe): EGT at 630°C (warn): EGT at 660°C (alarm): +┌───┐ ┌───┐ ┌───┐ +│ │ │ │ │ │ +│ │ │ │ │ │ +│ │ │YEL│ │RED│ +│GRN│ ← Filled │YEL│ ← Filled │RED│ ← Filled +│GRN│ │YEL│ │RED│ +└───┘ └───┘ └───┘ + +Benefit: Entire bar changes color - no alignment issues! +``` -### Option 1: Override in preferences.yaml.custom (Recommended - Easy to Revert) +## Configuration -Since your EGT/CHT bars are defined using ganged instruments that reference BAR11-14, BAR19-22, you can simply add the `type:` override to each bar in `config/preferences.yaml.custom`: +Your `config/preferences.yaml.custom` is **already configured** with the simple gauges: ```yaml gauges: - # EGT bars + # EGT for cylinders 1-4 BAR11: - type: vertical_bar_gauge_improved + type: vertical_bar_gauge_simple BAR12: - type: vertical_bar_gauge_improved + type: vertical_bar_gauge_simple BAR13: - type: vertical_bar_gauge_improved + type: vertical_bar_gauge_simple BAR14: - type: vertical_bar_gauge_improved - BAR19: - type: vertical_bar_gauge_improved - BAR20: - type: vertical_bar_gauge_improved + type: vertical_bar_gauge_simple - # CHT bars + # CHT for cylinders 1-4 BAR15: - type: vertical_bar_gauge_improved + type: vertical_bar_gauge_simple BAR16: - type: vertical_bar_gauge_improved + type: vertical_bar_gauge_simple BAR17: - type: vertical_bar_gauge_improved + type: vertical_bar_gauge_simple BAR18: - type: vertical_bar_gauge_improved + type: vertical_bar_gauge_simple + + # EGT for cylinders 5-6 + BAR19: + type: vertical_bar_gauge_simple + # ... cylinder 5 settings + BAR20: + type: vertical_bar_gauge_simple + # ... cylinder 6 settings + + # CHT for cylinders 5-6 BAR21: - type: vertical_bar_gauge_improved + type: vertical_bar_gauge_simple + # ... cylinder 5 settings BAR22: - type: vertical_bar_gauge_improved + type: vertical_bar_gauge_simple + # ... cylinder 6 settings ``` -This approach: -- ✅ Doesn't modify any include files -- ✅ Easy to test and revert -- ✅ Works with ganged bar layouts -- ✅ Can be version controlled separately +## How to Test -### Option 2: Modify Include Files (Permanent Change) +**Restart pyEfis to see the new single-color bars:** -If you want to make the change permanent, you can modify the include files directly. However, note that ganged instruments work differently - they reference individual BAR preferences, not direct type specifications. - -**For standalone bars** (not ganged), you would change: -```yaml -instruments: - - type: vertical_bar_gauge_improved - # ... other settings +```bash +cd ~/makerplane/pyefis +python pyEfis.py ``` -But since you're using ganged layouts (`ganged_vertical_bar_gauge`), **Option 1 is the better approach**. - -## Testing +## What to Expect -1. **Backup your current config** (already done in preferences.yaml.custom) +With the simple gauges, you'll see: +- ✅ **Entire bar is one solid color** - Green, yellow, or red +- ✅ **Bar fills from bottom to top** showing current value +- ✅ **Color indicates status** - No need to look at which zone the value is in +- ✅ **Zero alignment issues** - No color bands to align! +- ✅ **Clean, modern appearance** - Minimalist design +- ✅ **All functionality preserved** - Highlights, peak mode, normalize mode still work +- ✅ **Magenta circles still work** - Highlighting the hottest cylinder (EGTMAX1/CHTMAX1) -2. **Choose your approach** (Option 1 or Option 2 above) +### Color Logic: +The bar color is determined by the **current value** vs thresholds: -3. **Restart pyEfis**: - ```bash - cd ~/makerplane/pyefis - python pyEfis.py - ``` +1. **🔴 Red (Alarm)**: + - EGT ≥ 650°C (highAlarm) + - CHT ≥ 232°C (highAlarm) + - Or values below lowAlarm -4. **Look for alignment improvements**: - - Color bands (green/yellow/red) should align perfectly between bars - - No more "shifted" appearance - - Transitions between colors should be at exactly the same pixel height +2. **🟡 Yellow (Warning)**: + - EGT ≥ 620°C (highWarn) but < 650°C + - CHT ≥ 204°C (highWarn) but < 232°C + - Or values below lowWarn but above lowAlarm -## What to Expect +3. **🟢 Green (Safe)**: + - All other values (between lowWarn and highWarn) -With the improved gauges and your current settings: -- **`segments: 1`** - Gives you solid thermometer-style bars with no visible segment gaps -- **`segment_alpha: 250`** - Strong darkening effect for the unfilled portion -- **Aligned color bands** - All bars will show color transitions at identical pixel positions +## Alternative Gauge Options -## Reverting +You have three gauge types available: -If you want to go back to the original gauges: -- Simply change `vertical_bar_gauge_improved` back to `vertical_bar_gauge` -- Or remove the `type:` override from preferences.yaml.custom +### 1. **Simple (Currently Active)** - Recommended ✅ +```yaml +type: vertical_bar_gauge_simple +``` +- Entire bar changes color based on value +- Clean, modern look +- No alignment issues -## Technical Details +### 2. **Improved** - Multi-band with fixed alignment +```yaml +type: vertical_bar_gauge_improved +``` +- Traditional color bands (green/yellow/red zones) +- Better pixel alignment than original +- Use if you prefer the traditional multi-band look -The original implementation calculated each color band's height relative to the previous band, which could accumulate rounding errors: -```python -# Old way (simplified) -green_height = threshold2 - threshold1 # Might be 100.3 pixels -yellow_height = threshold3 - threshold2 # Might be 50.7 pixels -# After rounding, you get 100 + 51 = 151, but should be 150 +### 3. **Original** - Default multi-band +```yaml +type: vertical_bar_gauge ``` +- Original implementation +- Has the alignment issue you reported +- Available for comparison + +## Technical Implementation + +### Simple Gauge Approach + +The simple gauge uses a single `_getBarColor()` method that checks the current value against thresholds: -The improved implementation calculates from absolute positions: ```python -# New way (simplified) -green_top = round(calculate_pixel(threshold1)) # 50 -green_bottom = round(calculate_pixel(threshold2)) # 150 -yellow_bottom = round(calculate_pixel(threshold3)) # 200 -# Each band knows its exact position, no accumulation +def _getBarColor(self): + """Determine bar color based on current value and thresholds.""" + value = self._value + + # Check high alarm first (highest priority) + if self.highAlarm is not None and value >= self.highAlarm: + return self.alarmColor # Red + + # Check high warning + if self.highWarn is not None and value >= self.highWarn: + return self.warnColor # Yellow + + # Check low alarm + if self.lowAlarm is not None and value <= self.lowAlarm: + return self.alarmColor # Red + + # Check low warning + if self.lowWarn is not None and value <= self.lowWarn: + return self.warnColor # Yellow + + # Default to safe + return self.safeColor # Green ``` -## Need More Help? +### Drawing Process + +1. Draw dark gray background (empty portion) +2. Calculate value pixel position +3. Get bar color based on `_getBarColor()` +4. Draw filled portion from bottom to value position in that color +5. Draw highlight ball if this is the hottest cylinder +6. Draw peak line if in peak mode + +This approach is: +- ✅ **Simpler** - One color calculation instead of multiple band calculations +- ✅ **Faster** - Fewer draw operations +- ✅ **More reliable** - No pixel rounding or alignment issues +- ✅ **Easier to understand** - Color = status, instantly readable +- ✅ **Inherits from original** - All features (normalize, peak, temperature conversion) preserved + +## Summary + +All files are created and your configuration is ready: -If the alignment still looks off after trying the improved gauges, we can: -1. Check if the pixel rounding algorithm needs adjustment -2. Verify the threshold values in FIX Gateway are correct -3. Look at the bar layout calculations in screenbuilder +| File | Status | Lines | +|------|--------|-------| +| `verticalBarSimple.py` | ✅ Created | 182 | +| `horizontalBarSimple.py` | ✅ Created | 171 | +| `gauges/__init__.py` | ✅ Updated | Imports added | +| `screenbuilder.py` | ✅ Updated | Types registered | +| `preferences.yaml.custom` | ✅ Updated | All bars configured | -Let me know how it works! +**Ready to run!** Just restart pyEfis and you'll see the new clean single-color bars. 🚀 diff --git a/src/pyefis/instruments/gauges/__init__.py b/src/pyefis/instruments/gauges/__init__.py index 52e26c0..2ee3eff 100644 --- a/src/pyefis/instruments/gauges/__init__.py +++ b/src/pyefis/instruments/gauges/__init__.py @@ -19,6 +19,8 @@ from .verticalBar import VerticalBar from .horizontalBarImproved import HorizontalBarImproved from .verticalBarImproved import VerticalBarImproved +from .horizontalBarSimple import HorizontalBarSimple +from .verticalBarSimple import VerticalBarSimple from .arc import ArcGauge from .numeric import NumericDisplay from .egt import EGTGroup diff --git a/src/pyefis/instruments/gauges/horizontalBarSimple.py b/src/pyefis/instruments/gauges/horizontalBarSimple.py new file mode 100644 index 0000000..1492a05 --- /dev/null +++ b/src/pyefis/instruments/gauges/horizontalBarSimple.py @@ -0,0 +1,170 @@ +# Copyright (c) 2013 Phil Birkelbach +# Copyright (c) 2025 Simplified single-color version +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +from PyQt6.QtGui import * +from PyQt6.QtCore import * +from PyQt6.QtWidgets import * + +from .horizontalBar import HorizontalBar as HorizontalBarBase + + +class HorizontalBarSimple(HorizontalBarBase): + """ + Simplified horizontal bar that changes color based on value. + + No color bands, no segments - just a clean filled bar that changes + color (green/yellow/red) based on the current value and thresholds. + + This eliminates ALL alignment issues and gives a clean, modern look. + """ + + def __init__(self, parent=None, min_size=True, font_family="DejaVu Sans Condensed"): + super().__init__(parent, min_size, font_family) + + def _getBarColor(self): + """ + Determine the bar color based on current value and thresholds. + Returns the appropriate color (safe, warn, or alarm). + """ + value = self._value + + # Check high alarm + if self.highAlarm is not None and value >= self.highAlarm: + return self.alarmColor + + # Check high warning + if self.highWarn is not None and value >= self.highWarn: + return self.warnColor + + # Check low alarm + if self.lowAlarm is not None and value <= self.lowAlarm: + return self.alarmColor + + # Check low warning + if self.lowWarn is not None and value <= self.lowWarn: + return self.warnColor + + # Default to safe color + return self.safeColor + + def paintEvent(self, event): + # Check highlight status + if self.highlight_key: + if self._highlightValue == self._rawValue: + self.highlight = True + else: + self.highlight = False + + p = QPainter(self) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + + pen = QPen() + pen.setWidth(1) + pen.setCapStyle(Qt.PenCapStyle.FlatCap) + p.setPen(pen) + + # Draw name + opt = QTextOption(Qt.AlignmentFlag.AlignCenter) + if self.show_name: + if self.name_font_ghost_mask: + opt = QTextOption(Qt.AlignmentFlag.AlignLeft) + alpha = self.textColor.alpha() + self.textColor.setAlpha(self.font_ghost_alpha) + pen.setColor(self.textColor) + p.setPen(pen) + p.setFont(self.smallFont) + p.drawText(self.nameTextRect, self.name_font_ghost_mask, opt) + self.textColor.setAlpha(alpha) + pen.setColor(self.textColor) + p.setPen(pen) + p.setFont(self.smallFont) + p.drawText(self.nameTextRect, self.name, opt) + + # Draw value + if self.show_value: + if self.peakMode: + dv = self.value - self.peakValue + if dv <= -10: + pen.setColor(self.peakColor) + p.setFont(self.bigFont) + p.setPen(pen) + p.drawText(self.valueTextRect, str(round(dv)), opt) + else: + self.drawValue(p, pen) + else: + self.drawValue(p, pen) + + # Draw units + opt = QTextOption(Qt.AlignmentFlag.AlignCenter) + pen.setColor(self.textColor) + p.setPen(pen) + if self.show_units: + if self.units_font_mask: + opt = QTextOption(Qt.AlignmentFlag.AlignRight) + p.setFont(self.unitsFont) + if self.units_font_ghost_mask: + alpha = self.textColor.alpha() + self.textColor.setAlpha(self.font_ghost_alpha) + pen.setColor(self.textColor) + p.setPen(pen) + p.drawText(self.unitsTextRect, self.units_font_ghost_mask, opt) + self.textColor.setAlpha(alpha) + pen.setColor(self.textColor) + p.setPen(pen) + p.drawText(self.unitsTextRect, self.units, opt) + else: + p.setFont(self.smallFont) + p.drawText(self.unitsTextRect, self.units, opt) + + # ===== SIMPLIFIED BAR DRAWING - SINGLE COLOR, NO BANDS ===== + p.setRenderHint(QPainter.RenderHint.Antialiasing, False) + + # Get the color for the entire bar based on current value + barColor = self._getBarColor() + + # Draw background (empty portion) - dark gray + pen.setColor(QColor(40, 40, 40)) + p.setPen(pen) + p.setBrush(QColor(40, 40, 40)) + p.drawRect(QRectF(self.barLeft, self.barTop, self.barWidth, self.barHeight)) + + # Calculate value position + valuePixel = self.barLeft + self.interpolate(self._value, self.barWidth) + valuePixel = max(self.barLeft, min(self.barRight, valuePixel)) + + # Draw filled portion in the appropriate color + filledWidth = valuePixel - self.barLeft + if filledWidth > 0: + pen.setColor(barColor) + p.setPen(pen) + p.setBrush(barColor) + p.drawRect(QRectF(self.barLeft, self.barTop, filledWidth, self.barHeight)) + + # Highlight ball + if self.highlight: + pen.setColor(Qt.GlobalColor.black) + pen.setWidth(1) + p.setPen(pen) + p.setBrush(self.highlightColor) + p.drawEllipse(self.ballCenter, self.ballRadius, self.ballRadius) + + # Peak value line + if self.peakMode: + pen.setColor(QColor(Qt.GlobalColor.white)) + brush = QBrush(self.peakColor) + pen.setWidth(1) + p.setPen(pen) + p.setBrush(brush) + x = self.barLeft + self.interpolate(self.peakValue, self.barWidth) + x = max(self.barLeft, min(self.barRight, x)) + p.drawRect(qRound(x - 2), qRound(self.lineTop), qRound(4), qRound(self.lineHeight)) diff --git a/src/pyefis/instruments/gauges/verticalBarSimple.py b/src/pyefis/instruments/gauges/verticalBarSimple.py new file mode 100644 index 0000000..1b9f794 --- /dev/null +++ b/src/pyefis/instruments/gauges/verticalBarSimple.py @@ -0,0 +1,181 @@ +# Copyright (c) 2013 Phil Birkelbach +# Copyright (c) 2025 Simplified single-color version +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +from PyQt6.QtGui import * +from PyQt6.QtCore import * +from PyQt6.QtWidgets import * + +from .verticalBar import VerticalBar as VerticalBarBase + + +class VerticalBarSimple(VerticalBarBase): + """ + Simplified vertical bar that changes color based on value. + + No color bands, no segments - just a clean filled bar that changes + color (green/yellow/red) based on the current value and thresholds. + + This eliminates ALL alignment issues and gives a clean, modern look. + """ + + def __init__(self, parent=None, min_size=True, font_family="DejaVu Sans Condensed"): + super().__init__(parent, min_size, font_family) + + def _getBarColor(self): + """ + Determine the bar color based on current value and thresholds. + Returns the appropriate color (safe, warn, or alarm). + """ + value = self._value + + # Check high alarm + if self.highAlarm is not None and value >= self.highAlarm: + return self.alarmColor + + # Check high warning + if self.highWarn is not None and value >= self.highWarn: + return self.warnColor + + # Check low alarm + if self.lowAlarm is not None and value <= self.lowAlarm: + return self.alarmColor + + # Check low warning + if self.lowWarn is not None and value <= self.lowWarn: + return self.warnColor + + # Default to safe color + return self.safeColor + + def paintEvent(self, event): + # Check highlight status + if self.highlight_key: + if self._highlightValue == self._rawValue: + self.highlight = True + else: + self.highlight = False + + p = QPainter(self) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + + pen = QPen() + pen.setWidth(1) + pen.setCapStyle(Qt.PenCapStyle.FlatCap) + p.setPen(pen) + + # Draw name + opt = QTextOption(Qt.AlignmentFlag.AlignCenter) + if self.show_name: + if self.name_font_ghost_mask: + opt = QTextOption(Qt.AlignmentFlag.AlignLeft) + alpha = self.textColor.alpha() + self.textColor.setAlpha(self.font_ghost_alpha) + pen.setColor(self.textColor) + p.setPen(pen) + p.setFont(self.smallFont) + p.drawText(self.nameTextRect, self.name_font_ghost_mask, opt) + self.textColor.setAlpha(alpha) + pen.setColor(self.textColor) + p.setPen(pen) + p.setFont(self.smallFont) + p.drawText(self.nameTextRect, self.name, opt) + + # Draw value + if self.show_value: + if self.peakMode: + dv = self.value - self.peakValue + if dv <= -10: + pen.setColor(self.peakColor) + p.setFont(self.bigFont) + p.setPen(pen) + p.drawText(self.valueTextRect, str(round(dv)), opt) + else: + self.drawValue(p, pen) + else: + self.drawValue(p, pen) + + # Draw units + opt = QTextOption(Qt.AlignmentFlag.AlignCenter) + pen.setColor(self.textColor) + p.setPen(pen) + if self.show_units: + if self.units_font_mask: + opt = QTextOption(Qt.AlignmentFlag.AlignRight) + p.setFont(self.unitsFont) + if self.units_font_ghost_mask: + alpha = self.textColor.alpha() + self.textColor.setAlpha(self.font_ghost_alpha) + pen.setColor(self.textColor) + p.setPen(pen) + p.drawText(self.unitsTextRect, self.units_font_ghost_mask, opt) + self.textColor.setAlpha(alpha) + pen.setColor(self.textColor) + p.setPen(pen) + p.drawText(self.unitsTextRect, self.units, opt) + else: + p.setFont(self.smallFont) + p.drawText(self.unitsTextRect, self.units, opt) + + # ===== SIMPLIFIED BAR DRAWING - SINGLE COLOR, NO BANDS ===== + p.setRenderHint(QPainter.RenderHint.Antialiasing, False) + + # Get the color for the entire bar based on current value + barColor = self._getBarColor() + + # Draw background (empty portion) - dark gray + pen.setColor(QColor(40, 40, 40)) + p.setPen(pen) + p.setBrush(QColor(40, 40, 40)) + p.drawRect(QRectF(self.barLeft, self.barTop, self.barWidth, self.barHeight)) + + # Calculate value position + if self.normalizeMode and self.normalize_range > 0: + nval = self._value - self.normalizeReference + start = self.barTop + self.barHeight / 2 + valuePixel = start - (nval * self.barHeight / self.normalize_range) + else: + valuePixel = self.barTop + (self.barHeight - self.interpolate(self._value, self.barHeight)) + + valuePixel = max(self.barTop, min(self.barBottom, valuePixel)) + + # Draw filled portion in the appropriate color + filledHeight = self.barBottom - valuePixel + if filledHeight > 0: + pen.setColor(barColor) + p.setPen(pen) + p.setBrush(barColor) + p.drawRect(QRectF(self.barLeft, valuePixel, self.barWidth, filledHeight)) + + # Highlight ball + if self.highlight: + pen.setColor(Qt.GlobalColor.black) + pen.setWidth(1) + p.setPen(pen) + p.setBrush(self.highlightColor) + p.drawEllipse(self.ballCenter, self.ballRadius, self.ballRadius) + + # Peak value line + if self.peakMode: + pen.setColor(QColor(Qt.GlobalColor.white)) + brush = QBrush(self.peakColor) + pen.setWidth(1) + p.setPen(pen) + p.setBrush(brush) + if self.normalizeMode and self.normalize_range > 0: + nval = self.peakValue - self.normalizeReference + start = self.barTop + self.barHeight / 2 + y = start - (nval * self.barHeight / self.normalize_range) + else: + y = self.barTop + (self.barHeight - self.interpolate(self.peakValue, self.barHeight)) + y = max(self.barTop, min(self.barBottom, y)) + p.drawRect(qRound(self.lineLeft), qRound(y - 2), qRound(self.lineWidth), qRound(4)) diff --git a/src/pyefis/screens/screenbuilder.py b/src/pyefis/screens/screenbuilder.py index 500f3ba..fb486a1 100644 --- a/src/pyefis/screens/screenbuilder.py +++ b/src/pyefis/screens/screenbuilder.py @@ -512,10 +512,14 @@ def setup_instruments(self,count,i,ganged=False,replace=None,state=False): self.instruments[count] = gauges.HorizontalBar(self,min_size=False,font_family=font_family) elif i['type'] == 'horizontal_bar_gauge_improved': self.instruments[count] = gauges.HorizontalBarImproved(self,min_size=False,font_family=font_family) + elif i['type'] == 'horizontal_bar_gauge_simple': + self.instruments[count] = gauges.HorizontalBarSimple(self,min_size=False,font_family=font_family) elif i['type'] == 'vertical_bar_gauge': self.instruments[count] = gauges.VerticalBar(self,min_size=False,font_family=font_family) elif i['type'] == 'vertical_bar_gauge_improved': self.instruments[count] = gauges.VerticalBarImproved(self,min_size=False,font_family=font_family) + elif i['type'] == 'vertical_bar_gauge_simple': + self.instruments[count] = gauges.VerticalBarSimple(self,min_size=False,font_family=font_family) elif i['type'] == 'virtual_vfr': self.instruments[count] = VirtualVfr(self,font_percent=font_percent,font_family=font_family) From dc40a1cacebc554da533e6de2958a6d77204972b Mon Sep 17 00:00:00 2001 From: TerryH <32078938+tjh590@users.noreply.github.com> Date: Tue, 4 Nov 2025 22:07:42 -0500 Subject: [PATCH 10/20] improved bars #3 --- src/pyefis/instruments/gauges/horizontalBarSimple.py | 9 +++++++++ src/pyefis/instruments/gauges/verticalBarSimple.py | 9 +++++++++ src/pyefis/screens/screenbuilder.py | 4 ++++ 3 files changed, 22 insertions(+) diff --git a/src/pyefis/instruments/gauges/horizontalBarSimple.py b/src/pyefis/instruments/gauges/horizontalBarSimple.py index 1492a05..232ef86 100644 --- a/src/pyefis/instruments/gauges/horizontalBarSimple.py +++ b/src/pyefis/instruments/gauges/horizontalBarSimple.py @@ -30,6 +30,15 @@ class HorizontalBarSimple(HorizontalBarBase): def __init__(self, parent=None, min_size=True, font_family="DejaVu Sans Condensed"): super().__init__(parent, min_size, font_family) + # Force segments to 0 to prevent any segment drawing + self._segments_locked = True + self.segments = 0 + + def __setattr__(self, name, value): + # Prevent segments from being changed after initialization + if name == 'segments' and hasattr(self, '_segments_locked'): + return # Ignore any attempts to set segments + super().__setattr__(name, value) def _getBarColor(self): """ diff --git a/src/pyefis/instruments/gauges/verticalBarSimple.py b/src/pyefis/instruments/gauges/verticalBarSimple.py index 1b9f794..4003a06 100644 --- a/src/pyefis/instruments/gauges/verticalBarSimple.py +++ b/src/pyefis/instruments/gauges/verticalBarSimple.py @@ -30,6 +30,15 @@ class VerticalBarSimple(VerticalBarBase): def __init__(self, parent=None, min_size=True, font_family="DejaVu Sans Condensed"): super().__init__(parent, min_size, font_family) + # Force segments to 0 to prevent any segment drawing + self._segments_locked = True + self.segments = 0 + + def __setattr__(self, name, value): + # Prevent segments from being changed after initialization + if name == 'segments' and hasattr(self, '_segments_locked'): + return # Ignore any attempts to set segments + super().__setattr__(name, value) def _getBarColor(self): """ diff --git a/src/pyefis/screens/screenbuilder.py b/src/pyefis/screens/screenbuilder.py index fb486a1..deac114 100644 --- a/src/pyefis/screens/screenbuilder.py +++ b/src/pyefis/screens/screenbuilder.py @@ -427,6 +427,10 @@ def setup_instruments(self,count,i,ganged=False,replace=None,state=False): i['options'] = i.get('options',dict())|pref # Merge gauge specific settings i['options'] = i.get('options',dict())|specific_pref + + # Allow type override from preferences + if 'type' in specific_pref: + i['type'] = specific_pref['type'] if 'styles' in specific_pref: for style in self.parent.preferences['style']: From f3a5a4666468497f61fbffbfff9f9c96622e5ef8 Mon Sep 17 00:00:00 2001 From: TerryH <32078938+tjh590@users.noreply.github.com> Date: Wed, 5 Nov 2025 12:25:11 -0500 Subject: [PATCH 11/20] horiz recoded to correct --- .../gauges/horizontalBarImproved.py | 255 ++++++------------ .../instruments/gauges/verticalBarImproved.py | 46 ++-- 2 files changed, 120 insertions(+), 181 deletions(-) diff --git a/src/pyefis/instruments/gauges/horizontalBarImproved.py b/src/pyefis/instruments/gauges/horizontalBarImproved.py index d82f794..2e61e2b 100644 --- a/src/pyefis/instruments/gauges/horizontalBarImproved.py +++ b/src/pyefis/instruments/gauges/horizontalBarImproved.py @@ -15,221 +15,144 @@ from PyQt6.QtCore import * from PyQt6.QtWidgets import * -from .horizontalBar import HorizontalBar as HorizontalBarBase +from .horizontalBar import HorizontalBar -class HorizontalBarImproved(HorizontalBarBase): - """ - Improved horizontal bar with better alignment for color bands. - - Key improvements: - - Consistent pixel rounding for all thresholds - - Color bands calculated from absolute positions - - Cleaner segment rendering - """ +class HorizontalBarImproved(HorizontalBar): + """Improved horizontal bar with better alignment for color bands.""" def __init__(self, parent=None, min_size=True, font_family="DejaVu Sans Condensed"): super().__init__(parent, min_size, font_family) def _calculateThresholdPixel(self, value): - """ - Calculate pixel position for a threshold value with consistent rounding. - Returns position from barLeft (0 = left of bar, barWidth = right). - """ + """Calculate pixel position for a threshold value with consistent rounding.""" if value is None or self.highRange == self.lowRange: return None - # Calculate normalized position (0.0 to 1.0) + barWidth = int(self.width()) + if barWidth <= 0: + return None + normalized = (value - self.lowRange) / (self.highRange - self.lowRange) - normalized = max(0.0, min(1.0, normalized)) # Clamp to valid range + normalized = max(0.0, min(1.0, normalized)) - # Convert to pixel position from left edge - pixelFromLeft = normalized * self.barWidth + scaledPosition = int(normalized * 1000) + pixelFromLeft = (scaledPosition * barWidth) // 1000 - # Round to nearest pixel for consistent positioning - return round(pixelFromLeft) + return pixelFromLeft def paintEvent(self, event): - # Check highlight status - if self.highlight_key: - if self._highlightValue == self._rawValue: - self.highlight = True - else: - self.highlight = False - p = QPainter(self) p.setRenderHint(QPainter.RenderHint.Antialiasing) - pen = QPen() pen.setWidth(1) pen.setCapStyle(Qt.PenCapStyle.FlatCap) - p.setPen(pen) - # Draw name - opt = QTextOption(Qt.AlignmentFlag.AlignCenter) - if self.show_name: + p.setFont(self.smallFont) + if self.show_name: if self.name_font_ghost_mask: opt = QTextOption(Qt.AlignmentFlag.AlignLeft) alpha = self.textColor.alpha() self.textColor.setAlpha(self.font_ghost_alpha) pen.setColor(self.textColor) p.setPen(pen) - p.setFont(self.smallFont) p.drawText(self.nameTextRect, self.name_font_ghost_mask, opt) self.textColor.setAlpha(alpha) pen.setColor(self.textColor) p.setPen(pen) - p.setFont(self.smallFont) - p.drawText(self.nameTextRect, self.name, opt) - - # Draw value - if self.show_value: - if self.peakMode: - dv = self.value - self.peakValue - if dv <= -10: - pen.setColor(self.peakColor) - p.setFont(self.bigFont) - p.setPen(pen) - p.drawText(self.valueTextRect, str(round(dv)), opt) - else: - self.drawValue(p, pen) - else: - self.drawValue(p, pen) + p.drawText(self.nameTextRect, self.name) - # Draw units - opt = QTextOption(Qt.AlignmentFlag.AlignCenter) - pen.setColor(self.textColor) - p.setPen(pen) - if self.show_units: - if self.units_font_mask: - opt = QTextOption(Qt.AlignmentFlag.AlignRight) - p.setFont(self.unitsFont) - if self.units_font_ghost_mask: - alpha = self.textColor.alpha() - self.textColor.setAlpha(self.font_ghost_alpha) - pen.setColor(self.textColor) - p.setPen(pen) - p.drawText(self.unitsTextRect, self.units_font_ghost_mask, opt) - self.textColor.setAlpha(alpha) - pen.setColor(self.textColor) - p.setPen(pen) - p.drawText(self.unitsTextRect, self.units, opt) - else: - p.setFont(self.smallFont) - p.drawText(self.unitsTextRect, self.units, opt) + p.setFont(self.unitsFont) + opt = QTextOption(Qt.AlignmentFlag.AlignRight) + if self.show_units: + if self.units_font_ghost_mask: + alpha = self.textColor.alpha() + self.textColor.setAlpha(self.font_ghost_alpha) + pen.setColor(self.textColor) + p.setPen(pen) + p.drawText(self.valueTextRect, self.units_font_ghost_mask, opt) + self.textColor.setAlpha(alpha) + pen.setColor(self.textColor) + p.setPen(pen) + p.drawText(self.valueTextRect, self.units, opt) + + p.setFont(self.bigFont) + opt = QTextOption(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignBottom) + if self.show_value: + if self.font_ghost_mask: + alpha = self.valueColor.alpha() + self.valueColor.setAlpha(self.font_ghost_alpha) + pen.setColor(self.valueColor) + p.setPen(pen) + p.drawText(self.valueTextRect, self.font_ghost_mask, opt) + self.valueColor.setAlpha(alpha) + pen.setColor(self.valueColor) + p.setPen(pen) + p.drawText(self.valueTextRect, self.valueText, opt) - # ===== IMPROVED BAR DRAWING WITH CONSISTENT ALIGNMENT ===== p.setRenderHint(QPainter.RenderHint.Antialiasing, False) - # Calculate all threshold positions once with consistent rounding - lowAlarmPixel = self._calculateThresholdPixel(self.lowAlarm) if self.lowAlarm and self.lowAlarm >= self.lowRange else None - lowWarnPixel = self._calculateThresholdPixel(self.lowWarn) if self.lowWarn and self.lowWarn >= self.lowRange else None - highWarnPixel = self._calculateThresholdPixel(self.highWarn) if self.highWarn and self.highWarn <= self.highRange else None - highAlarmPixel = self._calculateThresholdPixel(self.highAlarm) if self.highAlarm and self.highAlarm <= self.highRange else None + barTop = int(self.barTop) + barHeight = int(self.barHeight) + barWidth = int(self.width()) - # Draw the bar in sections from left to right - currentLeft = self.barLeft + lowAlarmPixel = self._calculateThresholdPixel(self.lowAlarm) if self.lowAlarm else None + lowWarnPixel = self._calculateThresholdPixel(self.lowWarn) if self.lowWarn else None + highWarnPixel = self._calculateThresholdPixel(self.highWarn) if self.highWarn else None + highAlarmPixel = self._calculateThresholdPixel(self.highAlarm) if self.highAlarm else None - # Left alarm zone (low alarm) - if lowAlarmPixel is not None: - alarmWidth = lowAlarmPixel - if alarmWidth > 0: - pen.setColor(self.alarmColor) - p.setPen(pen) - p.setBrush(self.alarmColor) - p.drawRect(QRectF(currentLeft, self.barTop, alarmWidth, self.barHeight)) - currentLeft = self.barLeft + lowAlarmPixel + pen.setColor(self.safeColor) + brush = self.safeColor + p.setPen(pen) + p.setBrush(brush) + p.drawRect(0, barTop, barWidth, barHeight) - # Low warning zone + pen.setColor(self.warnColor) + brush = self.warnColor + p.setPen(pen) + p.setBrush(brush) if lowWarnPixel is not None: - warnWidth = lowWarnPixel - (currentLeft - self.barLeft) - if warnWidth > 0: - pen.setColor(self.warnColor) - p.setPen(pen) - p.setBrush(self.warnColor) - p.drawRect(QRectF(currentLeft, self.barTop, warnWidth, self.barHeight)) - currentLeft = self.barLeft + lowWarnPixel - - # Safe zone (middle) - safeRight = (self.barLeft + highWarnPixel) if highWarnPixel is not None else self.barRight - safeWidth = safeRight - currentLeft - if safeWidth > 0: - pen.setColor(self.safeColor) - p.setPen(pen) - p.setBrush(self.safeColor) - p.drawRect(QRectF(currentLeft, self.barTop, safeWidth, self.barHeight)) - currentLeft = safeRight - - # High warning zone + p.drawRect(0, barTop, lowWarnPixel, barHeight) if highWarnPixel is not None: - highWarnRight = (self.barLeft + highAlarmPixel) if highAlarmPixel is not None else self.barRight - warnWidth = highWarnRight - currentLeft - if warnWidth > 0: - pen.setColor(self.warnColor) - p.setPen(pen) - p.setBrush(self.warnColor) - p.drawRect(QRectF(currentLeft, self.barTop, warnWidth, self.barHeight)) - currentLeft = highWarnRight + warnWidth = barWidth - highWarnPixel + p.drawRect(highWarnPixel, barTop, warnWidth, barHeight) - # Right alarm zone (high alarm) + pen.setColor(self.alarmColor) + brush = self.alarmColor + p.setPen(pen) + p.setBrush(brush) + if lowAlarmPixel is not None: + p.drawRect(0, barTop, lowAlarmPixel, barHeight) if highAlarmPixel is not None: - alarmWidth = self.barRight - currentLeft - if alarmWidth > 0: - pen.setColor(self.alarmColor) - p.setPen(pen) - p.setBrush(self.alarmColor) - p.drawRect(QRectF(currentLeft, self.barTop, alarmWidth, self.barHeight)) + alarmWidth = barWidth - highAlarmPixel + p.drawRect(highAlarmPixel, barTop, alarmWidth, barHeight) - # Draw segments if needed (simplified) - if self.segments > 1: # Only draw if > 1 - segment_gap = self.barWidth * self.segment_gap_percent - segment_size = (self.barWidth - (segment_gap * (self.segments - 1))) / self.segments + if self.segments > 0: + segment_gap = barWidth * self.segment_gap_percent + segment_size = (barWidth - (segment_gap * (self.segments - 1))) / self.segments pen.setColor(Qt.GlobalColor.black) p.setPen(pen) p.setBrush(Qt.GlobalColor.black) - for segment in range(self.segments - 1): - seg_left = self.barLeft + round((segment + 1) * segment_size + segment * segment_gap) - gap_width = max(1, round(segment_gap)) # At least 1 pixel - p.drawRect(QRectF(seg_left, self.barTop, gap_width, self.barHeight)) - - # Highlight ball - if self.highlight: - pen.setColor(Qt.GlobalColor.black) - pen.setWidth(1) - p.setPen(pen) - p.setBrush(self.highlightColor) - p.drawEllipse(self.ballCenter, self.ballRadius, self.ballRadius) - - # Peak value line - if self.peakMode: - pen.setColor(QColor(Qt.GlobalColor.white)) - brush = QBrush(self.peakColor) - pen.setWidth(1) - p.setPen(pen) - p.setBrush(brush) - x = self.barLeft + self.interpolate(self.peakValue, self.barWidth) - x = max(self.barLeft, min(self.barRight, x)) - p.drawRect(qRound(x - 2), qRound(self.lineTop), qRound(4), qRound(self.lineHeight)) + seg_left = int(((segment + 1) * segment_size) + (segment * segment_gap)) + p.drawRect(seg_left, barTop, int(segment_gap), barHeight) - # Indicator (filled bar effect or line) + pen.setColor(QColor(Qt.GlobalColor.darkGray)) brush = QBrush(self.penColor) pen.setWidth(1) - p.setBrush(brush) - - pen.setColor(QColor(Qt.GlobalColor.darkGray)) p.setPen(pen) - valuePixel = self.barLeft + self.interpolate(self._value, self.barWidth) - valuePixel = max(self.barLeft, min(self.barRight, valuePixel)) + p.setBrush(brush) - if self.segments > 0: - # Filled bar effect - darken to the right of the value - pen.setColor(QColor(0, 0, 0, self.segment_alpha)) - p.setPen(pen) - p.setBrush(QColor(0, 0, 0, self.segment_alpha)) - darkenWidth = self.barRight - valuePixel - if darkenWidth > 0: - p.drawRect(QRectF(valuePixel, self.barTop, darkenWidth, self.barHeight)) - else: - # Traditional line indicator - p.drawRect(QRectF(valuePixel - 2, self.lineTop, 4, self.lineHeight)) + if self._value is not None: + x = self._calculateThresholdPixel(self._value) + if x is None: + x = 0 + x = max(0, min(barWidth, x)) + + if not self.segments > 0: + p.drawRect(x-2, barTop-4, 4, barHeight+8) + else: + pen.setColor(QColor(0, 0, 0, self.segment_alpha)) + p.setPen(pen) + p.setBrush(QColor(0, 0, 0, self.segment_alpha)) + p.drawRect(x, barTop, barWidth - x, barHeight) diff --git a/src/pyefis/instruments/gauges/verticalBarImproved.py b/src/pyefis/instruments/gauges/verticalBarImproved.py index 0bc6b20..70c9f93 100644 --- a/src/pyefis/instruments/gauges/verticalBarImproved.py +++ b/src/pyefis/instruments/gauges/verticalBarImproved.py @@ -35,20 +35,30 @@ def _calculateThresholdPixel(self, value): """ Calculate pixel position for a threshold value with consistent rounding. Returns position from barTop (0 = top of bar, barHeight = bottom). + + Uses integer arithmetic throughout to ensure consistent positioning + across bars with identical thresholds. """ if value is None or self.highRange == self.lowRange: return None + # Use integer barHeight to ensure consistent calculations + barHeight = int(self.barHeight) + if barHeight <= 0: + return None + # Calculate normalized position (0.0 to 1.0) normalized = (value - self.lowRange) / (self.highRange - self.lowRange) normalized = max(0.0, min(1.0, normalized)) # Clamp to valid range - # Convert to pixel position (inverted: high values = low pixels) - pixelFromBottom = normalized * self.barHeight - pixelFromTop = self.barHeight - pixelFromBottom + # Convert to pixel position using integer math for consistency + # Scale by 1000 to maintain precision, then divide back + scaledPosition = int(normalized * 1000) + pixelFromBottom = (scaledPosition * barHeight) // 1000 + pixelFromTop = barHeight - pixelFromBottom - # Round to nearest pixel for consistent positioning - return round(pixelFromTop) + # Add barTop offset as integer + return int(self.barTop) + pixelFromTop def paintEvent(self, event): # Check highlight status @@ -122,14 +132,20 @@ def paintEvent(self, event): # ===== IMPROVED BAR DRAWING WITH CONSISTENT ALIGNMENT ===== p.setRenderHint(QPainter.RenderHint.Antialiasing, False) + # Convert bar boundaries to integers for pixel-perfect alignment + barTop = int(self.barTop) + barBottom = int(self.barBottom) + barLeft = int(self.barLeft) + barWidth = int(self.barWidth) + # Calculate all threshold positions once with consistent rounding lowAlarmPixel = self._calculateThresholdPixel(self.lowAlarm) if self.lowAlarm and self.lowAlarm >= self.lowRange else None lowWarnPixel = self._calculateThresholdPixel(self.lowWarn) if self.lowWarn and self.lowWarn >= self.lowRange else None highWarnPixel = self._calculateThresholdPixel(self.highWarn) if self.highWarn and self.highWarn <= self.highRange else None highAlarmPixel = self._calculateThresholdPixel(self.highAlarm) if self.highAlarm and self.highAlarm <= self.highRange else None - # Draw the bar in sections from top to bottom - currentTop = self.barTop + # Draw the bar in sections from top to bottom using integer coordinates + currentTop = barTop # Top alarm zone (high alarm) if highAlarmPixel is not None: @@ -138,7 +154,7 @@ def paintEvent(self, event): pen.setColor(self.alarmColor) p.setPen(pen) p.setBrush(self.alarmColor) - p.drawRect(QRectF(self.barLeft, currentTop, self.barWidth, alarmHeight)) + p.drawRect(barLeft, currentTop, barWidth, alarmHeight) currentTop = highAlarmPixel # High warning zone @@ -148,38 +164,38 @@ def paintEvent(self, event): pen.setColor(self.warnColor) p.setPen(pen) p.setBrush(self.warnColor) - p.drawRect(QRectF(self.barLeft, currentTop, self.barWidth, warnHeight)) + p.drawRect(barLeft, currentTop, barWidth, warnHeight) currentTop = highWarnPixel # Safe zone (middle) - safeBottom = lowWarnPixel if lowWarnPixel is not None else self.barBottom + safeBottom = lowWarnPixel if lowWarnPixel is not None else barBottom safeHeight = safeBottom - currentTop if safeHeight > 0: pen.setColor(self.safeColor) p.setPen(pen) p.setBrush(self.safeColor) - p.drawRect(QRectF(self.barLeft, currentTop, self.barWidth, safeHeight)) + p.drawRect(barLeft, currentTop, barWidth, safeHeight) currentTop = safeBottom # Low warning zone if lowWarnPixel is not None: - lowWarnBottom = lowAlarmPixel if lowAlarmPixel is not None else self.barBottom + lowWarnBottom = lowAlarmPixel if lowAlarmPixel is not None else barBottom warnHeight = lowWarnBottom - currentTop if warnHeight > 0: pen.setColor(self.warnColor) p.setPen(pen) p.setBrush(self.warnColor) - p.drawRect(QRectF(self.barLeft, currentTop, self.barWidth, warnHeight)) + p.drawRect(barLeft, currentTop, barWidth, warnHeight) currentTop = lowWarnBottom # Bottom alarm zone (low alarm) if lowAlarmPixel is not None: - alarmHeight = self.barBottom - currentTop + alarmHeight = barBottom - currentTop if alarmHeight > 0: pen.setColor(self.alarmColor) p.setPen(pen) p.setBrush(self.alarmColor) - p.drawRect(QRectF(self.barLeft, currentTop, self.barWidth, alarmHeight)) + p.drawRect(barLeft, currentTop, barWidth, alarmHeight) # Draw segments if needed (simplified) if self.segments > 1: # Only draw if > 1 From 0e74eb7274352854886e2f76676c6e73f2b08a2a Mon Sep 17 00:00:00 2001 From: TerryH <32078938+tjh590@users.noreply.github.com> Date: Thu, 6 Nov 2025 00:20:43 -0500 Subject: [PATCH 12/20] testing --- src/pyefis/instruments/gauges/egt.py | 27 +- .../instruments/gauges/verticalBarImproved.py | 179 ++++++++-- teststack.py | 336 ++++++++++++++++++ 3 files changed, 502 insertions(+), 40 deletions(-) create mode 100644 teststack.py diff --git a/src/pyefis/instruments/gauges/egt.py b/src/pyefis/instruments/gauges/egt.py index 815c910..50484fe 100644 --- a/src/pyefis/instruments/gauges/egt.py +++ b/src/pyefis/instruments/gauges/egt.py @@ -87,10 +87,23 @@ def setMode(self, args): def resizeEvent(self, event): cylcount = len(self.bars) - barwidth = self.width() / cylcount - barheight = self.height() - x = 0 - for bar in self.bars: - bar.resize(qRound(barwidth), qRound(barheight)) - bar.move(qRound(barwidth * x), 0) - x += 1 + if cylcount == 0: + return + + total_width = self.width() + total_height = self.height() + + # Distribute integer pixel widths deterministically so the sum matches total_width. + base_width = total_width // cylcount + remainder = total_width - (base_width * cylcount) + + current_x = 0 + for index, bar in enumerate(self.bars): + # Spread the remaining pixels one-per-bar from the left. + extra_pixel = 1 if index < remainder else 0 + bar_width = base_width + extra_pixel + + bar.resize(bar_width, total_height) + bar.move(current_x, 0) + + current_x += bar_width diff --git a/src/pyefis/instruments/gauges/verticalBarImproved.py b/src/pyefis/instruments/gauges/verticalBarImproved.py index 70c9f93..16be818 100644 --- a/src/pyefis/instruments/gauges/verticalBarImproved.py +++ b/src/pyefis/instruments/gauges/verticalBarImproved.py @@ -14,9 +14,13 @@ from PyQt6.QtGui import * from PyQt6.QtCore import * from PyQt6.QtWidgets import * +import sys +import logging from .verticalBar import VerticalBar as VerticalBarBase +log = logging.getLogger(__name__) + class VerticalBarImproved(VerticalBarBase): """ @@ -30,7 +34,8 @@ class VerticalBarImproved(VerticalBarBase): def __init__(self, parent=None, min_size=True, font_family="DejaVu Sans Condensed"): super().__init__(parent, min_size, font_family) - + self._bar_left = -1 + def _calculateThresholdPixel(self, value): """ Calculate pixel position for a threshold value with consistent rounding. @@ -132,11 +137,60 @@ def paintEvent(self, event): # ===== IMPROVED BAR DRAWING WITH CONSISTENT ALIGNMENT ===== p.setRenderHint(QPainter.RenderHint.Antialiasing, False) + # CRITICAL: Set pen to no outline for color bands + # A 1-pixel pen draws on the rectangle border and can cause misalignment + pen.setWidth(0) # No outline + pen.setStyle(Qt.PenStyle.NoPen) # Disable pen completely + # Convert bar boundaries to integers for pixel-perfect alignment barTop = int(self.barTop) barBottom = int(self.barBottom) - barLeft = int(self.barLeft) - barWidth = int(self.barWidth) + + widgetWidth = int(self.width()) + + # Start with local geometry based on this widget alone. + barWidth = max(1, int(round(widgetWidth * self.bar_width_percent))) + barWidth = min(barWidth, widgetWidth) + barLeft = (widgetWidth - barWidth) // 2 + lineWidth = max(1, int(round(widgetWidth * self.line_width_percent))) + lineWidth = min(lineWidth, widgetWidth) + lineLeft = (widgetWidth - lineWidth) // 2 + + # When part of a ganged layout, align using the parent container width so + # every bar paints using the exact same geometry regardless of its own widget width. + parent_obj = self.parent() + if parent_obj is not None and hasattr(parent_obj, 'bars'): + try: + bars = parent_obj.bars + barCount = len(bars) + if barCount > 0: + index = bars.index(self) + parentWidth = max(1, parent_obj.width()) + slotWidth = parentWidth / barCount + + barWidth = max(1, int(round(slotWidth * self.bar_width_percent))) + barWidth = min(barWidth, widgetWidth) + lineWidth = max(1, int(round(slotWidth * self.line_width_percent))) + lineWidth = min(lineWidth, widgetWidth) + + slotLeft = slotWidth * index + desiredBarLeft = slotLeft + (slotWidth - barWidth) / 2.0 + desiredLineLeft = slotLeft + (slotWidth - lineWidth) / 2.0 + + # Translate global coordinates back into the widget's local space. + barLeft = int(round(desiredBarLeft - self.x())) + lineLeft = int(round(desiredLineLeft - self.x())) + except ValueError: + # Bar not found in parent list; keep local geometry. + pass + + # Clamp to ensure drawing stays inside this widget. + barLeft = max(0, min(widgetWidth - barWidth, barLeft)) + lineLeft = max(0, min(widgetWidth - lineWidth, lineLeft)) + + # Calculate ball geometry from the aligned bar values. + ballRadius = max(1, int(round(barWidth * 0.40))) + ballCenter = QPointF(barLeft + (barWidth / 2.0), self.barBottom - (barWidth / 2.0)) # Calculate all threshold positions once with consistent rounding lowAlarmPixel = self._calculateThresholdPixel(self.lowAlarm) if self.lowAlarm and self.lowAlarm >= self.lowRange else None @@ -144,37 +198,87 @@ def paintEvent(self, event): highWarnPixel = self._calculateThresholdPixel(self.highWarn) if self.highWarn and self.highWarn <= self.highRange else None highAlarmPixel = self._calculateThresholdPixel(self.highAlarm) if self.highAlarm and self.highAlarm <= self.highRange else None + # DEBUG: Print pixel positions for specific bars (limited to avoid spam) + # Looking for CHT11 or OILP1, but checking actual bar names + if hasattr(self, 'name'): + # Check if this is a bar we're interested in + debug_this = False + if 'ted' in str(self.name): + debug_this = True + + if debug_this: + log.warning(f"=== {self.name} Bar Debug ===") + log.warning(f" Widget width: {widgetWidth}, self.width(): {self.width()}") + log.warning(f" Original self.barLeft: {self.barLeft}, self.barWidth: {self.barWidth}") + log.warning(f" Calculated barLeft: {barLeft}, barWidth: {barWidth} (using bar_width_percent={self.bar_width_percent})") + log.warning(f" Bar dimensions: top={barTop}, bottom={barBottom}, left={barLeft}, width={barWidth}, height={barBottom-barTop}") + log.warning(f" Range: {self.lowRange} to {self.highRange}") + log.warning(f" Thresholds: lowAlarm={self.lowAlarm}, lowWarn={self.lowWarn}, highWarn={self.highWarn}, highAlarm={self.highAlarm}") + log.warning(f" Threshold pixels: lowAlarm={lowAlarmPixel}, lowWarn={lowWarnPixel}, highWarn={highWarnPixel}, highAlarm={highAlarmPixel}") + # Draw the bar in sections from top to bottom using integer coordinates currentTop = barTop - + + # 1) Snap to device pixels (Python/PyQt) + try: + dpr = p.device().devicePixelRatioF() + except Exception: + try: + dpr = self.devicePixelRatioF() + except Exception: + dpr = 1.0 + + def snap(v: float) -> float: + return round(v * dpr) / dpr + + # 2) Paint stacked bars with fillRect (no borders), AA off + p.setRenderHint(QPainter.RenderHint.Antialiasing, False) + p.setPen(Qt.PenStyle.NoPen) + + def draw_bar(x: float, y: float, w: float, h: float, color): + r = QRectF(snap(x), snap(y), snap(w), snap(h)) + p.fillRect(r, color) + # Top alarm zone (high alarm) if highAlarmPixel is not None: alarmHeight = highAlarmPixel - currentTop if alarmHeight > 0: - pen.setColor(self.alarmColor) - p.setPen(pen) - p.setBrush(self.alarmColor) - p.drawRect(barLeft, currentTop, barWidth, alarmHeight) + # DEBUG: Print alarm zone rect + if hasattr(self, 'name'): + if 'ted' in str(self.name): + log.warning(f" High Alarm rect: x={barLeft}, y={currentTop}, w={barWidth}, h={alarmHeight}") + #p.setPen(Qt.PenStyle.NoPen) + #p.setBrush(self.alarmColor) + #p.drawRect(barLeft, currentTop, barWidth, alarmHeight) + draw_bar(barLeft, currentTop, barWidth, alarmHeight, self.alarmColor) currentTop = highAlarmPixel # High warning zone if highWarnPixel is not None: warnHeight = highWarnPixel - currentTop if warnHeight > 0: - pen.setColor(self.warnColor) - p.setPen(pen) - p.setBrush(self.warnColor) - p.drawRect(barLeft, currentTop, barWidth, warnHeight) + # DEBUG: Print warning zone rect + if hasattr(self, 'name'): + if 'ted' in str(self.name): + log.warning(f" High Warn rect: x={barLeft}, y={currentTop}, w={barWidth}, h={warnHeight}") + #p.setPen(Qt.PenStyle.NoPen) + #p.setBrush(self.warnColor) + #p.drawRect(barLeft, currentTop, barWidth, warnHeight) + draw_bar(barLeft, currentTop, barWidth, warnHeight, self.warnColor) currentTop = highWarnPixel # Safe zone (middle) safeBottom = lowWarnPixel if lowWarnPixel is not None else barBottom safeHeight = safeBottom - currentTop if safeHeight > 0: - pen.setColor(self.safeColor) - p.setPen(pen) - p.setBrush(self.safeColor) - p.drawRect(barLeft, currentTop, barWidth, safeHeight) + # DEBUG: Print safe zone rect + if hasattr(self, 'name'): + if 'ted' in str(self.name): + log.warning(f" Safe Zone rect: x={barLeft}, y={currentTop}, w={barWidth}, h={safeHeight}") + #p.setPen(Qt.PenStyle.NoPen) + #p.setBrush(self.safeColor) + #p.drawRect(barLeft, currentTop, barWidth, safeHeight) + draw_bar(barLeft, currentTop, barWidth, safeHeight, self.safeColor) currentTop = safeBottom # Low warning zone @@ -182,20 +286,26 @@ def paintEvent(self, event): lowWarnBottom = lowAlarmPixel if lowAlarmPixel is not None else barBottom warnHeight = lowWarnBottom - currentTop if warnHeight > 0: - pen.setColor(self.warnColor) - p.setPen(pen) - p.setBrush(self.warnColor) - p.drawRect(barLeft, currentTop, barWidth, warnHeight) + if hasattr(self, 'name'): + if 'ted' in str(self.name): + log.warning(f" Low Warn rect: x={barLeft}, y={currentTop}, w={barWidth}, h={warnHeight}") + #p.setPen(Qt.PenStyle.NoPen) + #p.setBrush(self.warnColor) + #p.drawRect(barLeft, currentTop, barWidth, warnHeight) + draw_bar(barLeft, currentTop, barWidth, warnHeight, self.warnColor) currentTop = lowWarnBottom # Bottom alarm zone (low alarm) if lowAlarmPixel is not None: alarmHeight = barBottom - currentTop if alarmHeight > 0: - pen.setColor(self.alarmColor) - p.setPen(pen) - p.setBrush(self.alarmColor) - p.drawRect(barLeft, currentTop, barWidth, alarmHeight) + if hasattr(self, 'name'): + if 'ted' in str(self.name): + log.warning(f" Low Alarm rect: x={barLeft}, y={currentTop}, w={barWidth}, h={alarmHeight}") + #p.setPen(Qt.PenStyle.NoPen) + #p.setBrush(self.alarmColor) + #p.drawRect(barLeft, currentTop, barWidth, alarmHeight) + draw_bar(barLeft, currentTop, barWidth, alarmHeight, self.alarmColor) # Draw segments if needed (simplified) if self.segments > 1: # Only draw if > 1 @@ -204,11 +314,12 @@ def paintEvent(self, event): pen.setColor(Qt.GlobalColor.black) p.setPen(pen) p.setBrush(Qt.GlobalColor.black) - + + gap_height = max(1, int(round(segment_gap))) # At least 1 pixel for segment in range(self.segments - 1): - seg_top = self.barTop + round((segment + 1) * segment_size + segment * segment_gap) - gap_height = max(1, round(segment_gap)) # At least 1 pixel - p.drawRect(QRectF(self.barLeft, seg_top, self.barWidth, gap_height)) + seg_top = int(self.barTop + round((segment + 1) * segment_size + segment * segment_gap)) + #p.drawRect(barLeft, seg_top, barWidth, gap_height) + draw_bar(barLeft, seg_top, barWidth, gap_height, t.GlobalColor.black) # Highlight ball if self.highlight: @@ -216,7 +327,7 @@ def paintEvent(self, event): pen.setWidth(1) p.setPen(pen) p.setBrush(self.highlightColor) - p.drawEllipse(self.ballCenter, self.ballRadius, self.ballRadius) + p.drawEllipse(ballCenter, ballRadius, ballRadius) # Peak value line if self.peakMode: @@ -232,7 +343,7 @@ def paintEvent(self, event): else: y = self.barTop + (self.barHeight - self.interpolate(self.peakValue, self.barHeight)) y = max(self.barTop, min(self.barBottom, y)) - p.drawRect(qRound(self.lineLeft), qRound(y - 2), qRound(self.lineWidth), qRound(4)) + #p.drawRect(qRound(lineLeft), qRound(y - 2), qRound(lineWidth), qRound(4)) # Indicator (filled bar effect or line) brush = QBrush(self.penColor) @@ -252,14 +363,16 @@ def paintEvent(self, event): valuePixel = max(self.barTop, min(self.barBottom, valuePixel)) + valuePixelInt = int(valuePixel) + if self.segments > 0: # Filled bar effect - darken above the value pen.setColor(QColor(0, 0, 0, self.segment_alpha)) p.setPen(pen) p.setBrush(QColor(0, 0, 0, self.segment_alpha)) - darkenHeight = valuePixel - self.barTop + darkenHeight = valuePixelInt - barTop if darkenHeight > 0: - p.drawRect(QRectF(self.barLeft, self.barTop, self.barWidth, darkenHeight)) + p.drawRect(barLeft, barTop, barWidth, darkenHeight) else: # Traditional line indicator - p.drawRect(QRectF(self.lineLeft, valuePixel - 2, self.lineWidth, 4)) + p.drawRect(lineLeft, valuePixelInt - 2, lineWidth, 4) diff --git a/teststack.py b/teststack.py new file mode 100644 index 0000000..4a3c274 --- /dev/null +++ b/teststack.py @@ -0,0 +1,336 @@ +#!/usr/bin/env python3 +""" +Test program to visualize vertical bar gauges with color bands. +This replicates the drawing logic from verticalBarImproved.py in a simple standalone test. +""" + +import sys +from PyQt6.QtWidgets import QApplication, QWidget +from PyQt6.QtGui import QPainter, QPen, QColor, QFont +from PyQt6.QtCore import Qt, QRectF + +# ===== ADJUSTABLE CONSTANTS ===== +WINDOW_WIDTH = 800 +WINDOW_HEIGHT = 600 + +# Bar configuration +BAR_COUNT = 6 # Number of bars to display side by side +BAR_WIDTH = 40 +BAR_HEIGHT = 400 +BAR_SPACING = 20 # Space between bars +BAR_START_X = 50 # Left margin +BAR_START_Y = 100 # Top margin + +# Simulate widget height variations (each bar might be slightly different height in pyEfis) +# This simulates how different widgets might round font sizes differently +WIDGET_HEIGHTS = [400, 400, 400, 400, 400, 400] # Slight variations +SHOW_NAME = True +SHOW_VALUE = True +SHOW_UNITS = True +TEXT_GAP = 3 +SMALL_FONT_PERCENT = 0.08 +BIG_FONT_PERCENT = 0.10 + +# Value range +LOW_RANGE = 0 +HIGH_RANGE = 300 + +# Thresholds (temperatures in °C for CHT example) +LOW_ALARM = 50 +LOW_WARN = 85 +HIGH_WARN = 204 +HIGH_ALARM = 232 + +# Current value to display +CURRENT_VALUE = 180 + +# Colors +SAFE_COLOR = QColor(0, 255, 0) #Qt.GlobalColor.green #QColor(0, 255, 0) # Green +WARN_COLOR = QColor(255, 255, 0) #Qt.GlobalColor.yellow #QColor(255, 255, 0) # Yellow +ALARM_COLOR = QColor(255, 0, 0) #Qt.GlobalColor.red #QColor(255, 0, 0) # Red +BG_COLOR = QColor(40, 40, 40) #Qt.GlobalColor.black #QColor(40, 40, 40) # Dark gray background + +# Font +FONT_FAMILY = "DejaVu Sans Condensed" +FONT_SIZE = 12 + + +class VerticalBarTest(QWidget): + """Simple widget that draws vertical bars with color bands.""" + + def __init__(self): + super().__init__() + self.setWindowTitle("Vertical Bar Alignment Test") + self.setFixedSize(WINDOW_WIDTH, WINDOW_HEIGHT) + self.setStyleSheet(f"background-color: rgb({BG_COLOR.red()}, {BG_COLOR.green()}, {BG_COLOR.blue()});") + + def _calculateBarDimensions(self, widgetHeight): + """ + Calculate bar dimensions the same way pyEfis does in verticalBar.py resizeEvent. + This is where the variability comes from! + """ + from PyQt6.QtCore import qRound + + # Calculate font sizes based on widget height (like pyEfis does) + smallFontPixelSize = qRound(widgetHeight * SMALL_FONT_PERCENT) + bigFontPixelSize = qRound(widgetHeight * BIG_FONT_PERCENT) + + # Calculate barTop (depends on font size!) + if SHOW_NAME: + barTop = smallFontPixelSize + TEXT_GAP + else: + barTop = 1 + + # Calculate barBottom + barBottom = widgetHeight + if SHOW_VALUE: + barBottom -= (bigFontPixelSize + TEXT_GAP) + if SHOW_UNITS: + barBottom -= (smallFontPixelSize + TEXT_GAP) + + barHeight = barBottom - barTop + + return barTop, barBottom, barHeight, smallFontPixelSize, bigFontPixelSize + + def _calculateThresholdPixel(self, value, barTop, barBottom): + """ + Calculate pixel position for a threshold value with consistent rounding. + Returns absolute pixel position from top of window. + + Uses integer arithmetic throughout to ensure consistent positioning. + """ + if value is None or HIGH_RANGE == LOW_RANGE: + return None + + # Use integer barHeight to ensure consistent calculations + barHeight = int(barBottom - barTop) + if barHeight <= 0: + return None + + # Calculate normalized position (0.0 to 1.0) + normalized = (value - LOW_RANGE) / (HIGH_RANGE - LOW_RANGE) + normalized = max(0.0, min(1.0, normalized)) # Clamp to valid range + + # Convert to pixel position using integer math for consistency + # Scale by 1000 to maintain precision, then divide back + scaledPosition = int(normalized * 1000) + pixelFromBottom = (scaledPosition * barHeight) // 1000 + pixelFromTop = barHeight - pixelFromBottom + + # Add barTop offset as integer + return int(barTop) + pixelFromTop + + def _drawSingleBar(self, p, barLeft, barIndex, barLabel): + """Draw a single vertical bar with color bands.""" + + # Get the widget height for this bar (simulating different widget sizes) + widgetHeight = WIDGET_HEIGHTS[barIndex] if barIndex < len(WIDGET_HEIGHTS) else BAR_HEIGHT + + # Calculate bar dimensions the same way pyEfis does + barTopCalc, barBottomCalc, barHeightCalc, smallFont, bigFont = self._calculateBarDimensions(widgetHeight) + + # Position this bar at the correct Y location + barTop = BAR_START_Y + barTopCalc + barBottom = BAR_START_Y + barBottomCalc + + # Convert to integers for pixel-perfect alignment + barTop = int(barTop) + barBottom = int(barBottom) + barLeft = int(barLeft) + barWidth = int(BAR_WIDTH) + + # Calculate all threshold positions once with consistent rounding + lowAlarmPixel = self._calculateThresholdPixel(LOW_ALARM, barTop, barBottom) if LOW_ALARM and LOW_ALARM >= LOW_RANGE else None + lowWarnPixel = self._calculateThresholdPixel(LOW_WARN, barTop, barBottom) if LOW_WARN and LOW_WARN >= LOW_RANGE else None + highWarnPixel = self._calculateThresholdPixel(HIGH_WARN, barTop, barBottom) if HIGH_WARN and HIGH_WARN <= HIGH_RANGE else None + highAlarmPixel = self._calculateThresholdPixel(HIGH_ALARM, barTop, barBottom) if HIGH_ALARM and HIGH_ALARM <= HIGH_RANGE else None + + # Print debug info for first bar + if barLabel == "BAR1": + print(f"\n=== {barLabel} Debug ===") + print(f" Widget height: {widgetHeight}") + print(f" Small font size: {smallFont}, Big font size: {bigFont}") + print(f" Bar dimensions: top={barTop}, bottom={barBottom}, left={barLeft}, width={barWidth}, height={barBottom-barTop}") + print(f" Range: {LOW_RANGE} to {HIGH_RANGE}") + print(f" Thresholds: lowAlarm={LOW_ALARM}, lowWarn={LOW_WARN}, highWarn={HIGH_WARN}, highAlarm={HIGH_ALARM}") + print(f" Threshold pixels: lowAlarm={lowAlarmPixel}, lowWarn={lowWarnPixel}, highWarn={highWarnPixel}, highAlarm={highAlarmPixel}") + + # Print comparison for other bars + if barIndex > 0: + prevWidgetHeight = WIDGET_HEIGHTS[barIndex - 1] if (barIndex - 1) < len(WIDGET_HEIGHTS) else BAR_HEIGHT + prevTop, prevBottom, prevHeight, _, _ = self._calculateBarDimensions(prevWidgetHeight) + if barTopCalc != prevTop or barBottomCalc != prevBottom: + print(f" WARNING: {barLabel} has different dimensions than previous bar!") + print(f" This bar: top={barTopCalc}, bottom={barBottomCalc}, height={barHeightCalc}") + print(f" Prev bar: top={prevTop}, bottom={prevBottom}, height={prevHeight}") + print(f" Difference: top={barTopCalc - prevTop}, bottom={barBottomCalc - prevBottom}, height={barHeightCalc - prevHeight}") + + # Setup pen for drawing + pen = QPen() + pen.setWidth(0) + #pen.setCapStyle(Qt.PenCapStyle.FlatCap) + + # Disable antialiasing for pixel-perfect rendering + p.setRenderHint(QPainter.RenderHint.Antialiasing, False) + + # Draw the bar in sections from top to bottom using integer coordinates + currentTop = barTop + + # Top alarm zone (high alarm) + if highAlarmPixel is not None: + alarmHeight = highAlarmPixel - currentTop + if alarmHeight > 0: + if barLabel == "BAR1": + print(f" High Alarm rect: x={barLeft}, y={currentTop}, w={barWidth}, h={alarmHeight}") + pen.setColor(ALARM_COLOR) + p.setPen(pen) + p.setBrush(ALARM_COLOR) + p.drawRect(barLeft, currentTop, barWidth, alarmHeight) + currentTop = highAlarmPixel + + # High warning zone + if highWarnPixel is not None: + warnHeight = highWarnPixel - currentTop + if warnHeight > 0: + if barLabel == "BAR1": + print(f" High Warn rect: x={barLeft}, y={currentTop}, w={barWidth}, h={warnHeight}") + pen.setColor(WARN_COLOR) + p.setPen(pen) + p.setBrush(WARN_COLOR) + p.drawRect(barLeft, currentTop, barWidth, warnHeight) + currentTop = highWarnPixel + + # Safe zone (middle) + safeBottom = lowWarnPixel if lowWarnPixel is not None else barBottom + safeHeight = safeBottom - currentTop + if safeHeight > 0: + if barLabel == "BAR1": + print(f" Safe Zone rect: x={barLeft}, y={currentTop}, w={barWidth}, h={safeHeight}") + pen.setColor(SAFE_COLOR) + p.setPen(pen) + p.setBrush(SAFE_COLOR) + p.drawRect(barLeft, currentTop, barWidth, safeHeight) + currentTop = safeBottom + + # Low warning zone + if lowWarnPixel is not None: + lowWarnBottom = lowAlarmPixel if lowAlarmPixel is not None else barBottom + warnHeight = lowWarnBottom - currentTop + if warnHeight > 0: + if barLabel == "BAR1": + print(f" Low Warn rect: x={barLeft}, y={currentTop}, w={barWidth}, h={warnHeight}") + pen.setColor(WARN_COLOR) + p.setPen(pen) + p.setBrush(WARN_COLOR) + p.drawRect(barLeft, currentTop, barWidth, warnHeight) + currentTop = lowWarnBottom + + # Bottom alarm zone (low alarm) + if lowAlarmPixel is not None: + alarmHeight = barBottom - currentTop + if alarmHeight > 0: + if barLabel == "BAR1": + print(f" Low Alarm rect: x={barLeft}, y={currentTop}, w={barWidth}, h={alarmHeight}") + pen.setColor(ALARM_COLOR) + p.setPen(pen) + p.setBrush(ALARM_COLOR) + p.drawRect(barLeft, currentTop, barWidth, alarmHeight) + + # Draw current value indicator + valuePixel = self._calculateThresholdPixel(CURRENT_VALUE, barTop, barBottom) + if valuePixel is not None: + pen.setColor(QColor(255, 255, 255)) # White + pen.setWidth(2) + p.setPen(pen) + lineLeft = barLeft - 5 + lineRight = barLeft + barWidth + 5 + p.drawLine(lineLeft, int(valuePixel), lineRight, int(valuePixel)) + + # Draw label below bar + p.setRenderHint(QPainter.RenderHint.Antialiasing, True) + pen.setColor(QColor(255, 255, 255)) + pen.setWidth(1) + p.setPen(pen) + font = QFont(FONT_FAMILY, FONT_SIZE) + p.setFont(font) + labelRect = QRectF(barLeft - 10, barBottom + 10, barWidth + 20, 30) + p.drawText(labelRect, Qt.AlignmentFlag.AlignCenter, barLabel) + + # Draw threshold labels + #font.setPointSize(FONT_SIZE - 2) + #p.setFont(font) + #if HIGH_ALARM and highAlarmPixel: + # p.drawText(barLeft + barWidth + 5, highAlarmPixel + 5, f"{HIGH_ALARM}°C") + #if HIGH_WARN and highWarnPixel: + # p.drawText(barLeft + barWidth + 5, highWarnPixel + 5, f"{HIGH_WARN}°C") + + def paintEvent(self, event): + """Paint all the bars.""" + p = QPainter(self) + + # Draw title + p.setRenderHint(QPainter.RenderHint.Antialiasing, True) + pen = QPen(QColor(255, 255, 255)) + p.setPen(pen) + font = QFont(FONT_FAMILY, 16, QFont.Weight.Bold) + p.setFont(font) + p.drawText(20, 40, "Vertical Bar Alignment Test") + + font.setPointSize(12) + p.setFont(font) + p.drawText(20, 60, f"Value: {CURRENT_VALUE}°C | Warn: {HIGH_WARN}°C | Alarm: {HIGH_ALARM}°C") + + # Draw multiple bars side by side + for i in range(BAR_COUNT): + barLeft = BAR_START_X + i * (BAR_WIDTH + BAR_SPACING) + barLabel = f"BAR{i+1}" + self._drawSingleBar(p, barLeft, i, barLabel) + + # Draw alignment grid lines to help visualize + # p.setRenderHint(QPainter.RenderHint.Antialiasing, False) + # pen.setColor(QColor(80, 80, 80)) + # pen.setWidth(1) + # pen.setStyle(Qt.PenStyle.DotLine) + # p.setPen(pen) + + # Draw horizontal grid lines at threshold positions for each bar + # for i in range(BAR_COUNT): + # widgetHeight = WIDGET_HEIGHTS[i] if i < len(WIDGET_HEIGHTS) else BAR_HEIGHT + # barTopCalc, barBottomCalc, _, _, _ = self._calculateBarDimensions(widgetHeight) + # barTop = int(BAR_START_Y + barTopCalc) + # barBottom = int(BAR_START_Y + barBottomCalc) + + # highWarnPixel = self._calculateThresholdPixel(HIGH_WARN, barTop, barBottom) + # highAlarmPixel = self._calculateThresholdPixel(HIGH_ALARM, barTop, barBottom) + + # barLeft = BAR_START_X + i * (BAR_WIDTH + BAR_SPACING) + + # # Draw line across this bar only + # if highWarnPixel: + # p.drawLine(barLeft, highWarnPixel, barLeft + BAR_WIDTH, highWarnPixel) + # if highAlarmPixel: + # p.drawLine(barLeft, highAlarmPixel, barLeft + BAR_WIDTH, highAlarmPixel) + + +def main(): + """Main entry point.""" + print("=" * 60) + print("Vertical Bar Alignment Test - SIMULATING PYEFIS LOGIC") + print("=" * 60) + print(f"Window: {WINDOW_WIDTH}x{WINDOW_HEIGHT}") + print(f"Bars: {BAR_COUNT} bars, {BAR_WIDTH}px wide") + print(f"Widget heights: {WIDGET_HEIGHTS} (simulating slight variations)") + print(f"Range: {LOW_RANGE} to {HIGH_RANGE}") + print(f"Thresholds: Warn={HIGH_WARN}, Alarm={HIGH_ALARM}") + print(f"Current Value: {CURRENT_VALUE}") + print(f"Font percents: small={SMALL_FONT_PERCENT}, big={BIG_FONT_PERCENT}") + print("=" * 60) + + app = QApplication(sys.argv) + window = VerticalBarTest() + window.show() + sys.exit(app.exec()) + + +if __name__ == '__main__': + main() From ca1a5db68cba012df212ffd16c0ed55e76700bb3 Mon Sep 17 00:00:00 2001 From: TerryH <32078938+tjh590@users.noreply.github.com> Date: Thu, 6 Nov 2025 13:00:43 -0500 Subject: [PATCH 13/20] testing --- src/pyefis/instruments/gauges/abstract.py | 8 ++++++ .../instruments/gauges/verticalBarImproved.py | 28 +++++++++++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/pyefis/instruments/gauges/abstract.py b/src/pyefis/instruments/gauges/abstract.py index c4787c6..1e9a2ea 100644 --- a/src/pyefis/instruments/gauges/abstract.py +++ b/src/pyefis/instruments/gauges/abstract.py @@ -100,9 +100,17 @@ def __init__(self, parent=None, font_family="DejaVu Sans Condensed"): # These are the colors that are used when the value's # quality is marked as good self.bg_good_color = Qt.GlobalColor.black + + #Qt.GlobalColor.green + #QColor( 0, 180, 60) self.safe_good_color = Qt.GlobalColor.green + #Qt.GlobalColor.yellow + #QColor(255, 200, 40) self.warn_good_color = Qt.GlobalColor.yellow + #Qt.GlobalColor.red + #QColor(220, 60, 40) self.alarm_good_color = Qt.GlobalColor.red + self.text_good_color = Qt.GlobalColor.white self.pen_good_color = Qt.GlobalColor.white self.highlight_good_color = Qt.GlobalColor.magenta diff --git a/src/pyefis/instruments/gauges/verticalBarImproved.py b/src/pyefis/instruments/gauges/verticalBarImproved.py index 16be818..042a170 100644 --- a/src/pyefis/instruments/gauges/verticalBarImproved.py +++ b/src/pyefis/instruments/gauges/verticalBarImproved.py @@ -236,7 +236,12 @@ def snap(v: float) -> float: p.setPen(Qt.PenStyle.NoPen) def draw_bar(x: float, y: float, w: float, h: float, color): - r = QRectF(snap(x), snap(y), snap(w), snap(h)) + snx = snap(x) + sny = snap(y) + snw = snap(w) + snh = snap(h) + log.warning(f" Snapped {self.name}: x={snx:.4f}, y={sny:.4f}, w={snw:.4f}, h={snh:.4f}") + r = QRectF(snx, sny, snw, snh) p.fillRect(r, color) # Top alarm zone (high alarm) @@ -319,7 +324,7 @@ def draw_bar(x: float, y: float, w: float, h: float, color): for segment in range(self.segments - 1): seg_top = int(self.barTop + round((segment + 1) * segment_size + segment * segment_gap)) #p.drawRect(barLeft, seg_top, barWidth, gap_height) - draw_bar(barLeft, seg_top, barWidth, gap_height, t.GlobalColor.black) + draw_bar(barLeft, seg_top, barWidth, gap_height, Qt.GlobalColor.black) # Highlight ball if self.highlight: @@ -376,3 +381,22 @@ def draw_bar(x: float, y: float, w: float, h: float, color): else: # Traditional line indicator p.drawRect(lineLeft, valuePixelInt - 2, lineWidth, 4) + + # Draw 1-pixel grey guide lines on the sides of the bar. + # Left: full bar height; Right: tracks the top of the colored bar (current value). + # p.save() + # p.setRenderHint(QPainter.RenderHint.Antialiasing, False) + # p.setPen(Qt.PenStyle.NoPen) + # p.setBrush(QColor(160, 160, 160)) # grey + # # Left edge (full height) + # p.drawRect(barLeft, barTop, 1, barBottom - barTop) + # # Right edge (from current value to bottom) + # right_x = barLeft + barWidth - 1 + # start_y = valuePixelInt if isinstance(valuePixelInt, int) else int(valuePixel) + # # Clamp to bar bounds + # if start_y < barTop: + # start_y = barTop + # if start_y > barBottom: + # start_y = barBottom + # p.drawRect(right_x, start_y, 1, barBottom - start_y) + # p.restore() From 5d697fbf2e59cc9d30addd9cb4ce82db4f4d1983 Mon Sep 17 00:00:00 2001 From: TerryH <32078938+tjh590@users.noreply.github.com> Date: Thu, 6 Nov 2025 21:10:21 -0500 Subject: [PATCH 14/20] testing --- garmintry.py | 162 ++++++++++++++++++++++ src/pyefis/instruments/gauges/abstract.py | 11 +- teststack.py | 43 +++--- 3 files changed, 194 insertions(+), 22 deletions(-) create mode 100644 garmintry.py diff --git a/garmintry.py b/garmintry.py new file mode 100644 index 0000000..e5c4f58 --- /dev/null +++ b/garmintry.py @@ -0,0 +1,162 @@ +# pip install PyQt6 +from PyQt6 import QtGui, QtWidgets, QtCore +from PyQt6.QtCore import Qt, QRect +from PyQt6.QtGui import QColor, QPainter, QPalette + +def qrgba(r, g, b): # clamp helper + return QColor(int(r), int(g), int(b)) + +# --- Color sets --- +# "Original" = pure primaries (max contrast, more visual "vibration") +ORIG_RED = qrgba(255, 0, 0) +ORIG_YELLOW = qrgba(255, 255, 0) +ORIG_GREEN = qrgba( 0, 255, 0) + +# "Adjusted" ~= luminance-balanced vivid colors (reduce apparent misalignment) +# You can tweak these toward your taste/monitor; these are a solid starting point. +ADJ_RED = qrgba(255, 60, 40) # brighten red by adding a little G +ADJ_YELLOW = qrgba(245, 220, 40) # pull green down slightly +ADJ_GREEN = qrgba( 0, 220, 80) # reduce luminance, small R to warm it + +# Optional: an HSL version with equal lightness (very stable visually) +def vivid_hsl(h_deg): # same lightness for all hues + return QColor.fromHsl(int(h_deg) % 360, 255, 130) # S=255, L=130 +HSL_RED, HSL_YELLOW, HSL_GREEN = vivid_hsl(0), vivid_hsl(55), vivid_hsl(120) + +class Bars(QtWidgets.QWidget): + def __init__(self): + super().__init__() + self.setMinimumSize(800, 240) + self.use_adjusted = True # start with adjusted set + self.use_hsl_equalL = False # try this as an alternative + self.use_separators = True # 1px separators tame edge illusions + + # Dark background similar to your screenshots + pal = self.palette() + pal.setColor(QPalette.ColorRole.Window, QColor(36, 36, 36)) + self.setAutoFillBackground(True) + self.setPalette(pal) + + def keyPressEvent(self, e: QtGui.QKeyEvent) -> None: + if e.key() == Qt.Key.Key_Space: + self.use_adjusted = not self.use_adjusted + self.update() + elif e.key() == Qt.Key.Key_H: + self.use_hsl_equalL = not self.use_hsl_equalL + self.update() + elif e.key() == Qt.Key.Key_S: + self.use_separators = not self.use_separators + self.update() + else: + super().keyPressEvent(e) + + def paintEvent(self, ev: QtGui.QPaintEvent) -> None: + p = QPainter(self) + # --- Pixel discipline --- + p.setRenderHint(QPainter.RenderHint.Antialiasing, False) # no AA for crisp edges + p.setPen(Qt.PenStyle.NoPen) # we fill only + p.setBrush(Qt.BrushStyle.SolidPattern) + p.setCompositionMode(QPainter.CompositionMode.CompositionMode_Source) + + # Device Pixel Ratio awareness (helps on 125%/150% scaling displays) + dpr = self.devicePixelRatioF() # float + # All geometry below uses ints so rasterization lands on whole pixels. + W, H = self.width(), self.height() + + # Layout + n = 6 + margin = 28 + gap = 22 + #bar_w = int((W - 2*margin - (n-1)*gap) / n) + bar_w = 10 + + # Band heights as integers + # Top red, thin yellow, tall green, thin yellow, bottom red (like your bars) + top_red_h = int(H * 0.12) + top_yel_h = int(H * 0.05) + green_h = int(H * 0.46) + bot_yel_h = int(H * 0.05) + bot_red_h = int(H * 0.12) + + # Vertical centering + total_h = top_red_h + top_yel_h + green_h + bot_yel_h + bot_red_h + y0 = int((H - total_h) / 2) + + # Choose color set + if self.use_hsl_equalL: + RED, YEL, GRN = HSL_RED, HSL_YELLOW, HSL_GREEN + elif self.use_adjusted: + RED, YEL, GRN = ADJ_RED, ADJ_YELLOW, ADJ_GREEN + else: + RED, YEL, GRN = ORIG_RED, ORIG_YELLOW, ORIG_GREEN + + # Optional thin neutral separators to “lock” edges visually + sep = 1 if self.use_separators else 0 + SEP_COLOR = QColor(52, 52, 52) # near-bg dark gray + + # Draw bars + x = margin + for i in range(n): + # compute integer rectangles (fillRect uses ints -> aligns to pixel grid) + y = y0 + + # top red + p.setBrush(RED) + p.fillRect(QRect(int(x), int(y), int(bar_w), int(top_red_h)), RED) + y += top_red_h + if sep: + p.fillRect(QRect(int(x), int(y), int(bar_w), sep), SEP_COLOR) + y += sep + + # top yellow + p.setBrush(YEL) + p.fillRect(QRect(int(x), int(y), int(bar_w), int(top_yel_h)), YEL) + y += top_yel_h + if sep: + p.fillRect(QRect(int(x), int(y), int(bar_w), sep), SEP_COLOR) + y += sep + + # green + p.setBrush(GRN) + p.fillRect(QRect(int(x), int(y), int(bar_w), int(green_h)), GRN) + y += green_h + if sep: + p.fillRect(QRect(int(x), int(y), int(bar_w), sep), SEP_COLOR) + y += sep + + # bottom yellow + p.setBrush(YEL) + p.fillRect(QRect(int(x), int(y), int(bar_w), int(bot_yel_h)), YEL) + y += bot_yel_h + if sep: + p.fillRect(QRect(int(x), int(y), int(bar_w), sep), SEP_COLOR) + y += sep + + # bottom red + p.setBrush(RED) + p.fillRect(QRect(int(x), int(y), int(bar_w), int(bot_red_h)), RED) + + x += bar_w + gap + + # Labels + p.setPen(QColor(220, 220, 220)) + font = p.font() + font.setPointSize(10) + p.setFont(font) + label = "Adjusted" if self.use_adjusted else "Original" + if self.use_hsl_equalL: label += " (HSL equal L)" + if self.use_separators: label += " + 1px separators" + p.drawText(self.rect().adjusted(8, 8, -8, -8), Qt.AlignmentFlag.AlignTop|Qt.AlignmentFlag.AlignLeft, label) + +def main(): + import sys + app = QtWidgets.QApplication(sys.argv) + # Optional: ensure crisp pixmaps on HiDPI + # QtGui.QGuiApplication.setAttribute(Qt.ApplicationAttribute.AA_UseHighDpiPixmaps, True) + w = Bars() + w.setWindowTitle("Garmin-style Bars (Space: toggle Original/Adjusted, H: toggle HSL equal L, S: separators)") + w.show() + sys.exit(app.exec()) + +if __name__ == "__main__": + main() diff --git a/src/pyefis/instruments/gauges/abstract.py b/src/pyefis/instruments/gauges/abstract.py index 1e9a2ea..2cbf655 100644 --- a/src/pyefis/instruments/gauges/abstract.py +++ b/src/pyefis/instruments/gauges/abstract.py @@ -103,13 +103,18 @@ def __init__(self, parent=None, font_family="DejaVu Sans Condensed"): #Qt.GlobalColor.green #QColor( 0, 180, 60) - self.safe_good_color = Qt.GlobalColor.green + #self.safe_good_color = Qt.GlobalColor.green + self.safe_good_color = QColor( 0, 255, 0) + #Qt.GlobalColor.yellow #QColor(255, 200, 40) - self.warn_good_color = Qt.GlobalColor.yellow + #self.warn_good_color = Qt.GlobalColor.yellow + self.warn_good_color = QColor( 255, 255, 0) + #Qt.GlobalColor.red #QColor(220, 60, 40) - self.alarm_good_color = Qt.GlobalColor.red + #self.alarm_good_color = Qt.GlobalColor.red + self.alarm_good_color = QColor( 220, 60, 40) self.text_good_color = Qt.GlobalColor.white self.pen_good_color = Qt.GlobalColor.white diff --git a/teststack.py b/teststack.py index 4a3c274..a251133 100644 --- a/teststack.py +++ b/teststack.py @@ -15,15 +15,15 @@ # Bar configuration BAR_COUNT = 6 # Number of bars to display side by side -BAR_WIDTH = 40 -BAR_HEIGHT = 400 +BAR_WIDTH = 20 +BAR_HEIGHT = 160 BAR_SPACING = 20 # Space between bars BAR_START_X = 50 # Left margin BAR_START_Y = 100 # Top margin # Simulate widget height variations (each bar might be slightly different height in pyEfis) # This simulates how different widgets might round font sizes differently -WIDGET_HEIGHTS = [400, 400, 400, 400, 400, 400] # Slight variations +WIDGET_HEIGHTS = [160, 160, 160, 160, 160, 160] # Slight variations SHOW_NAME = True SHOW_VALUE = True SHOW_UNITS = True @@ -45,9 +45,9 @@ CURRENT_VALUE = 180 # Colors -SAFE_COLOR = QColor(0, 255, 0) #Qt.GlobalColor.green #QColor(0, 255, 0) # Green -WARN_COLOR = QColor(255, 255, 0) #Qt.GlobalColor.yellow #QColor(255, 255, 0) # Yellow -ALARM_COLOR = QColor(255, 0, 0) #Qt.GlobalColor.red #QColor(255, 0, 0) # Red +SAFE_COLOR = QColor("#00FF00") #Qt.GlobalColor.green #QColor(0, 255, 0) # Green +WARN_COLOR = QColor("#FFFF00") #Qt.GlobalColor.yellow #QColor(255, 255, 0) # Yellow +ALARM_COLOR = QColor("#FF0000") #Qt.GlobalColor.red #QColor(255, 0, 0) # Red BG_COLOR = QColor(40, 40, 40) #Qt.GlobalColor.black #QColor(40, 40, 40) # Dark gray background # Font @@ -185,7 +185,8 @@ def _drawSingleBar(self, p, barLeft, barIndex, barLabel): pen.setColor(ALARM_COLOR) p.setPen(pen) p.setBrush(ALARM_COLOR) - p.drawRect(barLeft, currentTop, barWidth, alarmHeight) + #p.drawRect(barLeft, currentTop, barWidth, alarmHeight) + p.fillRect(QRectF(barLeft, currentTop, barWidth, alarmHeight),ALARM_COLOR) currentTop = highAlarmPixel # High warning zone @@ -197,7 +198,8 @@ def _drawSingleBar(self, p, barLeft, barIndex, barLabel): pen.setColor(WARN_COLOR) p.setPen(pen) p.setBrush(WARN_COLOR) - p.drawRect(barLeft, currentTop, barWidth, warnHeight) + #p.drawRect(barLeft, currentTop, barWidth, warnHeight) + p.fillRect(QRectF(barLeft, currentTop, barWidth, warnHeight), WARN_COLOR) currentTop = highWarnPixel # Safe zone (middle) @@ -209,7 +211,8 @@ def _drawSingleBar(self, p, barLeft, barIndex, barLabel): pen.setColor(SAFE_COLOR) p.setPen(pen) p.setBrush(SAFE_COLOR) - p.drawRect(barLeft, currentTop, barWidth, safeHeight) + #p.drawRect(barLeft, currentTop, barWidth, safeHeight) + p.fillRect(QRectF(barLeft, currentTop, barWidth, safeHeight), SAFE_COLOR) currentTop = safeBottom # Low warning zone @@ -222,7 +225,8 @@ def _drawSingleBar(self, p, barLeft, barIndex, barLabel): pen.setColor(WARN_COLOR) p.setPen(pen) p.setBrush(WARN_COLOR) - p.drawRect(barLeft, currentTop, barWidth, warnHeight) + #p.drawRect(barLeft, currentTop, barWidth, warnHeight) + p.fillRect(QRectF(barLeft, currentTop, barWidth, warnHeight), WARN_COLOR) currentTop = lowWarnBottom # Bottom alarm zone (low alarm) @@ -234,17 +238,18 @@ def _drawSingleBar(self, p, barLeft, barIndex, barLabel): pen.setColor(ALARM_COLOR) p.setPen(pen) p.setBrush(ALARM_COLOR) - p.drawRect(barLeft, currentTop, barWidth, alarmHeight) + #p.drawRect(barLeft, currentTop, barWidth, alarmHeight) + p.fillRect(QRectF(barLeft, currentTop, barWidth, alarmHeight), ALARM_COLOR) # Draw current value indicator - valuePixel = self._calculateThresholdPixel(CURRENT_VALUE, barTop, barBottom) - if valuePixel is not None: - pen.setColor(QColor(255, 255, 255)) # White - pen.setWidth(2) - p.setPen(pen) - lineLeft = barLeft - 5 - lineRight = barLeft + barWidth + 5 - p.drawLine(lineLeft, int(valuePixel), lineRight, int(valuePixel)) + # valuePixel = self._calculateThresholdPixel(CURRENT_VALUE, barTop, barBottom) + # if valuePixel is not None: + # pen.setColor(QColor(255, 255, 255)) # White + # pen.setWidth(2) + # p.setPen(pen) + # lineLeft = barLeft - 5 + # lineRight = barLeft + barWidth + 5 + # p.drawLine(lineLeft, int(valuePixel), lineRight, int(valuePixel)) # Draw label below bar p.setRenderHint(QPainter.RenderHint.Antialiasing, True) From ca76a2236bda8a692387e010ec91e2ff316c15e5 Mon Sep 17 00:00:00 2001 From: TerryH <32078938+tjh590@users.noreply.github.com> Date: Sat, 8 Nov 2025 12:36:54 -0500 Subject: [PATCH 15/20] new config_inpsector app --- tools/config_inspector.py | 437 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 437 insertions(+) create mode 100644 tools/config_inspector.py diff --git a/tools/config_inspector.py b/tools/config_inspector.py new file mode 100644 index 0000000..d86f8fa --- /dev/null +++ b/tools/config_inspector.py @@ -0,0 +1,437 @@ +#!/usr/bin/env python3 + +""" +Config Inspector: Standalone Qt app to explore pyEfis configuration + +Features: +- Loads preferences.yaml (+ preferences.yaml.custom overrides) and default.yaml +- Resolves includes according to preferences['includes'] mapping +- Presents a consolidated tree view of the final configuration +- Marks values resolved from preferences (e.g., enabled.AUTO_START) +- Marks preference keys overridden by .custom with an [override] tag +- Shows expandable nodes for each include with the concrete file used +- If an include file is used >1 time, annotate nodes and provide a summary + +This viewer is read-only and does not modify config files. +""" + +from __future__ import annotations + +import argparse +import os +import sys +import yaml +from dataclasses import dataclass, field +from typing import Any, Dict, List, Tuple, Optional, Set + +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QStandardItemModel, QStandardItem +from PyQt6.QtWidgets import QApplication, QMainWindow, QTreeView, QWidget, QVBoxLayout, QLabel, QSplitter + + +# ---------- Utilities for preferences loading and merge tracking ---------- + +Path = Tuple[str, ...] + + +def deep_merge_with_overrides(base: Any, override: Any, path: Optional[Path] = None, overridden: Optional[set] = None) -> Any: + """Deep merge override into base. Track paths where override changes/defines values. + + Returns the merged value. Collects overridden key paths (as tuples) in the 'overridden' set. + """ + if overridden is None: + overridden = set() + if path is None: + path = tuple() + + # If either side is not a dict, override completely + if not isinstance(base, dict) or not isinstance(override, dict): + if override is not None: + overridden.add(path) + return override + return base + + out = dict(base) + for k, v in override.items(): + p = path + (k,) + if k in base: + out[k] = deep_merge_with_overrides(base[k], v, p, overridden) + # If the merged subtree equals base[k] but we've passed through override, still record if value differs + if isinstance(base[k], dict) and isinstance(v, dict): + # changes recorded at leaf nodes already + pass + else: + if base[k] != out[k]: + overridden.add(p) + else: + out[k] = v + overridden.add(p) + return out + + +def load_preferences(config_dir: str) -> Tuple[Dict[str, Any], Set[Path]]: + """Load preferences.yaml and merge preferences.yaml.custom if present, tracking overridden paths.""" + pref_path = os.path.join(config_dir, "preferences.yaml") + with open(pref_path, "r", encoding="utf-8") as f: + base = yaml.safe_load(f) or {} + cust_path = pref_path + ".custom" + overridden: Set[Path] = set() + if os.path.exists(cust_path): + with open(cust_path, "r", encoding="utf-8") as f: + custom = yaml.safe_load(f) or {} + merged = deep_merge_with_overrides(base, custom, overridden=overridden) + return merged, overridden + return base, overridden + + +# ---------- Include resolution and traversal with tracking ---------- + +@dataclass +class IncludeRecord: + logical: str + file_path: str + contexts: List[str] = field(default_factory=list) + + +class ConfigWalker: + def __init__(self, base_config_dir: str, preferences: Dict[str, Any]): + self.base_config_dir = base_config_dir + self.preferences = preferences or {} + self.includes_used: Dict[str, IncludeRecord] = {} # key: resolved file path + self.include_nodes: List[Tuple[str, QStandardItem]] = [] # (file_path, item) + + def _resolve_include_file(self, current_file_dir: str, logical_or_path: str) -> Tuple[str, str]: + """Resolve include using resolution order: current dir -> base dir -> preferences.includes mapping. + Returns (logical_name, resolved_path). + """ + # If it's a direct path that exists relative to current + candidate = os.path.join(current_file_dir, logical_or_path) + if os.path.exists(candidate): + return logical_or_path, os.path.normpath(candidate) + + # Try base dir + candidate = os.path.join(self.base_config_dir, logical_or_path) + if os.path.exists(candidate): + return logical_or_path, os.path.normpath(candidate) + + # Try preferences includes mapping + logical = logical_or_path + includes_map = (self.preferences or {}).get("includes", {}) + mapped = includes_map.get(logical) + if mapped: + # mapped can be relative to current or base + cand2 = os.path.join(current_file_dir, mapped) + if os.path.exists(cand2): + return logical, os.path.normpath(cand2) + cand2 = os.path.join(self.base_config_dir, mapped) + if os.path.exists(cand2): + return logical, os.path.normpath(cand2) + raise FileNotFoundError(f"Cannot resolve include '{logical_or_path}' (base: {self.base_config_dir})") + + def _record_include(self, logical: str, resolved_path: str, context: str) -> None: + rec = self.includes_used.get(resolved_path) + if not rec: + rec = IncludeRecord(logical=logical, file_path=resolved_path, contexts=[]) + self.includes_used[resolved_path] = rec + rec.contexts.append(context) + + # ---------- Tree model population ---------- + def build_model(self, root_config_file: str) -> QStandardItemModel: + model = QStandardItemModel() + model.setHorizontalHeaderLabels(["Key", "Value", "Source/Notes"]) # 3 columns + + # – Preferences root + prefs_root = QStandardItem("Preferences (merged)") + prefs_root.setEditable(False) + model.appendRow([prefs_root, QStandardItem("") , QStandardItem("")]) + self._populate_preferences_tree(prefs_root) + + # – Config root + config_root = QStandardItem("Config (consolidated)") + config_root.setEditable(False) + model.appendRow([config_root, QStandardItem("") , QStandardItem(os.path.relpath(root_config_file, self.base_config_dir))]) + + # Walk the main config + file_dir = os.path.dirname(root_config_file) + with open(root_config_file, "r", encoding="utf-8") as f: + cfg = yaml.safe_load(f) or {} + + # Build tree from cfg + self._walk_mapping(cfg, parent=config_root, current_file=root_config_file, breadcrumb=("",)) + + # – Includes summary root + summary_root = QStandardItem("Includes summary") + summary_root.setEditable(False) + model.appendRow([summary_root, QStandardItem("") , QStandardItem("")]) + for path, rec in sorted(self.includes_used.items(), key=lambda kv: kv[0]): + label = f"{os.path.relpath(path, self.base_config_dir)}" + used_n = len(rec.contexts) + notes = f"used {used_n} time(s); logical: {rec.logical}; contexts: " + " | ".join(rec.contexts) + item = QStandardItem(label) + item.setEditable(False) + summary_root.appendRow([item, QStandardItem("") , QStandardItem(notes)]) + + # After building, annotate include nodes that were used multiple times + counts: Dict[str, int] = {k: len(v.contexts) for k, v in self.includes_used.items()} + for fpath, item in self.include_nodes: + cnt = counts.get(fpath, 1) + if cnt > 1: + item.setText(f"[include] {item.text()} (used {cnt}x)") + return model + + def _populate_preferences_tree(self, parent: QStandardItem) -> None: + # Show merged preferences; mark overridden paths + overridden_paths = getattr(self, "_overridden_paths", set()) + + def add_node(key: str, val: Any, p: QStandardItem, path: Path) -> None: + key_item = QStandardItem(str(key)) + key_item.setEditable(False) + val_item = QStandardItem("") + src_item = QStandardItem("") + + if isinstance(val, dict): + p.appendRow([key_item, val_item, src_item]) + for k, v in val.items(): + add_node(k, v, key_item, path + (k,)) + elif isinstance(val, list): + p.appendRow([key_item, QStandardItem(f"[{len(val)} items]"), src_item]) + for i, v in enumerate(val): + add_node(f"[{i}]", v, key_item, path + (str(i),)) + else: + # scalar + vtext = self._scalar_to_text(val) + val_item.setText(vtext) + # mark override + if path in overridden_paths: + src_item.setText("[override]") + p.appendRow([key_item, val_item, src_item]) + + add_node("root", self.preferences, parent, tuple()) + + def set_overridden_paths(self, overridden_paths: Set[Path]) -> None: + self._overridden_paths = overridden_paths + + def _scalar_to_text(self, v: Any) -> str: + if isinstance(v, (int, float)): + return str(v) + if isinstance(v, bool): + return "true" if v else "false" + if v is None: + return "null" + return str(v) + + # -------------- YAML traversal and model building -------------- + def _walk_mapping(self, mapping: Dict[str, Any], parent: QStandardItem, current_file: str, breadcrumb: Path) -> None: + cur_dir = os.path.dirname(current_file) + for key, val in (mapping or {}).items(): + if key == "include": + # This can be a string or list of strings + files: List[str] + if isinstance(val, str): + files = [val] + elif isinstance(val, list): + files = val + else: + raise SyntaxError(f"include in {current_file} must be a string or list") + + for f in files: + logical, resolved = self._resolve_include_file(cur_dir, f) + # include node + short = os.path.relpath(resolved, self.base_config_dir) + include_item = QStandardItem(f"{logical} -> {short}") + include_item.setEditable(False) + # Second column empty (it's a container) + src_item = QStandardItem("[include]") + parent.appendRow([QStandardItem("[include]"), QStandardItem("") , QStandardItem(f"{logical} -> {short}")]) + # The visual tree should put children under the include descriptor; use the last inserted row's first item as parent + include_parent: QStandardItem = parent.child(parent.rowCount() - 1, 0) + include_parent.setText(f"[include] {logical} -> {short}") + self.include_nodes.append((resolved, include_parent)) + + # Record usage + self._record_include(logical, resolved, context="/".join(breadcrumb)) + + # Load included YAML and traverse it + with open(resolved, "r", encoding="utf-8") as incf: + sub = yaml.safe_load(incf) or {} + if isinstance(sub, dict): + self._walk_mapping(sub, include_parent, current_file=resolved, breadcrumb=breadcrumb + (f"include:{logical}",)) + else: + # If included file yields non-dict, just display scalar/list directly + self._walk_value(sub, include_parent, current_file=resolved, key_label="", breadcrumb=breadcrumb + (f"include:{logical}",)) + continue + + # Normal key path + key_item = QStandardItem(str(key)) + key_item.setEditable(False) + if isinstance(val, dict): + parent.appendRow([key_item, QStandardItem("") , QStandardItem("")]) + self._walk_mapping(val, key_item, current_file=current_file, breadcrumb=breadcrumb + (str(key),)) + elif isinstance(val, list): + parent.appendRow([key_item, QStandardItem(f"[{len(val)} items]"), QStandardItem("")]) + self._walk_list(val, key_item, current_file=current_file, breadcrumb=breadcrumb + (str(key),)) + else: + # scalar: resolve enabled.* if applicable + display_text, notes = self._resolve_scalar_with_preferences(val) + parent.appendRow([key_item, QStandardItem(display_text), QStandardItem(notes)]) + + def _walk_list(self, arr: List[Any], parent: QStandardItem, current_file: str, breadcrumb: Path) -> None: + cur_dir = os.path.dirname(current_file) + for i, elem in enumerate(arr): + # list-level include form: element is a dict with 'include' + if isinstance(elem, dict) and "include" in elem: + logical, resolved = self._resolve_include_file(cur_dir, elem["include"]) + short = os.path.relpath(resolved, self.base_config_dir) + parent.appendRow([QStandardItem("[include]"), QStandardItem("") , QStandardItem(f"{logical} -> {short}")]) + include_parent: QStandardItem = parent.child(parent.rowCount() - 1, 0) + include_parent.setText(f"[include] {logical} -> {short}") + self.include_nodes.append((resolved, include_parent)) + self._record_include(logical, resolved, context="/".join(breadcrumb + (f"[{i}]",))) + + # load items + with open(resolved, "r", encoding="utf-8") as incf: + sub = yaml.safe_load(incf) or {} + if not isinstance(sub, dict) or "items" not in sub: + # Fallback: show whatever is in the file under this include + self._walk_value(sub, include_parent, current_file=resolved, key_label="", breadcrumb=breadcrumb + (f"include:{logical}",)) + else: + items = sub.get("items") + if isinstance(items, list): + for j, a in enumerate(items): + self._walk_value(a, include_parent, current_file=resolved, key_label=f"[{j}]", breadcrumb=breadcrumb + (f"include:{logical}",)) + elif isinstance(items, dict): + # edge: dict under items + for k, v in items.items(): + self._walk_value({k: v}, include_parent, current_file=resolved, key_label=str(k), breadcrumb=breadcrumb + (f"include:{logical}",)) + continue + + # normal element + self._walk_value(elem, parent, current_file=current_file, key_label=f"[{i}]", breadcrumb=breadcrumb + (f"[{i}]",)) + + def _walk_value(self, val: Any, parent: QStandardItem, current_file: str, key_label: str, breadcrumb: Path) -> None: + if isinstance(val, dict): + key_item = QStandardItem(key_label) + key_item.setEditable(False) + parent.appendRow([key_item, QStandardItem("") , QStandardItem("")]) + self._walk_mapping(val, key_item, current_file=current_file, breadcrumb=breadcrumb + (key_label,)) + elif isinstance(val, list): + key_item = QStandardItem(key_label) + key_item.setEditable(False) + parent.appendRow([key_item, QStandardItem(f"[{len(val)} items]"), QStandardItem("")]) + self._walk_list(val, key_item, current_file=current_file, breadcrumb=breadcrumb + (key_label,)) + else: + display_text, notes = self._resolve_scalar_with_preferences(val) + parent.appendRow([QStandardItem(key_label), QStandardItem(display_text), QStandardItem(notes)]) + + def _resolve_scalar_with_preferences(self, val: Any) -> Tuple[str, str]: + """If val is a string that matches preferences['enabled'] key, return final bool and marker. + Otherwise return stringified val. + """ + # Convert scalars + if isinstance(val, str): + enabled = (self.preferences or {}).get("enabled", {}) or {} + if val in enabled: + resolved = enabled[val] + # annotate if overridden + ov = getattr(self, "_overridden_paths", set()) + over_mark = " [override]" if (('enabled', val) in ov or ("enabled", val) in ov) else "" + return ("true" if bool(resolved) else "false", f"resolved from enabled.{val}{over_mark}") + # Not an enabled token + return (val, "") + # Non-string scalars + if isinstance(val, bool): + return ("true" if val else "false", "") + if val is None: + return ("null", "") + return (str(val), "") + + +# ---------- Main Window ---------- + +class ConfigInspectorWindow(QMainWindow): + def __init__(self, model: QStandardItemModel, base_dir: str, root_file: str): + super().__init__() + self.setWindowTitle("pyEfis Config Inspector") + self.resize(1100, 800) + + # Main widget + main = QWidget(self) + layout = QVBoxLayout(main) + main.setLayout(layout) + + info = QLabel(f"Base config dir: {base_dir}\nRoot config: {os.path.relpath(root_file, base_dir)}") + info.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + + tree = QTreeView(self) + tree.setModel(model) + tree.setUniformRowHeights(True) + tree.setAlternatingRowColors(True) + tree.setSortingEnabled(False) + tree.expandToDepth(1) + tree.header().setStretchLastSection(True) + tree.header().setDefaultSectionSize(380) + + layout.addWidget(info) + layout.addWidget(tree) + + self.setCentralWidget(main) + + +# ---------- CLI / Entry Point ---------- + +def find_default_config() -> Tuple[str, str]: + """Attempt to find default.yaml and return (base_config_dir, default_yaml_path).""" + user_home = os.environ.get('SNAP_REAL_HOME', os.path.expanduser("~")) + prefix_path = sys.prefix + path_options = [ + '{USER}/makerplane/pyefis/config', + '{PREFIX}/local/etc/pyefis', + '{PREFIX}/etc/pyefis', + '/etc/pyefis', + '.', + ] + config_filename = 'default.yaml' + for directory in path_options: + d = directory.format(USER=user_home, PREFIX=prefix_path) + cand = os.path.join(d, config_filename) + if os.path.isfile(cand): + return os.path.abspath(d), os.path.abspath(cand) + # Fallback to repo-relative location + here = os.path.dirname(os.path.abspath(__file__)) + repo_config = os.path.abspath(os.path.join(here, '..', 'config')) + cand = os.path.join(repo_config, config_filename) + return repo_config, cand + + +def main(): + parser = argparse.ArgumentParser(description="pyEfis Config Inspector") + parser.add_argument("--config", dest="config_file", help="Path to config/default.yaml. Defaults to auto-detect.") + parser.add_argument("--base", dest="base_dir", help="Base config directory. Defaults to directory of --config or auto-detected.") + args = parser.parse_args() + + if args.config_file: + config_file = os.path.abspath(args.config_file) + base_dir = os.path.abspath(args.base_dir) if args.base_dir else os.path.dirname(config_file) + else: + base_dir, config_file = find_default_config() + + if not os.path.exists(config_file): + print(f"Error: cannot find config file: {config_file}", file=sys.stderr) + sys.exit(2) + + # Load preferences and overrides + preferences, overridden = load_preferences(base_dir) + + # Build model + app = QApplication(sys.argv) + walker = ConfigWalker(base_dir, preferences) + walker.set_overridden_paths(overridden) + model = walker.build_model(config_file) + + w = ConfigInspectorWindow(model, base_dir, config_file) + w.show() + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() From 3f801fdc8257808fcbb2ae08a3c5b1e66d1a17d3 Mon Sep 17 00:00:00 2001 From: TerryH <32078938+tjh590@users.noreply.github.com> Date: Sat, 8 Nov 2025 14:17:14 -0500 Subject: [PATCH 16/20] additional features --- tools/config_inspector.py | 257 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 244 insertions(+), 13 deletions(-) diff --git a/tools/config_inspector.py b/tools/config_inspector.py index d86f8fa..cb498a2 100644 --- a/tools/config_inspector.py +++ b/tools/config_inspector.py @@ -24,9 +24,25 @@ from dataclasses import dataclass, field from typing import Any, Dict, List, Tuple, Optional, Set -from PyQt6.QtCore import Qt +from PyQt6.QtCore import Qt, QPoint, QRegularExpression from PyQt6.QtGui import QStandardItemModel, QStandardItem -from PyQt6.QtWidgets import QApplication, QMainWindow, QTreeView, QWidget, QVBoxLayout, QLabel, QSplitter +from PyQt6.QtWidgets import ( + QApplication, + QMainWindow, + QTreeView, + QWidget, + QVBoxLayout, + QLabel, + QSplitter, + QHBoxLayout, + QLineEdit, + QMenu, + QComboBox, + QPlainTextEdit, + QDialog, + QDialogButtonBox, +) +from PyQt6.QtCore import QSortFilterProxyModel # ---------- Utilities for preferences loading and merge tracking ---------- @@ -171,12 +187,28 @@ def build_model(self, root_config_file: str) -> QStandardItemModel: item.setEditable(False) summary_root.appendRow([item, QStandardItem("") , QStandardItem(notes)]) - # After building, annotate include nodes that were used multiple times + # After building, annotate include nodes to compact multiple [include] markers + # Replace any number of leading "[include] " with a single "[{n}x] " when a file is used multiple times + # and remove any trailing "(used nx)" legacy suffixes. counts: Dict[str, int] = {k: len(v.contexts) for k, v in self.includes_used.items()} for fpath, item in self.include_nodes: cnt = counts.get(fpath, 1) + text = item.text() + + # Remove any trailing legacy suffix like " (used 6x)" + if " (used " in text: + text = text[: text.rfind(" (used ")] + + # Strip all leading "[include] " prefixes + prefix = "[include] " + while text.startswith(prefix): + text = text[len(prefix) :] + + # Re-apply compact prefix if cnt > 1: - item.setText(f"[include] {item.text()} (used {cnt}x)") + item.setText(f"[{cnt}x] {text}") + else: + item.setText(f"[include] {text}") return model def _populate_preferences_tree(self, parent: QStandardItem) -> None: @@ -362,20 +394,219 @@ def __init__(self, model: QStandardItemModel, base_dir: str, root_file: str): info = QLabel(f"Base config dir: {base_dir}\nRoot config: {os.path.relpath(root_file, base_dir)}") info.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) - tree = QTreeView(self) - tree.setModel(model) - tree.setUniformRowHeights(True) - tree.setAlternatingRowColors(True) - tree.setSortingEnabled(False) - tree.expandToDepth(1) - tree.header().setStretchLastSection(True) - tree.header().setDefaultSectionSize(380) + # Filters row + filters_row = QWidget(self) + filters_layout = QHBoxLayout(filters_row) + filters_row.setLayout(filters_layout) + self.key_filter_edit = QLineEdit(self) + self.key_filter_edit.setPlaceholderText("Filter by key (case-sensitive substring)") + self.value_filter_edit = QLineEdit(self) + self.value_filter_edit.setPlaceholderText("Filter by value (case-sensitive substring)") + filters_layout.addWidget(QLabel("Key filter:")) + filters_layout.addWidget(self.key_filter_edit, 1) + filters_layout.addWidget(QLabel("Value filter:")) + filters_layout.addWidget(self.value_filter_edit, 1) + + # Proxy model for filtering + self.proxy = TreeFilterProxy() + self.proxy.setSourceModel(model) + self.proxy.setRecursiveFilteringEnabled(True) + + # Tree view + self.tree = QTreeView(self) + self.tree.setModel(self.proxy) + # Enable non-uniform rows so wrapped text can expand height if needed + self.tree.setUniformRowHeights(False) + self.tree.setAlternatingRowColors(True) + self.tree.setSortingEnabled(False) + self.tree.expandToDepth(1) + self.tree.header().setStretchLastSection(True) + self.tree.header().setDefaultSectionSize(380) + # Allow wrapping in cells when text has line breaks + self.tree.setWordWrap(True) + # Context menu for expand/collapse subtree + self.tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.tree.customContextMenuRequested.connect(self._on_context_menu) + + # Wire up filters + self.key_filter_edit.textChanged.connect(self._on_filters_changed) + self.value_filter_edit.textChanged.connect(self._on_filters_changed) + + # AND/OR operator selector for filters + op_row = QWidget(self) + op_layout = QHBoxLayout(op_row) + op_row.setLayout(op_layout) + op_layout.addWidget(QLabel("Filter logic:")) + self.logic_combo = QComboBox(self) + self.logic_combo.addItems(["AND", "OR"]) # default AND + self.logic_combo.currentTextChanged.connect(self._on_filters_changed) + op_layout.addWidget(self.logic_combo) layout.addWidget(info) - layout.addWidget(tree) + layout.addWidget(filters_row) + layout.addWidget(op_row) + layout.addWidget(self.tree) self.setCentralWidget(main) + def _on_filters_changed(self, *_): + key_text = self.key_filter_edit.text().strip() + val_text = self.value_filter_edit.text().strip() + self.proxy.set_key_filter(key_text) + self.proxy.set_value_filter(val_text) + self.proxy.set_op_mode(self.logic_combo.currentText() or "AND") + + # Optionally expand nodes that remain to show matches context (leave as-is) + + def _on_context_menu(self, pos: QPoint): + index = self.tree.indexAt(pos) + if not index.isValid(): + return + menu = QMenu(self) + act_expand = menu.addAction("All expand") + act_collapse = menu.addAction("All collapse") + act_view = menu.addAction("View full text…") + action = menu.exec(self.tree.viewport().mapToGlobal(pos)) + if action == act_expand: + self._expand_subtree(index) + elif action == act_collapse: + self._collapse_subtree(index) + elif action == act_view: + self._open_view_dialog(index) + + def _expand_subtree(self, proxy_index): + # Expand this node and all descendants + stack = [proxy_index] + while stack: + idx = stack.pop() + if not idx.isValid(): + continue + self.tree.expand(idx) + for r in range(self.proxy.rowCount(idx)): + child = self.proxy.index(r, 0, idx) + stack.append(child) + + def _collapse_subtree(self, proxy_index): + # Collapse descendants then the node + stack = [proxy_index] + order = [] + while stack: + idx = stack.pop() + if not idx.isValid(): + continue + order.append(idx) + for r in range(self.proxy.rowCount(idx)): + child = self.proxy.index(r, 0, idx) + stack.append(child) + # collapse in reverse so children first + for idx in reversed(order): + self.tree.collapse(idx) + + def _open_view_dialog(self, proxy_index): + if not proxy_index.isValid(): + return + # Gather row data + def col_text(c): + return self.proxy.data(self.proxy.index(proxy_index.row(), c, proxy_index.parent()), Qt.ItemDataRole.DisplayRole) or "" + key = col_text(0) + val = col_text(1) + notes = col_text(2) + dlg = QDialog(self) + dlg.setWindowTitle("Full text") + dlg.resize(700, 500) + v = QVBoxLayout(dlg) + t = QPlainTextEdit(dlg) + t.setReadOnly(True) + t.setPlainText(f"Key:\n{key}\n\nValue:\n{val}\n\nNotes:\n{notes}") + v.addWidget(t) + buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok, parent=dlg) + buttons.accepted.connect(dlg.accept) + buttons.rejected.connect(dlg.reject) + v.addWidget(buttons) + dlg.exec() + + +class TreeFilterProxy(QSortFilterProxyModel): + """Filter by key (column 0) and value (column 1) independently. + Both filters are AND-ed: a row matches if it matches the key filter AND the value filter. + Recursive filtering is enabled so parents remain if any child matches. + """ + def __init__(self): + super().__init__() + self._key_re: Optional[QRegularExpression] = None + self._val_re: Optional[QRegularExpression] = None + # Case-sensitive by default per request + self.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseSensitive) + self._op_mode: str = "AND" # or "OR" + + def set_key_filter(self, text: str): + self._key_re = self._make_re(text) + self.invalidateFilter() + + def set_value_filter(self, text: str): + self._val_re = self._make_re(text) + self.invalidateFilter() + + def _make_re(self, text: str) -> Optional[QRegularExpression]: + if not text: + return None + # escape and wrap as contains + pattern = QRegularExpression.escape(text) + return QRegularExpression(f".*{pattern}.*") + + def set_op_mode(self, mode: str): + mode = (mode or "AND").upper() + if mode not in ("AND", "OR"): + mode = "AND" + if mode != self._op_mode: + self._op_mode = mode + self.invalidateFilter() + + def filterAcceptsRow(self, source_row: int, source_parent) -> bool: + model = self.sourceModel() + idx_key = model.index(source_row, 0, source_parent) + idx_val = model.index(source_row, 1, source_parent) + + def text(index): + return model.data(index, Qt.ItemDataRole.DisplayRole) or "" + + # Check self match + key_ok = True + val_ok = True + if self._key_re is not None: + key_ok = self._key_re.match(str(text(idx_key))).hasMatch() + if self._val_re is not None: + val_ok = self._val_re.match(str(text(idx_val))).hasMatch() + + if self._op_mode == "AND": + # AND: all non-empty filters must match + if self._key_re is not None and not key_ok: + self_match = False + elif self._val_re is not None and not val_ok: + self_match = False + else: + self_match = True + else: # OR + active = [] + if self._key_re is not None: + active.append(key_ok) + if self._val_re is not None: + active.append(val_ok) + if not active: # no filters active -> match everything + self_match = True + else: + self_match = any(active) + + if self_match: + return True + + # Otherwise, accept if any child matches recursively + for r in range(model.rowCount(idx_key)): + if self.filterAcceptsRow(r, idx_key): + return True + + return False + # ---------- CLI / Entry Point ---------- From 782ad0d1cd85421887eb4feb919778ab29ced3dd Mon Sep 17 00:00:00 2001 From: TerryH <32078938+tjh590@users.noreply.github.com> Date: Sat, 8 Nov 2025 16:28:34 -0500 Subject: [PATCH 17/20] horizontal bars can have values on the left side --- .../instruments/gauges/horizontalBar.py | 110 ++++++++++++------ .../gauges/horizontalBarImproved.py | 60 ++++++---- .../instruments/gauges/verticalBarImproved.py | 2 +- src/pyefis/screens/screenbuilder.py | 2 +- 4 files changed, 111 insertions(+), 63 deletions(-) diff --git a/src/pyefis/instruments/gauges/horizontalBar.py b/src/pyefis/instruments/gauges/horizontalBar.py index e9e3703..786871f 100644 --- a/src/pyefis/instruments/gauges/horizontalBar.py +++ b/src/pyefis/instruments/gauges/horizontalBar.py @@ -35,6 +35,14 @@ def __init__(self, parent=None, min_size=True, font_family="DejaVu Sans Condense self.segment_gap_percent = 0.01 self.segment_alpha = 180 self.bar_divisor = 4.5 + # New options: place value/dbkey text on the left side of the bar + self.value_on_bar_left = False + self.value_on_bar_left_width_percent = 0.25 # portion of width reserved for left label + self.show_dbkey_text = False # when true, show dbkey text instead of numeric value on left label + # Internals computed on resize + self._bar_left = 0 + self._bar_width = 0 + self.barValueRect = QRectF() def getRatio(self): # Return X for 1:x specifying the ratio for this instrument return 2 @@ -57,10 +65,32 @@ def resizeEvent(self, event): self.barHeight = self.section_size * self.bar_divisor self.barTop = self.section_size * 2.7 self.nameTextRect = QRectF(1, 0, self.width(), self.section_size * 2.4) - self.valueTextRect = QRectF(1, self.section_size * 8, - self.width()-5, self.section_size * 4) + self.valueTextRect = QRectF(1, self.section_size * 8, self.width()-5, self.section_size * 4) + # Geometry is recalculated in paintEvent as well to reflect dynamic late-applied flags + self._recompute_geometry() + + def _recompute_geometry(self): + """Recalculate geometry based on current flags. + Called in resizeEvent and paintEvent to handle preferences applied after initial resize.""" + self._bar_left = 0 + self._bar_width = self.width() + if self.value_on_bar_left: + pct = max(0.05, min(0.5, float(getattr(self, 'value_on_bar_left_width_percent', 0.25) or 0.25))) + left_w = int(self.width() * pct) + gap = 4 + self.barValueRect = QRectF(2, self.barTop, max(0, left_w - gap), self.barHeight) + self._bar_left = left_w + self._bar_width = max(0, self.width() - self._bar_left) + else: + self.barValueRect = QRectF() + + def get_bar_geometry(self): + """Return (left, top, width, height) for the drawable bar region, respecting left label space.""" + return (int(self._bar_left), int(self.barTop), int(self._bar_width), int(self.barHeight)) def paintEvent(self, event): + # Ensure geometry reflects any preference attributes set after construction + self._recompute_geometry() p = QPainter(self) p.setRenderHint(QPainter.RenderHint.Antialiasing) pen = QPen() @@ -97,22 +127,29 @@ def paintEvent(self, event): p.setPen(pen) p.drawText(self.valueTextRect, self.units, opt) - # Main Value - p.setFont(self.bigFont) - #pen.setColor(self.valueColor) - #p.setPen(pen) - opt = QTextOption(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignBottom) - if self.show_value: - if self.font_ghost_mask: - alpha = self.valueColor.alpha() - self.valueColor.setAlpha(self.font_ghost_alpha) + # Main Value (standard position) unless moved to left of bar + if not self.value_on_bar_left: + p.setFont(self.bigFont) + opt = QTextOption(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignBottom) + if self.show_value: + if self.font_ghost_mask: + alpha = self.valueColor.alpha() + self.valueColor.setAlpha(self.font_ghost_alpha) + pen.setColor(self.valueColor) + p.setPen(pen) + p.drawText(self.valueTextRect, self.font_ghost_mask, opt) + self.valueColor.setAlpha(alpha) pen.setColor(self.valueColor) p.setPen(pen) - p.drawText(self.valueTextRect, self.font_ghost_mask, opt) - self.valueColor.setAlpha(alpha) - pen.setColor(self.valueColor) + p.drawText(self.valueTextRect, self.valueText, opt) + else: + # Draw value or dbkey as a label area to the left of the bar + label_text = self.dbkey if self.show_dbkey_text else self.valueText + p.setFont(self.bigFont) + pen.setColor(self.valueColor if not self.show_dbkey_text else self.textColor) p.setPen(pen) - p.drawText(self.valueTextRect, self.valueText, opt) + opt_left = QTextOption(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) + p.drawText(self.barValueRect, label_text, opt_left) # Draws the bar p.setRenderHint(QPainter.RenderHint.Antialiasing, False) @@ -120,44 +157,45 @@ def paintEvent(self, event): brush = self.safeColor p.setPen(pen) p.setBrush(brush) - p.drawRect(QRectF(0, self.barTop, self.width(), self.barHeight)) + bar_left, bar_top, bar_width, bar_height = self.get_bar_geometry() + p.drawRect(QRectF(bar_left, bar_top, bar_width, bar_height)) pen.setColor(self.warnColor) brush = self.warnColor p.setPen(pen) p.setBrush(brush) if(self.lowWarn): - p.drawRect(QRectF(0, self.barTop, - self.interpolate(self.lowWarn, self.width()), - self.barHeight)) + p.drawRect(QRectF(bar_left, bar_top, + self.interpolate(self.lowWarn, bar_width), + bar_height)) if(self.highWarn): - x = self.interpolate(self.highWarn, self.width()) - p.drawRect(QRectF(x, self.barTop, - self.width() - x, self.barHeight)) + x = bar_left + self.interpolate(self.highWarn, bar_width) + p.drawRect(QRectF(x, bar_top, + (bar_left + bar_width) - x, bar_height)) pen.setColor(self.alarmColor) brush = self.alarmColor p.setPen(pen) p.setBrush(brush) if(self.lowAlarm): - p.drawRect(QRectF(0, self.barTop, - self.interpolate(self.lowAlarm, self.width()), - self.barHeight)) + p.drawRect(QRectF(bar_left, bar_top, + self.interpolate(self.lowAlarm, bar_width), + bar_height)) if(self.highAlarm): - x = self.interpolate(self.highAlarm, self.width()) - p.drawRect(QRectF(x, self.barTop, - self.width() - x, self.barHeight)) + x = bar_left + self.interpolate(self.highAlarm, bar_width) + p.drawRect(QRectF(x, bar_top, + (bar_left + bar_width) - x, bar_height)) # Draw black bars to create segments if self.segments > 0: - segment_gap = self.width() * self.segment_gap_percent - segment_size = (self.width() - (segment_gap * (self.segments - 1)))/self.segments + segment_gap = bar_width * self.segment_gap_percent + segment_size = (bar_width - (segment_gap * (self.segments - 1)))/self.segments p.setRenderHint(QPainter.RenderHint.Antialiasing, False) pen.setColor(Qt.GlobalColor.black) p.setPen(pen) p.setBrush(Qt.GlobalColor.black) for segment in range(self.segments - 1): - seg_left = ((segment + 1) * segment_size) + (segment * segment_gap) - p.drawRect(QRectF(seg_left, self.barTop, segment_gap, self.barHeight)) + seg_left = bar_left + (((segment + 1) * segment_size) + (segment * segment_gap)) + p.drawRect(QRectF(seg_left, bar_top, segment_gap, bar_height)) # Indicator Line pen.setColor(QColor(Qt.GlobalColor.darkGray)) @@ -165,15 +203,15 @@ def paintEvent(self, event): pen.setWidth(1) p.setPen(pen) p.setBrush(brush) - x = self.interpolate(self._value, self.width()) + x = bar_left + self.interpolate(self._value, bar_width) if x < 0: x = 0 - if x > self.width(): x = self.width() + if x > (bar_left + bar_width): x = (bar_left + bar_width) if not self.segments > 0: - p.drawRect(QRectF(x-2, self.barTop-4, 4, self.barHeight+8)) + p.drawRect(QRectF(x-2, bar_top-4, 4, bar_height+8)) else: # IF segmented, darken the top part of the bars from the line up pen.setColor(QColor(0, 0, 0, self.segment_alpha)) p.setPen(pen) p.setBrush(QColor(0, 0, 0, self.segment_alpha)) - p.drawRect(QRectF(x, self.barTop, self.width() - x, self.barHeight)) + p.drawRect(QRectF(x, bar_top, (bar_left + bar_width) - x, bar_height)) diff --git a/src/pyefis/instruments/gauges/horizontalBarImproved.py b/src/pyefis/instruments/gauges/horizontalBarImproved.py index 2e61e2b..b1d0d24 100644 --- a/src/pyefis/instruments/gauges/horizontalBarImproved.py +++ b/src/pyefis/instruments/gauges/horizontalBarImproved.py @@ -28,8 +28,8 @@ def _calculateThresholdPixel(self, value): """Calculate pixel position for a threshold value with consistent rounding.""" if value is None or self.highRange == self.lowRange: return None - - barWidth = int(self.width()) + # Use the drawable bar width (excludes left label area when enabled) + _, _, barWidth, _ = self.get_bar_geometry() if barWidth <= 0: return None @@ -42,6 +42,8 @@ def _calculateThresholdPixel(self, value): return pixelFromLeft def paintEvent(self, event): + # Ensure geometry reflects any preference attributes set after construction + self._recompute_geometry() p = QPainter(self) p.setRenderHint(QPainter.RenderHint.Antialiasing) pen = QPen() @@ -75,27 +77,33 @@ def paintEvent(self, event): pen.setColor(self.textColor) p.setPen(pen) p.drawText(self.valueTextRect, self.units, opt) - - p.setFont(self.bigFont) - opt = QTextOption(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignBottom) - if self.show_value: - if self.font_ghost_mask: - alpha = self.valueColor.alpha() - self.valueColor.setAlpha(self.font_ghost_alpha) + # Draw value either in standard position or inside left label area + if not self.value_on_bar_left: + p.setFont(self.bigFont) + opt = QTextOption(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignBottom) + if self.show_value: + if self.font_ghost_mask: + alpha = self.valueColor.alpha() + self.valueColor.setAlpha(self.font_ghost_alpha) + pen.setColor(self.valueColor) + p.setPen(pen) + p.drawText(self.valueTextRect, self.font_ghost_mask, opt) + self.valueColor.setAlpha(alpha) pen.setColor(self.valueColor) p.setPen(pen) - p.drawText(self.valueTextRect, self.font_ghost_mask, opt) - self.valueColor.setAlpha(alpha) - pen.setColor(self.valueColor) + p.drawText(self.valueTextRect, self.valueText, opt) + else: + label_text = self.dbkey if self.show_dbkey_text else self.valueText + p.setFont(self.bigFont) + pen.setColor(self.valueColor if not self.show_dbkey_text else self.textColor) p.setPen(pen) - p.drawText(self.valueTextRect, self.valueText, opt) + opt_left = QTextOption(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) + p.drawText(self.barValueRect, label_text, opt_left) p.setRenderHint(QPainter.RenderHint.Antialiasing, False) - - barTop = int(self.barTop) - barHeight = int(self.barHeight) - barWidth = int(self.width()) - + + bar_left, barTop, barWidth, barHeight = self.get_bar_geometry() + lowAlarmPixel = self._calculateThresholdPixel(self.lowAlarm) if self.lowAlarm else None lowWarnPixel = self._calculateThresholdPixel(self.lowWarn) if self.lowWarn else None highWarnPixel = self._calculateThresholdPixel(self.highWarn) if self.highWarn else None @@ -105,27 +113,27 @@ def paintEvent(self, event): brush = self.safeColor p.setPen(pen) p.setBrush(brush) - p.drawRect(0, barTop, barWidth, barHeight) + p.drawRect(bar_left, barTop, barWidth, barHeight) pen.setColor(self.warnColor) brush = self.warnColor p.setPen(pen) p.setBrush(brush) if lowWarnPixel is not None: - p.drawRect(0, barTop, lowWarnPixel, barHeight) + p.drawRect(bar_left, barTop, lowWarnPixel, barHeight) if highWarnPixel is not None: warnWidth = barWidth - highWarnPixel - p.drawRect(highWarnPixel, barTop, warnWidth, barHeight) + p.drawRect(bar_left + highWarnPixel, barTop, warnWidth, barHeight) pen.setColor(self.alarmColor) brush = self.alarmColor p.setPen(pen) p.setBrush(brush) if lowAlarmPixel is not None: - p.drawRect(0, barTop, lowAlarmPixel, barHeight) + p.drawRect(bar_left, barTop, lowAlarmPixel, barHeight) if highAlarmPixel is not None: alarmWidth = barWidth - highAlarmPixel - p.drawRect(highAlarmPixel, barTop, alarmWidth, barHeight) + p.drawRect(bar_left + highAlarmPixel, barTop, alarmWidth, barHeight) if self.segments > 0: segment_gap = barWidth * self.segment_gap_percent @@ -135,7 +143,7 @@ def paintEvent(self, event): p.setBrush(Qt.GlobalColor.black) for segment in range(self.segments - 1): seg_left = int(((segment + 1) * segment_size) + (segment * segment_gap)) - p.drawRect(seg_left, barTop, int(segment_gap), barHeight) + p.drawRect(bar_left + seg_left, barTop, int(segment_gap), barHeight) pen.setColor(QColor(Qt.GlobalColor.darkGray)) brush = QBrush(self.penColor) @@ -148,6 +156,7 @@ def paintEvent(self, event): if x is None: x = 0 x = max(0, min(barWidth, x)) + x = bar_left + x if not self.segments > 0: p.drawRect(x-2, barTop-4, 4, barHeight+8) @@ -155,4 +164,5 @@ def paintEvent(self, event): pen.setColor(QColor(0, 0, 0, self.segment_alpha)) p.setPen(pen) p.setBrush(QColor(0, 0, 0, self.segment_alpha)) - p.drawRect(x, barTop, barWidth - x, barHeight) + # Darken from the indicator to the end of the bar area + p.drawRect(x, barTop, (bar_left + barWidth) - x, barHeight) diff --git a/src/pyefis/instruments/gauges/verticalBarImproved.py b/src/pyefis/instruments/gauges/verticalBarImproved.py index 042a170..a24736c 100644 --- a/src/pyefis/instruments/gauges/verticalBarImproved.py +++ b/src/pyefis/instruments/gauges/verticalBarImproved.py @@ -240,7 +240,7 @@ def draw_bar(x: float, y: float, w: float, h: float, color): sny = snap(y) snw = snap(w) snh = snap(h) - log.warning(f" Snapped {self.name}: x={snx:.4f}, y={sny:.4f}, w={snw:.4f}, h={snh:.4f}") + # log.warning(f" Snapped {self.name}: x={snx:.4f}, y={sny:.4f}, w={snw:.4f}, h={snh:.4f}") r = QRectF(snx, sny, snw, snh) p.fillRect(r, color) diff --git a/src/pyefis/screens/screenbuilder.py b/src/pyefis/screens/screenbuilder.py index deac114..12e2f86 100644 --- a/src/pyefis/screens/screenbuilder.py +++ b/src/pyefis/screens/screenbuilder.py @@ -541,7 +541,7 @@ def setup_instruments(self,count,i,ganged=False,replace=None,state=False): if 'egt_mode_switching' == option and (value == True) and i['type'] == 'vertical_bar_gauge': hmi.actions.setEgtMode.connect(self.instruments[count].setMode) next - if 'dbkey' in option: + if option == 'dbkey': if callable(getattr(self.instruments[count], 'setDbkey', None)): self.instruments[count].setDbkey(value) else: From f307b2e764e5c2380e6b87a35bb7f2be5faccb7a Mon Sep 17 00:00:00 2001 From: TerryH <32078938+tjh590@users.noreply.github.com> Date: Sat, 8 Nov 2025 18:23:26 -0500 Subject: [PATCH 18/20] add big_font/small_font percent option --- .../instruments/gauges/horizontalBar.py | 160 ++++++++++++++---- .../gauges/horizontalBarImproved.py | 72 +++++--- 2 files changed, 173 insertions(+), 59 deletions(-) diff --git a/src/pyefis/instruments/gauges/horizontalBar.py b/src/pyefis/instruments/gauges/horizontalBar.py index 786871f..8a1d94a 100644 --- a/src/pyefis/instruments/gauges/horizontalBar.py +++ b/src/pyefis/instruments/gauges/horizontalBar.py @@ -35,6 +35,9 @@ def __init__(self, parent=None, min_size=True, font_family="DejaVu Sans Condense self.segment_gap_percent = 0.01 self.segment_alpha = 180 self.bar_divisor = 4.5 + # Optional font scaling percents (similar to vertical bars); if set, override legacy sizing + self.small_font_percent = None # fraction of widget height + self.big_font_percent = None # fraction of widget height # New options: place value/dbkey text on the left side of the bar self.value_on_bar_left = False self.value_on_bar_left_width_percent = 0.25 # portion of width reserved for left label @@ -50,17 +53,30 @@ def getRatio(self): def resizeEvent(self, event): self.bigFont = QFont(self.font_family) self.section_size = self.height() / 12 - self.bigFont.setPixelSize( qRound(self.section_size * 4)) + # Big font sizing: prefer percent if provided, else legacy section-size based + if self.big_font_percent is not None: + self.bigFont.setPixelSize(qRound(self.height() * float(self.big_font_percent))) + else: + self.bigFont.setPixelSize(qRound(self.section_size * 4)) if self.font_mask: - self.bigFont.setPointSizeF(helpers.fit_to_mask(self.width()-5, self.section_size * 4, self.font_mask, self.font_family)) + # Fit to width/height constraints if a mask exists + self.bigFont.setPointSizeF(helpers.fit_to_mask(self.width()-5, self.bigFont.pixelSize() or (self.section_size * 4), self.font_mask, self.font_family)) + self.smallFont = QFont(self.font_family) - self.smallFont.setPixelSize(qRound(self.section_size * 2)) + if self.small_font_percent is not None: + self.smallFont.setPixelSize(qRound(self.height() * float(self.small_font_percent))) + else: + self.smallFont.setPixelSize(qRound(self.section_size * 2)) if self.name_font_mask: - self.smallFont.setPointSizeF(helpers.fit_to_mask(self.width(), self.section_size * 2.4, self.name_font_mask, self.font_family)) + self.smallFont.setPointSizeF(helpers.fit_to_mask(self.width(), self.smallFont.pixelSize() or (self.section_size * 2.4), self.name_font_mask, self.font_family)) + self.unitsFont = QFont(self.font_family) - self.unitsFont.setPixelSize(qRound(self.section_size * 2)) + if self.small_font_percent is not None: + self.unitsFont.setPixelSize(qRound(self.height() * float(self.small_font_percent))) + else: + self.unitsFont.setPixelSize(qRound(self.section_size * 2)) if self.units_font_mask: - self.unitsFont.setPointSizeF(helpers.fit_to_mask(self.width(), self.section_size * 2.4, self.name_font_mask, self.font_family)) + self.unitsFont.setPointSizeF(helpers.fit_to_mask(self.width(), self.unitsFont.pixelSize() or (self.section_size * 2.4), self.name_font_mask, self.font_family)) self.barHeight = self.section_size * self.bar_divisor self.barTop = self.section_size * 2.7 @@ -84,6 +100,46 @@ def _recompute_geometry(self): else: self.barValueRect = QRectF() + def _font_fit(self, base_font: QFont, text: str, rect: QRectF, min_px: int = 6) -> QFont: + """Return a copy of base_font scaled so 'text' fits within rect (no clipping). + Prefers pixelSize; falls back to pointSizeF if pixelSize is 0.""" + f = QFont(base_font) + # Establish a starting pixel size based on height + start_px = f.pixelSize() if f.pixelSize() > 0 else int(f.pointSizeF()) + if start_px <= 0: + # default to a reasonable size proportional to rect height + start_px = int(rect.height() * 0.8) if rect.height() > 0 else 12 + # Constrain by height first + max_by_height = int(rect.height() * 0.9) if rect.height() > 0 else start_px + size = max(min(start_px, max_by_height), min_px) + # Quick proportional down-scale for width + f.setPixelSize(size) + fm = QFontMetrics(f) + # Account for small padding + avail_w = max(0, int(rect.width()) - 2) + avail_h = max(0, int(rect.height()) - 2) + if avail_w > 0: + text_w = fm.horizontalAdvance(text) + if text_w > 0 and text_w > avail_w: + scale = avail_w / text_w + size = max(min_px, int(size * scale)) + f.setPixelSize(size) + fm = QFontMetrics(f) + # Ensure height fits too + if avail_h > 0: + text_h = fm.height() + if text_h > avail_h: + scale = avail_h / text_h + size = max(min_px, int(size * scale)) + f.setPixelSize(size) + # Final safety loop to avoid off-by-one overflow + fm = QFontMetrics(f) + while (fm.horizontalAdvance(text) > avail_w or fm.height() > avail_h) and size > min_px: + size -= 1 + f.setPixelSize(size) + fm = QFontMetrics(f) + return f + def get_bar_geometry(self): """Return (left, top, width, height) for the drawable bar region, respecting left label space.""" return (int(self._bar_left), int(self.barTop), int(self._bar_width), int(self.barHeight)) @@ -98,34 +154,60 @@ def paintEvent(self, event): pen.setCapStyle(Qt.PenCapStyle.FlatCap) #pen.setColor(self.textColor) #p.setPen(pen) - p.setFont(self.smallFont) - if self.show_name: + # Top row: name/dbkey followed immediately by units + top_rect = QRectF(self.nameTextRect) + name_text = (self.dbkey if self.show_dbkey_text else self.name) if self.show_name else "" + units_text = self.units if self.show_units else "" + spacer = " " if name_text and units_text else "" + combined_text = f"{name_text}{spacer}{units_text}" + # Fit font for combined text within the top rect + fitted_font = self._font_fit(self.smallFont, combined_text, top_rect) + p.setFont(fitted_font) + # Optional ghost masks: draw individually at computed positions + pen.setColor(self.textColor) + p.setPen(pen) + # Draw name/dbkey left + if name_text: + opt_left = QTextOption(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) if self.name_font_ghost_mask: - opt = QTextOption(Qt.AlignmentFlag.AlignLeft) alpha = self.textColor.alpha() self.textColor.setAlpha(self.font_ghost_alpha) pen.setColor(self.textColor) p.setPen(pen) - p.drawText(self.nameTextRect, self.name_font_ghost_mask, opt) + p.drawText(top_rect, self.name_font_ghost_mask, opt_left) self.textColor.setAlpha(alpha) - pen.setColor(self.textColor) - p.setPen(pen) - p.drawText(self.nameTextRect, self.name) - - # Units - p.setFont(self.unitsFont) - opt = QTextOption(Qt.AlignmentFlag.AlignRight) - if self.show_units: - if self.units_font_ghost_mask: - alpha = self.textColor.alpha() - self.textColor.setAlpha(self.font_ghost_alpha) pen.setColor(self.textColor) p.setPen(pen) - p.drawText(self.valueTextRect, self.units_font_ghost_mask, opt) - self.textColor.setAlpha(alpha) - pen.setColor(self.textColor) - p.setPen(pen) - p.drawText(self.valueTextRect, self.units, opt) + p.drawText(top_rect, name_text, opt_left) + # Measure name width to position units immediately after + fm = QFontMetrics(fitted_font) + name_w = fm.horizontalAdvance(name_text + spacer) + if units_text: + units_rect = QRectF(top_rect.left() + name_w, top_rect.top(), max(0, top_rect.width() - name_w), top_rect.height()) + if self.units_font_ghost_mask: + alpha = self.textColor.alpha() + self.textColor.setAlpha(self.font_ghost_alpha) + pen.setColor(self.textColor) + p.setPen(pen) + p.drawText(units_rect, self.units_font_ghost_mask, opt_left) + self.textColor.setAlpha(alpha) + pen.setColor(self.textColor) + p.setPen(pen) + p.drawText(units_rect, units_text, opt_left) + else: + # No name; draw units starting at left + if units_text: + opt_left = QTextOption(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) + if self.units_font_ghost_mask: + alpha = self.textColor.alpha() + self.textColor.setAlpha(self.font_ghost_alpha) + pen.setColor(self.textColor) + p.setPen(pen) + p.drawText(top_rect, self.units_font_ghost_mask, opt_left) + self.textColor.setAlpha(alpha) + pen.setColor(self.textColor) + p.setPen(pen) + p.drawText(top_rect, units_text, opt_left) # Main Value (standard position) unless moved to left of bar if not self.value_on_bar_left: @@ -143,43 +225,48 @@ def paintEvent(self, event): p.setPen(pen) p.drawText(self.valueTextRect, self.valueText, opt) else: - # Draw value or dbkey as a label area to the left of the bar - label_text = self.dbkey if self.show_dbkey_text else self.valueText - p.setFont(self.bigFont) - pen.setColor(self.valueColor if not self.show_dbkey_text else self.textColor) + # Draw numeric value as a label area to the left of the bar (always value here) + label_text = self.valueText + left_font = self._font_fit(self.bigFont, label_text or "", self.barValueRect) + p.setFont(left_font) + pen.setColor(self.valueColor) p.setPen(pen) opt_left = QTextOption(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) p.drawText(self.barValueRect, label_text, opt_left) - # Draws the bar + # Draws the bar (clip so nothing bleeds under left label when reserved) p.setRenderHint(QPainter.RenderHint.Antialiasing, False) + bar_left, bar_top, bar_width, bar_height = self.get_bar_geometry() + p.save() + p.setClipRect(QRectF(bar_left, bar_top, bar_width, bar_height)) pen.setColor(self.safeColor) brush = self.safeColor p.setPen(pen) p.setBrush(brush) - bar_left, bar_top, bar_width, bar_height = self.get_bar_geometry() p.drawRect(QRectF(bar_left, bar_top, bar_width, bar_height)) + # Warn regions pen.setColor(self.warnColor) brush = self.warnColor p.setPen(pen) p.setBrush(brush) - if(self.lowWarn): + if self.lowWarn: p.drawRect(QRectF(bar_left, bar_top, self.interpolate(self.lowWarn, bar_width), bar_height)) - if(self.highWarn): + if self.highWarn: x = bar_left + self.interpolate(self.highWarn, bar_width) p.drawRect(QRectF(x, bar_top, (bar_left + bar_width) - x, bar_height)) + # Alarm regions pen.setColor(self.alarmColor) brush = self.alarmColor p.setPen(pen) p.setBrush(brush) - if(self.lowAlarm): + if self.lowAlarm: p.drawRect(QRectF(bar_left, bar_top, self.interpolate(self.lowAlarm, bar_width), bar_height)) - if(self.highAlarm): + if self.highAlarm: x = bar_left + self.interpolate(self.highAlarm, bar_width) p.drawRect(QRectF(x, bar_top, (bar_left + bar_width) - x, bar_height)) @@ -214,4 +301,5 @@ def paintEvent(self, event): p.setPen(pen) p.setBrush(QColor(0, 0, 0, self.segment_alpha)) p.drawRect(QRectF(x, bar_top, (bar_left + bar_width) - x, bar_height)) + p.restore() diff --git a/src/pyefis/instruments/gauges/horizontalBarImproved.py b/src/pyefis/instruments/gauges/horizontalBarImproved.py index b1d0d24..2c5d181 100644 --- a/src/pyefis/instruments/gauges/horizontalBarImproved.py +++ b/src/pyefis/instruments/gauges/horizontalBarImproved.py @@ -49,34 +49,55 @@ def paintEvent(self, event): pen = QPen() pen.setWidth(1) pen.setCapStyle(Qt.PenCapStyle.FlatCap) - - p.setFont(self.smallFont) - if self.show_name: + # Top row: name/dbkey followed immediately by units + top_rect = QRectF(self.nameTextRect) + name_text = (self.dbkey if self.show_dbkey_text else self.name) if self.show_name else "" + units_text = self.units if self.show_units else "" + spacer = " " if name_text and units_text else "" + combined_text = f"{name_text}{spacer}{units_text}" + fitted_font = self._font_fit(self.smallFont, combined_text, top_rect) + p.setFont(fitted_font) + pen.setColor(self.textColor) + p.setPen(pen) + if name_text: + opt_left = QTextOption(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) if self.name_font_ghost_mask: - opt = QTextOption(Qt.AlignmentFlag.AlignLeft) alpha = self.textColor.alpha() self.textColor.setAlpha(self.font_ghost_alpha) pen.setColor(self.textColor) p.setPen(pen) - p.drawText(self.nameTextRect, self.name_font_ghost_mask, opt) + p.drawText(top_rect, self.name_font_ghost_mask, opt_left) self.textColor.setAlpha(alpha) - pen.setColor(self.textColor) - p.setPen(pen) - p.drawText(self.nameTextRect, self.name) - - p.setFont(self.unitsFont) - opt = QTextOption(Qt.AlignmentFlag.AlignRight) - if self.show_units: - if self.units_font_ghost_mask: - alpha = self.textColor.alpha() - self.textColor.setAlpha(self.font_ghost_alpha) pen.setColor(self.textColor) p.setPen(pen) - p.drawText(self.valueTextRect, self.units_font_ghost_mask, opt) - self.textColor.setAlpha(alpha) - pen.setColor(self.textColor) - p.setPen(pen) - p.drawText(self.valueTextRect, self.units, opt) + p.drawText(top_rect, name_text, opt_left) + fm = QFontMetrics(fitted_font) + name_w = fm.horizontalAdvance(name_text + spacer) + if units_text: + units_rect = QRectF(top_rect.left() + name_w, top_rect.top(), max(0, top_rect.width() - name_w), top_rect.height()) + if self.units_font_ghost_mask: + alpha = self.textColor.alpha() + self.textColor.setAlpha(self.font_ghost_alpha) + pen.setColor(self.textColor) + p.setPen(pen) + p.drawText(units_rect, self.units_font_ghost_mask, opt_left) + self.textColor.setAlpha(alpha) + pen.setColor(self.textColor) + p.setPen(pen) + p.drawText(units_rect, units_text, opt_left) + else: + if units_text: + opt_left = QTextOption(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) + if self.units_font_ghost_mask: + alpha = self.textColor.alpha() + self.textColor.setAlpha(self.font_ghost_alpha) + pen.setColor(self.textColor) + p.setPen(pen) + p.drawText(top_rect, self.units_font_ghost_mask, opt_left) + self.textColor.setAlpha(alpha) + pen.setColor(self.textColor) + p.setPen(pen) + p.drawText(top_rect, units_text, opt_left) # Draw value either in standard position or inside left label area if not self.value_on_bar_left: p.setFont(self.bigFont) @@ -93,9 +114,11 @@ def paintEvent(self, event): p.setPen(pen) p.drawText(self.valueTextRect, self.valueText, opt) else: - label_text = self.dbkey if self.show_dbkey_text else self.valueText - p.setFont(self.bigFont) - pen.setColor(self.valueColor if not self.show_dbkey_text else self.textColor) + # Always show numeric value on left (dbkey already used as name when requested) + label_text = self.valueText + left_font = self._font_fit(self.bigFont, label_text or "", self.barValueRect) + p.setFont(left_font) + pen.setColor(self.valueColor) p.setPen(pen) opt_left = QTextOption(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) p.drawText(self.barValueRect, label_text, opt_left) @@ -103,6 +126,8 @@ def paintEvent(self, event): p.setRenderHint(QPainter.RenderHint.Antialiasing, False) bar_left, barTop, barWidth, barHeight = self.get_bar_geometry() + p.save() + p.setClipRect(QRectF(bar_left, barTop, barWidth, barHeight)) lowAlarmPixel = self._calculateThresholdPixel(self.lowAlarm) if self.lowAlarm else None lowWarnPixel = self._calculateThresholdPixel(self.lowWarn) if self.lowWarn else None @@ -166,3 +191,4 @@ def paintEvent(self, event): p.setBrush(QColor(0, 0, 0, self.segment_alpha)) # Darken from the indicator to the end of the bar area p.drawRect(x, barTop, (bar_left + barWidth) - x, barHeight) + p.restore() From 6dd1d76a1aa6ac091acbf189ad692b8ef1c35e31 Mon Sep 17 00:00:00 2001 From: TerryH <32078938+tjh590@users.noreply.github.com> Date: Sat, 8 Nov 2025 20:56:35 -0500 Subject: [PATCH 19/20] Phase 1 diagnostics implemented (not yet tested) --- src/pyefis/diagnostics/overlay.py | 99 +++++++++++++++++++ .../instruments/gauges/horizontalBar.py | 54 ++++++++-- .../gauges/horizontalBarImproved.py | 18 ++-- 3 files changed, 153 insertions(+), 18 deletions(-) create mode 100644 src/pyefis/diagnostics/overlay.py diff --git a/src/pyefis/diagnostics/overlay.py b/src/pyefis/diagnostics/overlay.py new file mode 100644 index 0000000..f9f321a --- /dev/null +++ b/src/pyefis/diagnostics/overlay.py @@ -0,0 +1,99 @@ +from PyQt6.QtCore import QElapsedTimer, QTimer, QObject, pyqtSignal +from PyQt6.QtWidgets import QWidget +from PyQt6.QtGui import QPainter, QColor, QFont + +class PaintStats: + __slots__ = ("total_ns","count","max_ns") + def __init__(self): + self.total_ns = 0 + self.count = 0 + self.max_ns = 0 + def add(self, dur_ns:int): + self.total_ns += dur_ns + self.count += 1 + if dur_ns > self.max_ns: + self.max_ns = dur_ns + def snapshot(self): + avg = self.total_ns / self.count if self.count else 0 + return avg, self.max_ns, self.count + def reset(self): + self.total_ns = 0; self.count = 0; self.max_ns = 0 + +class GaugeDiagnostics(QObject): + updated = pyqtSignal() + instance = None + + @staticmethod + def get(): + if GaugeDiagnostics.instance is None: + GaugeDiagnostics.instance = GaugeDiagnostics() + return GaugeDiagnostics.instance + + def __init__(self): + super().__init__() + self._enabled = True + self._stats_by_type = {} + self._last_snapshot = {} + self._fps = 0 + self._frame_counter = 0 + self._timer = QTimer(self) + self._timer.timeout.connect(self._roll) + self._timer.start(1000) + def enabled(self): return self._enabled + def set_enabled(self, v:bool): self._enabled = v + + def record(self, gauge_type:str, duration_ns:int): + if not self._enabled: + return + stats = self._stats_by_type.get(gauge_type) + if stats is None: + stats = PaintStats() + self._stats_by_type[gauge_type] = stats + stats.add(duration_ns) + self._frame_counter += 1 + + def _roll(self): + # Compute FPS as frames in last second + self._fps = self._frame_counter + self._frame_counter = 0 + snap = {} + for k,v in self._stats_by_type.items(): + avg,maxv,count = v.snapshot() + snap[k] = { + 'avg_ms': avg/1e6, + 'max_ms': maxv/1e6, + 'count': count + } + v.reset() + self._last_snapshot = snap + self.updated.emit() + + def snapshot(self): + return { + 'fps': self._fps, + 'gauges': self._last_snapshot + } + +class DiagnosticsOverlay(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + from PyQt6.QtCore import Qt + self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) + self.setAttribute(Qt.WidgetAttribute.WA_AlwaysStackOnTop, True) + self.setStyleSheet("background: transparent") + self.font = QFont("DejaVu Sans Mono", 9) + GaugeDiagnostics.get().updated.connect(self.update) + + def paintEvent(self, e): + diag = GaugeDiagnostics.get().snapshot() + p = QPainter(self) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + p.setFont(self.font) + y = 12 + p.setPen(QColor(255,255,255)) + p.drawText(4,y,f"FPS: {diag['fps']}") + y += 14 + for k,v in diag['gauges'].items(): + p.drawText(4,y,f"{k}: avg {v['avg_ms']:.2f} ms max {v['max_ms']:.2f} ms paints {v['count']}") + y += 14 + p.end() diff --git a/src/pyefis/instruments/gauges/horizontalBar.py b/src/pyefis/instruments/gauges/horizontalBar.py index 8a1d94a..ad46198 100644 --- a/src/pyefis/instruments/gauges/horizontalBar.py +++ b/src/pyefis/instruments/gauges/horizontalBar.py @@ -46,6 +46,14 @@ def __init__(self, parent=None, min_size=True, font_family="DejaVu Sans Condense self._bar_left = 0 self._bar_width = 0 self.barValueRect = QRectF() + # Cache for top-row combined text (name/dbkey + units) + self._top_text_cache = { + 'text': None, + 'w': 0, + 'h': 0, + 'font': None, + 'name_w': 0, + } def getRatio(self): # Return X for 1:x specifying the ratio for this instrument return 2 @@ -140,12 +148,40 @@ def _font_fit(self, base_font: QFont, text: str, rect: QRectF, min_px: int = 6) fm = QFontMetrics(f) return f + def _get_top_layout(self): + """Return fitted font and name width for top row (name/dbkey + units), using a small cache.""" + top_rect = self.nameTextRect + name_text = (self.dbkey if self.show_dbkey_text else self.name) if self.show_name else "" + units_text = self.units if self.show_units else "" + spacer = " " if name_text and units_text else "" + combined_text = f"{name_text}{spacer}{units_text}" + key_changed = ( + combined_text != self._top_text_cache['text'] or + top_rect.width() != self._top_text_cache['w'] or + top_rect.height() != self._top_text_cache['h'] + ) + if key_changed: + fitted_font = self._font_fit(self.smallFont, combined_text, top_rect) + fm = QFontMetrics(fitted_font) + name_w = fm.horizontalAdvance(name_text + spacer) if name_text else 0 + self._top_text_cache.update({ + 'text': combined_text, + 'w': top_rect.width(), + 'h': top_rect.height(), + 'font': fitted_font, + 'name_w': name_w, + }) + return self._top_text_cache['font'] or self.smallFont, (name_text, units_text, spacer, self._top_text_cache['name_w']) + def get_bar_geometry(self): """Return (left, top, width, height) for the drawable bar region, respecting left label space.""" return (int(self._bar_left), int(self.barTop), int(self._bar_width), int(self.barHeight)) def paintEvent(self, event): # Ensure geometry reflects any preference attributes set after construction + from time import perf_counter_ns + from pyefis.diagnostics.overlay import GaugeDiagnostics + _t0 = perf_counter_ns() self._recompute_geometry() p = QPainter(self) p.setRenderHint(QPainter.RenderHint.Antialiasing) @@ -154,14 +190,9 @@ def paintEvent(self, event): pen.setCapStyle(Qt.PenCapStyle.FlatCap) #pen.setColor(self.textColor) #p.setPen(pen) - # Top row: name/dbkey followed immediately by units + # Top row: name/dbkey followed immediately by units (cached layout) top_rect = QRectF(self.nameTextRect) - name_text = (self.dbkey if self.show_dbkey_text else self.name) if self.show_name else "" - units_text = self.units if self.show_units else "" - spacer = " " if name_text and units_text else "" - combined_text = f"{name_text}{spacer}{units_text}" - # Fit font for combined text within the top rect - fitted_font = self._font_fit(self.smallFont, combined_text, top_rect) + fitted_font, (name_text, units_text, spacer, name_w) = self._get_top_layout() p.setFont(fitted_font) # Optional ghost masks: draw individually at computed positions pen.setColor(self.textColor) @@ -179,9 +210,6 @@ def paintEvent(self, event): pen.setColor(self.textColor) p.setPen(pen) p.drawText(top_rect, name_text, opt_left) - # Measure name width to position units immediately after - fm = QFontMetrics(fitted_font) - name_w = fm.horizontalAdvance(name_text + spacer) if units_text: units_rect = QRectF(top_rect.left() + name_w, top_rect.top(), max(0, top_rect.width() - name_w), top_rect.height()) if self.units_font_ghost_mask: @@ -302,4 +330,10 @@ def paintEvent(self, event): p.setBrush(QColor(0, 0, 0, self.segment_alpha)) p.drawRect(QRectF(x, bar_top, (bar_left + bar_width) - x, bar_height)) p.restore() + # record diagnostics + _t1 = perf_counter_ns() + try: + GaugeDiagnostics.get().record(self.__class__.__name__, _t1 - _t0) + except Exception: + pass diff --git a/src/pyefis/instruments/gauges/horizontalBarImproved.py b/src/pyefis/instruments/gauges/horizontalBarImproved.py index 2c5d181..673e1e9 100644 --- a/src/pyefis/instruments/gauges/horizontalBarImproved.py +++ b/src/pyefis/instruments/gauges/horizontalBarImproved.py @@ -43,19 +43,18 @@ def _calculateThresholdPixel(self, value): def paintEvent(self, event): # Ensure geometry reflects any preference attributes set after construction + from time import perf_counter_ns + from pyefis.diagnostics.overlay import GaugeDiagnostics + _t0 = perf_counter_ns() self._recompute_geometry() p = QPainter(self) p.setRenderHint(QPainter.RenderHint.Antialiasing) pen = QPen() pen.setWidth(1) pen.setCapStyle(Qt.PenCapStyle.FlatCap) - # Top row: name/dbkey followed immediately by units + # Top row: name/dbkey followed immediately by units (use base cache helper) top_rect = QRectF(self.nameTextRect) - name_text = (self.dbkey if self.show_dbkey_text else self.name) if self.show_name else "" - units_text = self.units if self.show_units else "" - spacer = " " if name_text and units_text else "" - combined_text = f"{name_text}{spacer}{units_text}" - fitted_font = self._font_fit(self.smallFont, combined_text, top_rect) + fitted_font, (name_text, units_text, spacer, name_w) = self._get_top_layout() p.setFont(fitted_font) pen.setColor(self.textColor) p.setPen(pen) @@ -71,8 +70,6 @@ def paintEvent(self, event): pen.setColor(self.textColor) p.setPen(pen) p.drawText(top_rect, name_text, opt_left) - fm = QFontMetrics(fitted_font) - name_w = fm.horizontalAdvance(name_text + spacer) if units_text: units_rect = QRectF(top_rect.left() + name_w, top_rect.top(), max(0, top_rect.width() - name_w), top_rect.height()) if self.units_font_ghost_mask: @@ -192,3 +189,8 @@ def paintEvent(self, event): # Darken from the indicator to the end of the bar area p.drawRect(x, barTop, (bar_left + barWidth) - x, barHeight) p.restore() + _t1 = perf_counter_ns() + try: + GaugeDiagnostics.get().record(self.__class__.__name__, _t1 - _t0) + except Exception: + pass From beb30ca0b544b60d4c78119165dbca1f8722e3a1 Mon Sep 17 00:00:00 2001 From: TerryH <32078938+tjh590@users.noreply.github.com> Date: Sat, 8 Nov 2025 22:47:20 -0500 Subject: [PATCH 20/20] Phase2 --- src/pyefis/diagnostics/overlay.py | 29 ++++- src/pyefis/instruments/gauges/abstract.py | 108 +++++++++++++++++- .../instruments/gauges/horizontalBar.py | 3 + .../gauges/horizontalBarImproved.py | 2 + 4 files changed, 136 insertions(+), 6 deletions(-) diff --git a/src/pyefis/diagnostics/overlay.py b/src/pyefis/diagnostics/overlay.py index f9f321a..c9f0a67 100644 --- a/src/pyefis/diagnostics/overlay.py +++ b/src/pyefis/diagnostics/overlay.py @@ -33,6 +33,10 @@ def __init__(self): super().__init__() self._enabled = True self._stats_by_type = {} + self._inputs = {} + self._suppressed = {} + self._coalesced = {} + self._painted_values = {} self._last_snapshot = {} self._fps = 0 self._frame_counter = 0 @@ -52,6 +56,18 @@ def record(self, gauge_type:str, duration_ns:int): stats.add(duration_ns) self._frame_counter += 1 + def record_input(self, gauge_type:str): + self._inputs[gauge_type] = self._inputs.get(gauge_type, 0) + 1 + + def record_suppressed(self, gauge_type:str): + self._suppressed[gauge_type] = self._suppressed.get(gauge_type, 0) + 1 + + def record_coalesced(self, gauge_type:str): + self._coalesced[gauge_type] = self._coalesced.get(gauge_type, 0) + 1 + + def record_painted_value(self, gauge_type:str, value:float): + self._painted_values[gauge_type] = value + def _roll(self): # Compute FPS as frames in last second self._fps = self._frame_counter @@ -62,10 +78,18 @@ def _roll(self): snap[k] = { 'avg_ms': avg/1e6, 'max_ms': maxv/1e6, - 'count': count + 'count': count, + 'inputs': self._inputs.get(k, 0), + 'suppressed': self._suppressed.get(k, 0), + 'coalesced': self._coalesced.get(k, 0), + 'last_value': self._painted_values.get(k) } v.reset() self._last_snapshot = snap + # reset counters for next interval + self._inputs = {} + self._suppressed = {} + self._coalesced = {} self.updated.emit() def snapshot(self): @@ -94,6 +118,7 @@ def paintEvent(self, e): p.drawText(4,y,f"FPS: {diag['fps']}") y += 14 for k,v in diag['gauges'].items(): - p.drawText(4,y,f"{k}: avg {v['avg_ms']:.2f} ms max {v['max_ms']:.2f} ms paints {v['count']}") + p.drawText(4,y,(f"{k}: avg {v['avg_ms']:.2f} ms max {v['max_ms']:.2f} ms paints {v['count']} " + f"in {v.get('inputs',0)} sup {v.get('suppressed',0)} coa {v.get('coalesced',0)}")) y += 14 p.end() diff --git a/src/pyefis/instruments/gauges/abstract.py b/src/pyefis/instruments/gauges/abstract.py index 2cbf655..050edac 100644 --- a/src/pyefis/instruments/gauges/abstract.py +++ b/src/pyefis/instruments/gauges/abstract.py @@ -140,6 +140,54 @@ def __init__(self, parent=None, font_family="DejaVu Sans Condensed"): self.unitsOverride = None self.conversionFunction = lambda x: x + # Phase 2: repaint throttling and delta suppression + # Settings (can be overridden via YAML binding on gauges) + self.throttle_enabled = True # enable coalesced repaint scheduling + self.max_fps = 60 # cap paints per gauge per second + self.delta_threshold_abs = 0.0 # absolute change needed to repaint (0 disables) + self.delta_threshold_rel = 0.0 # relative change needed (0 disables) + self._last_painted_value = None # last value that was actually painted + self._pending_repaint = False + self._repaint_timer = QTimer(self) + self._repaint_timer.setSingleShot(True) + self._repaint_timer.timeout.connect(self._on_repaint_timer) + + # Internal: compute current throttle interval in ms + def _repaint_interval_ms(self): + try: + fps = float(self.max_fps) if self.max_fps else 0 + except Exception: + fps = 0 + if fps <= 0: + return 0 + return max(1, int(1000.0 / fps)) + + # Internal: schedule a repaint respecting throttle; optionally record diagnostics if coalesced + def _schedule_update(self): + if not self.throttle_enabled: + super().update() + return + interval = self._repaint_interval_ms() + if interval <= 0: + # throttle disabled by config (max_fps <= 0) + super().update() + return + if self._repaint_timer.isActive(): + # Already have a repaint pending, coalesce this request + try: + from pyefis.diagnostics.overlay import GaugeDiagnostics + GaugeDiagnostics.get().record_coalesced(self.__class__.__name__) + except Exception: + pass + return + # Start timer to coalesce multiple update() calls + self._repaint_timer.start(interval) + self._pending_repaint = True + + def _on_repaint_timer(self): + self._pending_repaint = False + super().update() + def interpolate(self, value, range_): h = float(range_) l = float(self.lowRange) @@ -163,8 +211,39 @@ def setValue(self, value): self._value = common.bounds(self.lowRange, self.highRange, cvalue) else: self._value = cvalue + # Diagnostics: count incoming updates + try: + from pyefis.diagnostics.overlay import GaugeDiagnostics + GaugeDiagnostics.get().record_input(self.__class__.__name__) + except Exception: + pass + # Delta suppression: if change is too small vs last painted value, skip scheduling + suppress = False + if self._last_painted_value is not None: + try: + delta_abs = abs(float(self._value) - float(self._last_painted_value)) + rel_base = max(1e-12, abs(float(self._last_painted_value))) + delta_rel = delta_abs / rel_base + abs_thr = float(self.delta_threshold_abs or 0.0) + rel_thr = float(self.delta_threshold_rel or 0.0) + if (abs_thr > 0.0 and delta_abs < abs_thr) and (rel_thr > 0.0 and delta_rel < rel_thr or rel_thr == 0.0): + # meets absolute suppression and (relative not required or also under) + suppress = True + elif (rel_thr > 0.0 and delta_rel < rel_thr) and (abs_thr > 0.0 and delta_abs < abs_thr or abs_thr == 0.0): + # meets relative suppression and (absolute not required or also under) + suppress = True + except Exception: + suppress = False + # Update colors now (may affect value color); defer paint via scheduler self.setColors() - self.update() + if suppress and self.throttle_enabled: + try: + from pyefis.diagnostics.overlay import GaugeDiagnostics + GaugeDiagnostics.get().record_suppressed(self.__class__.__name__) + except Exception: + pass + else: + self._schedule_update() if self._value > self.peakValue: self.peakValue = self._value @@ -316,6 +395,20 @@ def setupGauge(self): # Recalculate selections self.calculate_selections() + # Apply dynamic preferences if provided on instance (e.g. bound via YAML) + # Accept attribute names: max_fps, throttle_enabled, delta_threshold_abs, delta_threshold_rel + for attr in ("max_fps","throttle_enabled","delta_threshold_abs","delta_threshold_rel"): + try: + if hasattr(self, attr): + # basic validation conversions + val = getattr(self, attr) + if attr == "throttle_enabled": + setattr(self, attr, bool(val)) + else: + setattr(self, attr, float(val)) + except Exception: + pass + def setAuxData(self, auxdata): if "Min" in auxdata and auxdata["Min"] != None: @@ -330,7 +423,9 @@ def setAuxData(self, auxdata): self.highWarn = self.conversionFunction(auxdata["highWarn"]) if "highAlarm" in auxdata and auxdata["highAlarm"] != None: self.highAlarm = self.conversionFunction(auxdata["highAlarm"]) - self.update() + # Recompute colors on aux changes may be expensive; schedule repaint + self.setColors() + self._schedule_update() def setColors(self): if self.bad or self.fail or self.old: @@ -362,7 +457,11 @@ def setColors(self): if self.highAlarm != None and self.value > self.highAlarm: self.valueColor = self.alarmColor - self.update() + # Defer update when throttling is enabled + if self.throttle_enabled: + self._schedule_update() + else: + self.update() def annunciateFlag(self, flag): self.annunciate = flag @@ -386,7 +485,8 @@ def oldFlag(self, flag): def resetPeak(self): self.peakValue = self.value - self.update() + # Immediate feedback not required; schedule repaint + self._schedule_update() def setUnitSwitching(self): """When this function is called the unit switching features are used""" diff --git a/src/pyefis/instruments/gauges/horizontalBar.py b/src/pyefis/instruments/gauges/horizontalBar.py index ad46198..9d4e6a0 100644 --- a/src/pyefis/instruments/gauges/horizontalBar.py +++ b/src/pyefis/instruments/gauges/horizontalBar.py @@ -334,6 +334,9 @@ def paintEvent(self, event): _t1 = perf_counter_ns() try: GaugeDiagnostics.get().record(self.__class__.__name__, _t1 - _t0) + GaugeDiagnostics.get().record_painted_value(self.__class__.__name__, float(self._value)) except Exception: pass + # Update last painted value for delta suppression logic + self._last_painted_value = self._value diff --git a/src/pyefis/instruments/gauges/horizontalBarImproved.py b/src/pyefis/instruments/gauges/horizontalBarImproved.py index 673e1e9..fedbc1e 100644 --- a/src/pyefis/instruments/gauges/horizontalBarImproved.py +++ b/src/pyefis/instruments/gauges/horizontalBarImproved.py @@ -192,5 +192,7 @@ def paintEvent(self, event): _t1 = perf_counter_ns() try: GaugeDiagnostics.get().record(self.__class__.__name__, _t1 - _t0) + GaugeDiagnostics.get().record_painted_value(self.__class__.__name__, float(self._value)) except Exception: pass + self._last_painted_value = self._value