diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..eb0dbdc --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,19 @@ +{ + "name": "pyEfis (light) Codespace", + "image": "mcr.microsoft.com/devcontainers/python:3.11", + "postCreateCommand": "sudo apt update && sudo apt install -y python3-pyqt6", + "features": {}, + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python" + ] + } + }, + "remoteUser": "vscode", + "shutdownAction": "stopContainer", + "hostRequirements": { + "cpus": 2, + "memory": "4gb" + } +} 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: diff --git a/.gitignore b/.gitignore index 160ea24..f764f2a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +CIFP/ +agent*.md .~lock* *.ods *.log @@ -11,7 +13,7 @@ config/ # IDE artifacts .vscode/ -.code-worksapce +.code-workspace ### Python template # Byte-compiled / optimized / DLL files diff --git a/IMPROVED_GAUGES.md b/IMPROVED_GAUGES.md new file mode 100644 index 0000000..247fe76 --- /dev/null +++ b/IMPROVED_GAUGES.md @@ -0,0 +1,249 @@ +# Simplified Bar Gauges - Clean Single-Color Design + +## The Simple Approach + +I've created **truly simplified versions** of the vertical and horizontal bar gauges that eliminate ALL alignment issues by using a **single-color approach**. + +### 🎯 How It Works + +Instead of drawing multiple color bands (green/yellow/red zones), the **entire bar changes color** based on the current value: + +- **🟒 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) + +This is like a modern "traffic light" gauge - the whole bar tells you the status at a glance. + +### βœ… 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/horizontalBarSimple.py`** βœ… + - Simplified horizontal bar with single-color approach + - 171 lines, complete implementation + +### Updated Files: +3. **`src/pyefis/instruments/gauges/__init__.py`** βœ… + - Added imports for `HorizontalBarSimple` and `VerticalBarSimple` + +4. **`src/pyefis/screens/screenbuilder.py`** βœ… + - Added support for `vertical_bar_gauge_simple` type + - Added support for `horizontal_bar_gauge_simple` type + +5. **`config/preferences.yaml.custom`** βœ… + - All 12 EGT/CHT bars (BAR11-22) configured to use `vertical_bar_gauge_simple` + +## 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! +``` + +## Configuration + +Your `config/preferences.yaml.custom` is **already configured** with the simple gauges: + +```yaml +gauges: + # EGT for cylinders 1-4 + BAR11: + type: vertical_bar_gauge_simple + BAR12: + type: vertical_bar_gauge_simple + BAR13: + type: vertical_bar_gauge_simple + BAR14: + type: vertical_bar_gauge_simple + + # CHT for cylinders 1-4 + BAR15: + type: vertical_bar_gauge_simple + BAR16: + type: vertical_bar_gauge_simple + BAR17: + type: vertical_bar_gauge_simple + BAR18: + 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_simple + # ... cylinder 5 settings + BAR22: + type: vertical_bar_gauge_simple + # ... cylinder 6 settings +``` + +## How to Test + +**Restart pyEfis to see the new single-color bars:** + +```bash +cd ~/makerplane/pyefis +python pyEfis.py +``` + +## What to Expect + +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) + +### Color Logic: +The bar color is determined by the **current value** vs thresholds: + +1. **πŸ”΄ Red (Alarm)**: + - EGT β‰₯ 650Β°C (highAlarm) + - CHT β‰₯ 232Β°C (highAlarm) + - Or values below lowAlarm + +2. **🟑 Yellow (Warning)**: + - EGT β‰₯ 620Β°C (highWarn) but < 650Β°C + - CHT β‰₯ 204Β°C (highWarn) but < 232Β°C + - Or values below lowWarn but above lowAlarm + +3. **🟒 Green (Safe)**: + - All other values (between lowWarn and highWarn) + +## Alternative Gauge Options + +You have three gauge types available: + +### 1. **Simple (Currently Active)** - Recommended βœ… +```yaml +type: vertical_bar_gauge_simple +``` +- Entire bar changes color based on value +- Clean, modern look +- No alignment issues + +### 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 + +### 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: + +```python +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 +``` + +### 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: + +| 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 | + +**Ready to run!** Just restart pyEfis and you'll see the new clean single-color bars. πŸš€ 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 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/diagnostics/overlay.py b/src/pyefis/diagnostics/overlay.py new file mode 100644 index 0000000..c9f0a67 --- /dev/null +++ b/src/pyefis/diagnostics/overlay.py @@ -0,0 +1,124 @@ +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._inputs = {} + self._suppressed = {} + self._coalesced = {} + self._painted_values = {} + 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 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 + 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, + '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): + 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']} " + 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/gui.py b/src/pyefis/gui.py index 3909e65..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 +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,18 +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() - # Close down fix connections - # This needs done before the main event loop is stopped below - 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() + # 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: + 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 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): @@ -167,7 +206,15 @@ def showEvent(self, event): def closeEvent(self, event): log.debug("Window Close event received") - self.windowClose.emit(event) + # Prevent double cleanup + 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 16dc1da..3d9b72c 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,26 @@ 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 + 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): @@ -193,8 +207,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/__init__.py b/src/pyefis/instruments/gauges/__init__.py index a528943..2ee3eff 100644 --- a/src/pyefis/instruments/gauges/__init__.py +++ b/src/pyefis/instruments/gauges/__init__.py @@ -17,6 +17,10 @@ from .horizontalBar import HorizontalBar 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/abstract.py b/src/pyefis/instruments/gauges/abstract.py index e770324..050edac 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 @@ -99,9 +100,22 @@ 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 - self.safe_good_color = Qt.GlobalColor.green - self.warn_good_color = Qt.GlobalColor.yellow - self.alarm_good_color = Qt.GlobalColor.red + + #Qt.GlobalColor.green + #QColor( 0, 180, 60) + #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 = QColor( 255, 255, 0) + + #Qt.GlobalColor.red + #QColor(220, 60, 40) + #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 self.highlight_good_color = Qt.GlobalColor.magenta @@ -126,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) @@ -149,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 @@ -220,6 +313,19 @@ 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: + if self.encoder_num_blink_timer.isActive(): + self.encoder_num_blink_timer.stop() + except Exception: + pass + try: + super().closeEvent(event) + except Exception: + pass + def setUnits(self, value): self._units = value @@ -289,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: @@ -303,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: @@ -335,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 @@ -359,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/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/horizontalBar.py b/src/pyefis/instruments/gauges/horizontalBar.py index e9e3703..9d4e6a0 100644 --- a/src/pyefis/instruments/gauges/horizontalBar.py +++ b/src/pyefis/instruments/gauges/horizontalBar.py @@ -35,6 +35,25 @@ 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 + 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() + # 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 @@ -42,25 +61,128 @@ 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 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 _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_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) pen = QPen() @@ -68,96 +190,127 @@ 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 (cached layout) + top_rect = QRectF(self.nameTextRect) + 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) + 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) + 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 - 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) + p.drawText(self.valueTextRect, self.valueText, opt) + else: + # 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) - 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 + # 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) - p.drawRect(QRectF(0, self.barTop, self.width(), self.barHeight)) + 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): - p.drawRect(QRectF(0, self.barTop, - self.interpolate(self.lowWarn, self.width()), - self.barHeight)) - if(self.highWarn): - x = self.interpolate(self.highWarn, self.width()) - p.drawRect(QRectF(x, self.barTop, - self.width() - x, self.barHeight)) + if self.lowWarn: + p.drawRect(QRectF(bar_left, bar_top, + self.interpolate(self.lowWarn, bar_width), + bar_height)) + 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): - p.drawRect(QRectF(0, self.barTop, - self.interpolate(self.lowAlarm, self.width()), - self.barHeight)) - if(self.highAlarm): - x = self.interpolate(self.highAlarm, self.width()) - p.drawRect(QRectF(x, self.barTop, - self.width() - x, self.barHeight)) + if self.lowAlarm: + p.drawRect(QRectF(bar_left, bar_top, + self.interpolate(self.lowAlarm, bar_width), + bar_height)) + 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)) # 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 +318,25 @@ 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)) + p.restore() + # record diagnostics + _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 new file mode 100644 index 0000000..fedbc1e --- /dev/null +++ b/src/pyefis/instruments/gauges/horizontalBarImproved.py @@ -0,0 +1,198 @@ +# 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 + + +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.""" + if value is None or self.highRange == self.lowRange: + return None + # Use the drawable bar width (excludes left label area when enabled) + _, _, barWidth, _ = self.get_bar_geometry() + if barWidth <= 0: + return None + + normalized = (value - self.lowRange) / (self.highRange - self.lowRange) + normalized = max(0.0, min(1.0, normalized)) + + scaledPosition = int(normalized * 1000) + pixelFromLeft = (scaledPosition * barWidth) // 1000 + + return pixelFromLeft + + 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 (use base cache helper) + top_rect = QRectF(self.nameTextRect) + fitted_font, (name_text, units_text, spacer, name_w) = self._get_top_layout() + 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: + alpha = self.textColor.alpha() + self.textColor.setAlpha(self.font_ghost_alpha) + pen.setColor(self.textColor) + p.setPen(pen) + p.drawText(top_rect, self.name_font_ghost_mask, opt_left) + self.textColor.setAlpha(alpha) + pen.setColor(self.textColor) + p.setPen(pen) + p.drawText(top_rect, name_text, opt_left) + 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) + 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) + else: + # 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) + + 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 + highWarnPixel = self._calculateThresholdPixel(self.highWarn) if self.highWarn else None + highAlarmPixel = self._calculateThresholdPixel(self.highAlarm) if self.highAlarm else None + + pen.setColor(self.safeColor) + brush = self.safeColor + p.setPen(pen) + p.setBrush(brush) + 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(bar_left, barTop, lowWarnPixel, barHeight) + if highWarnPixel is not None: + warnWidth = barWidth - highWarnPixel + 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(bar_left, barTop, lowAlarmPixel, barHeight) + if highAlarmPixel is not None: + alarmWidth = barWidth - highAlarmPixel + p.drawRect(bar_left + highAlarmPixel, barTop, alarmWidth, barHeight) + + 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 = int(((segment + 1) * segment_size) + (segment * segment_gap)) + p.drawRect(bar_left + seg_left, barTop, int(segment_gap), barHeight) + + pen.setColor(QColor(Qt.GlobalColor.darkGray)) + brush = QBrush(self.penColor) + pen.setWidth(1) + p.setPen(pen) + p.setBrush(brush) + + if self._value is not None: + x = self._calculateThresholdPixel(self._value) + 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) + else: + pen.setColor(QColor(0, 0, 0, self.segment_alpha)) + p.setPen(pen) + 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() + _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 diff --git a/src/pyefis/instruments/gauges/horizontalBarSimple.py b/src/pyefis/instruments/gauges/horizontalBarSimple.py new file mode 100644 index 0000000..232ef86 --- /dev/null +++ b/src/pyefis/instruments/gauges/horizontalBarSimple.py @@ -0,0 +1,179 @@ +# 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) + # 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): + """ + 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/verticalBarImproved.py b/src/pyefis/instruments/gauges/verticalBarImproved.py new file mode 100644 index 0000000..a24736c --- /dev/null +++ b/src/pyefis/instruments/gauges/verticalBarImproved.py @@ -0,0 +1,402 @@ +# 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 * +import sys +import logging + +from .verticalBar import VerticalBar as VerticalBarBase + +log = logging.getLogger(__name__) + + +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) + self._bar_left = -1 + + 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 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(self.barTop) + 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) + + # 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) + + 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 + 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 + + # 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): + 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) + if highAlarmPixel is not None: + alarmHeight = highAlarmPixel - currentTop + if alarmHeight > 0: + # 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: + # 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: + # 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 + if lowWarnPixel is not None: + lowWarnBottom = lowAlarmPixel if lowAlarmPixel is not None else barBottom + warnHeight = lowWarnBottom - currentTop + if warnHeight > 0: + 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: + 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 + 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) + + gap_height = max(1, int(round(segment_gap))) # At least 1 pixel + 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, Qt.GlobalColor.black) + + # Highlight ball + if self.highlight: + pen.setColor(Qt.GlobalColor.black) + pen.setWidth(1) + p.setPen(pen) + p.setBrush(self.highlightColor) + p.drawEllipse(ballCenter, ballRadius, 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(lineLeft), qRound(y - 2), qRound(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)) + + 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 = valuePixelInt - barTop + if darkenHeight > 0: + p.drawRect(barLeft, barTop, barWidth, darkenHeight) + 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() diff --git a/src/pyefis/instruments/gauges/verticalBarSimple.py b/src/pyefis/instruments/gauges/verticalBarSimple.py new file mode 100644 index 0000000..4003a06 --- /dev/null +++ b/src/pyefis/instruments/gauges/verticalBarSimple.py @@ -0,0 +1,190 @@ +# 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) + # 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): + """ + 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/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..12e2f86 100644 --- a/src/pyefis/screens/screenbuilder.py +++ b/src/pyefis/screens/screenbuilder.py @@ -101,6 +101,17 @@ 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: + if self.encoder_timer.isActive(): + self.encoder_timer.stop() + except Exception: + pass + try: + super().closeEvent(event) + except Exception: + pass def calc_includes(self,i,p_rows,p_cols): args = i['type'].split(',') @@ -416,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']: @@ -499,8 +514,16 @@ 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'] == '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) @@ -518,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: diff --git a/teststack.py b/teststack.py new file mode 100644 index 0000000..a251133 --- /dev/null +++ b/teststack.py @@ -0,0 +1,341 @@ +#!/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 = 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 = [160, 160, 160, 160, 160, 160] # 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("#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 +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) + p.fillRect(QRectF(barLeft, currentTop, barWidth, alarmHeight),ALARM_COLOR) + 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) + p.fillRect(QRectF(barLeft, currentTop, barWidth, warnHeight), WARN_COLOR) + 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) + p.fillRect(QRectF(barLeft, currentTop, barWidth, safeHeight), SAFE_COLOR) + 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) + p.fillRect(QRectF(barLeft, currentTop, barWidth, warnHeight), WARN_COLOR) + 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) + 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)) + + # 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() diff --git a/tools/config_inspector.py b/tools/config_inspector.py new file mode 100644 index 0000000..cb498a2 --- /dev/null +++ b/tools/config_inspector.py @@ -0,0 +1,668 @@ +#!/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, QPoint, QRegularExpression +from PyQt6.QtGui import QStandardItemModel, QStandardItem +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 ---------- + +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 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"[{cnt}x] {text}") + else: + item.setText(f"[include] {text}") + 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) + + # 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(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 ---------- + +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()