diff --git a/.env.template b/.env.template index b06759e..50ab6e5 100644 --- a/.env.template +++ b/.env.template @@ -7,4 +7,6 @@ VENV_DIR= # Python path for development (sibling packages) PYTHONPATH=. # Locale (e.g. fr) -LANG= \ No newline at end of file +LANG= +# Debug mode (0 or 1) +GUIDATA_DEBUG=0 \ No newline at end of file diff --git a/doc/release_notes/release_3.14.md b/doc/release_notes/release_3.14.md index 133ab37..544a98b 100644 --- a/doc/release_notes/release_3.14.md +++ b/doc/release_notes/release_3.14.md @@ -1,5 +1,19 @@ # Version 3.14 # +## guidata Version 3.14.4 ## + +🛠️ Bug fixes: + +* **`DataSetEditGroupBox` / computed items**: Fixed input field corruption when typing into a `LineEditWidget` whose `DataSet` contains other items declared via `set_computed(...)` (or any `display.callback`) — typing multiple characters in a row (e.g. `52` after selecting all) was silently truncated and re-interpreted between keystrokes (producing `5.02` instead of `52`, or `0.0.25` instead of `0.25`). The reactive update of computed siblings was recursively re-entering the same callback with a different exclusion target and overwriting the field the user was editing. Bug introduced in v3.13.0 (commit `0af365e`, "Add support for computed properties in datasets") (fixes [Issue #104](https://github.com/PlotPyStack/guidata/issues/104)) +* **High-DPI display scaling**: Enabled automatic high-DPI scaling attributes (`AA_EnableHighDpiScaling`, `AA_UseHighDpiPixmaps`, `HighDpiScaleFactorRoundingPolicy.PassThrough`) before `QApplication` creation in `qapplication()` — on Qt5, UI elements were not scaled on high-DPI monitors (150%/200%/300% Windows display scaling), making text and widgets appear disproportionately small. Qt6 already enables these by default, so the calls are no-ops there. This single change resolves a large portion of visible symptoms downstream (PlotPy, PythonQwt, DataLab) (partial fix for [Issue #101](https://github.com/PlotPyStack/guidata/issues/101)) +* **`CheckBoxWidget` clipping**: Fixed checkbox visual clipping when the checkbox text is empty — in mixed-widget grid layouts (especially at high DPI), the checkbox's natural height was smaller than other input widgets (`QComboBox`, `QLineEdit`), causing it to be cut off. The minimum height is now aligned to a standard input widget height +* **`gbuild` CLI command**: Fixed `gbuild` entry point referencing a `main` function that did not exist in `securebuild.py` — running the `gbuild` command after installing guidata raised an `AttributeError`. The argument parsing code was moved from a top-level `if __name__` block into a proper `main()` function (fixes [Issue #99](https://github.com/PlotPyStack/guidata/issues/99)) +* **Cleanup utility (`gclean`)**: Fixed `clean_wix_installer_files` removing git-tracked files (e.g. `.bmp`, `.wxs` templates) when their extension matched generated artefact patterns — tracked files are now excluded from glob-based removal + +♻️ Internal changes: + +* **Debug environment variable**: Renamed debug environment variable from `DEBUG` to `GUIDATA_DEBUG` to avoid collisions with unrelated tooling or third-party conventions that also use the generic `DEBUG` name + ## guidata Version 3.14.3 ## 🛠️ Bug fixes: diff --git a/guidata/__init__.py b/guidata/__init__.py index f07831e..98a1dcf 100644 --- a/guidata/__init__.py +++ b/guidata/__init__.py @@ -8,7 +8,7 @@ and application development tools for Qt. """ -__version__ = "3.14.3" +__version__ = "3.14.4" # Dear (Debian, RPM, ...) package makers, please feel free to customize the @@ -19,6 +19,31 @@ import guidata.config # noqa: E402, F401 +def _configure_high_dpi(): + """Configure high-DPI scaling attributes before QApplication creation. + + Must be called before QApplication is instantiated. + Under Qt6 these attributes are enabled by default (the calls are no-ops). + """ + from qtpy.QtCore import Qt + from qtpy.QtWidgets import QApplication + + # Qt.AA_EnableHighDpiScaling: opt-in to automatic scaling on Qt 5.6+ + if hasattr(Qt, "AA_EnableHighDpiScaling"): + QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True) + + # Qt.AA_UseHighDpiPixmaps: render QIcon/QPixmap at device pixel ratio + if hasattr(Qt, "AA_UseHighDpiPixmaps"): + QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True) + + # HighDpiScaleFactorRoundingPolicy.PassThrough: avoid rounding artefacts + # on fractional scaling factors (e.g. 125%, 150%) + if hasattr(Qt, "HighDpiScaleFactorRoundingPolicy"): + QApplication.setHighDpiScaleFactorRoundingPolicy( + Qt.HighDpiScaleFactorRoundingPolicy.PassThrough + ) + + def qapplication(): """ Return QApplication instance @@ -28,6 +53,7 @@ def qapplication(): app = QApplication.instance() if not app: + _configure_high_dpi() app = QApplication([]) install_translator(app) from guidata import qthelpers diff --git a/guidata/dataset/qtitemwidgets.py b/guidata/dataset/qtitemwidgets.py index 3738712..ac0d63e 100644 --- a/guidata/dataset/qtitemwidgets.py +++ b/guidata/dataset/qtitemwidgets.py @@ -315,9 +315,15 @@ def __init__( ) self.group.setLayout(self.layout) - def get(self) -> None: - """Update widget contents from data item value""" - self.edit.update_widgets() + def get(self, except_this_one: AbstractDataSetWidget | None = None) -> None: + """Update widget contents from data item value + + Args: + except_this_one: widget to skip when refreshing nested widgets + (forwarded to `update_widgets` so that the currently-edited + widget is not overwritten while the user is typing). + """ + self.edit.update_widgets(except_this_one=except_this_one) def set(self) -> None: """Update data item value from widget contents""" @@ -396,10 +402,21 @@ def __init__( raise self.widgets.append(widget) - def get(self) -> None: - """Update widget contents from data item value""" + def get(self, except_this_one: AbstractDataSetWidget | None = None) -> None: + """Update widget contents from data item value + + Args: + except_this_one: widget to skip when refreshing nested widgets + (forwarded to children so that the currently-edited widget is + not overwritten while the user is typing). + """ for widget in self.widgets: - widget.get() + if widget is except_this_one: + continue + if isinstance(widget, (GroupWidget, TabGroupWidget)): + widget.get(except_this_one=except_this_one) + else: + widget.get() def set(self) -> None: """Update data item value from widget contents""" @@ -448,12 +465,31 @@ def _display_callback(widget: AbstractDataSetWidget, value): if widget.contains_computed_items() or cb is not None: top_level_layout = widget.retrieve_top_level_layout() if widget.build_mode: + # During widget refresh cascades, only persist this widget's + # current value to the dataset. Do NOT trigger another sibling + # refresh, otherwise the cascade re-enters here from each + # `setText` call (via `textChanged` -> `line_edit_changed`) and + # ends up overwriting whichever widget the user is currently + # typing in (e.g. typing "5" in `dx` becomes "5.0" because + # `xmin`'s recomputed `setText` re-triggers a refresh of `dx`). widget.set() - else: - top_level_layout.update_dataitems() + return + top_level_layout.update_dataitems() if cb is not None: cb(widget.item.instance, widget.item.item, value) - top_level_layout.update_widgets(except_this_one=widget) + # Set `build_mode` on all sibling widgets while we refresh them, so + # the cascading `setText` calls below do not re-enter this function + # and recursively call `update_widgets` with a different + # `except_this_one`. + all_widgets = top_level_layout.get_terminal_widgets() + previous_modes = [(w, w.build_mode) for w in all_widgets] + for w in all_widgets: + w.build_mode = True + try: + top_level_layout.update_widgets(except_this_one=widget) + finally: + for w, mode in previous_modes: + w.build_mode = mode class LineEditWidget(AbstractDataSetWidget): @@ -605,6 +641,13 @@ def __init__( super().__init__(item, parent_layout) self.checkbox = QCheckBox(self.item.get_prop_value("display", "text")) self.checkbox.setToolTip(item.get_help()) + if not self.item.get_prop_value("display", "text"): + # When checkbox text is empty, the widget's natural height is smaller + # than other input widgets (QComboBox, QLineEdit), causing visual + # clipping in mixed-widget grid layouts (especially at high DPI). + # Align minimum height to a standard input widget height. + ref_height = QLineEdit().sizeHint().height() + self.checkbox.setMinimumHeight(ref_height) self.group = self.checkbox self.store = self.item.get_prop("display", "store", None) diff --git a/guidata/dataset/qtwidgets.py b/guidata/dataset/qtwidgets.py index 30f620e..fc3f5fd 100644 --- a/guidata/dataset/qtwidgets.py +++ b/guidata/dataset/qtwidgets.py @@ -498,10 +498,19 @@ def update_widgets( """Refresh the content of all widgets Args: - except_this_one: widget to skip + except_this_one: widget to skip (recursively, including widgets + nested in `GroupWidget` or `TabGroupWidget` containers) """ for widget in self.widgets: - if widget is not except_this_one: + if widget is except_this_one: + continue + if isinstance(widget, (GroupWidget, TabGroupWidget)): + # Forward `except_this_one` so that the currently-edited widget + # is preserved even when nested inside a group/tab container + # (otherwise typing into a nested field would re-render it from + # the model on every keystroke, e.g. "5" -> "5.0"). + widget.get(except_this_one=except_this_one) + else: widget.get() def widget_value_changed(self) -> None: diff --git a/guidata/env.py b/guidata/env.py index 5617a70..751badb 100644 --- a/guidata/env.py +++ b/guidata/env.py @@ -17,7 +17,7 @@ from contextlib import contextmanager from typing import Any, Generator -DEBUG = os.environ.get("DEBUG", "").lower() in ("1", "true") +DEBUG = os.environ.get("GUIDATA_DEBUG", "").lower() in ("1", "true") PARSE = os.environ.get("GUIDATA_PARSE_ARGS", "").lower() in ("1", "true") diff --git a/guidata/locale/fr/LC_MESSAGES/guidata.po b/guidata/locale/fr/LC_MESSAGES/guidata.po index a7e1110..9f0e792 100644 --- a/guidata/locale/fr/LC_MESSAGES/guidata.po +++ b/guidata/locale/fr/LC_MESSAGES/guidata.po @@ -240,12 +240,12 @@ msgstr "les tableaux %s" msgid "%s are currently not supported" msgstr "Attention: %s ne sont pas pris en charge" -msgid "NumPy array" -msgstr "Tableau NumPy" - msgid "Array editor" msgstr "Éditeur de tableaux" +msgid "NumPy array" +msgstr "Tableau NumPy" + msgid "Record array fields:" msgstr "Champs:" @@ -368,15 +368,15 @@ msgstr "Attribut" msgid "elements" msgstr "éléments" +msgid "Value" +msgstr "Valeur" + msgid "Type" msgstr "Type" msgid "Size" msgstr "Taille" -msgid "Value" -msgstr "Valeur" - msgid "Warning" msgstr "Avertissement" @@ -629,13 +629,13 @@ msgstr "Le format ({}) est incorrect" msgid "Format ({}) should start with '%'" msgstr "Le format ({}) ne doit pas commencer par '%'" +msgid "CSV Files" +msgstr "Fichiers CSV" + #, fuzzy msgid "Export dataframe" msgstr "DataFrame" -msgid "CSV Files" -msgstr "Fichiers CSV" - msgid "Import as" msgstr "Importer en tant que" diff --git a/guidata/utils/cleanup.py b/guidata/utils/cleanup.py index 1acdc58..b2b75a0 100644 --- a/guidata/utils/cleanup.py +++ b/guidata/utils/cleanup.py @@ -20,10 +20,32 @@ import os import re import shutil +import subprocess import sys from pathlib import Path +def _git_tracked_files(project_root: Path) -> set[Path]: + """Return the set of git-tracked file paths under ``project_root``. + + Returns an empty set if the directory is not a git working tree or git + is unavailable; callers should treat that as "nothing is protected". + """ + try: + result = subprocess.run( + ["git", "-C", str(project_root), "ls-files", "-z"], + capture_output=True, + check=True, + ) + except (OSError, subprocess.CalledProcessError): + return set() + return { + (project_root / rel.decode("utf-8")).resolve() + for rel in result.stdout.split(b"\x00") + if rel + } + + def get_project_root(start_path: Path | str | None = None) -> Path: """Find the project root directory by looking for pyproject.toml. @@ -131,7 +153,10 @@ def remove_if_exists(path: Path) -> None: def remove_glob_pattern( - pattern: str, search_root: Path, case_sensitive: bool = True + pattern: str, + search_root: Path, + case_sensitive: bool = True, + protected: set[Path] | None = None, ) -> None: """Remove files matching a glob pattern. @@ -139,6 +164,8 @@ def remove_glob_pattern( pattern: Glob pattern to match files/directories. search_root: Root directory to search from. case_sensitive: Whether the glob matching should be case sensitive. + protected: Set of resolved paths that must never be deleted (e.g. + git-tracked files that share an extension with generated artefacts). """ if not case_sensitive: pattern = f"**/{pattern.lower()}" @@ -147,6 +174,9 @@ def remove_glob_pattern( else: matches = list(search_root.glob(pattern)) + if protected: + matches = [m for m in matches if m.resolve() not in protected] + if matches: print(f" Removing {len(matches)} items matching pattern: {pattern}") for match in matches: @@ -298,12 +328,17 @@ def clean_wix_installer_files(project_root: Path, lib_name: str, mod_name: str) print(" Cleaning WiX installer files...") wix_dir = project_root / "wix" if wix_dir.exists(): + tracked = _git_tracked_files(project_root) remove_if_exists(wix_dir / "bin") remove_if_exists(wix_dir / "obj") - remove_glob_pattern("*.bmp", wix_dir) - remove_glob_pattern("*.wixpdb", wix_dir) - remove_glob_pattern(f"{lib_name}*.wxs", wix_dir, case_sensitive=False) - remove_glob_pattern(f"{mod_name}*.wxs", wix_dir, case_sensitive=False) + remove_glob_pattern("*.bmp", wix_dir, protected=tracked) + remove_glob_pattern("*.wixpdb", wix_dir, protected=tracked) + remove_glob_pattern( + f"{lib_name}*.wxs", wix_dir, case_sensitive=False, protected=tracked + ) + remove_glob_pattern( + f"{mod_name}*.wxs", wix_dir, case_sensitive=False, protected=tracked + ) def clean_empty_directories(project_root: Path) -> None: diff --git a/guidata/utils/securebuild.py b/guidata/utils/securebuild.py index a0820fe..e7d5526 100644 --- a/guidata/utils/securebuild.py +++ b/guidata/utils/securebuild.py @@ -144,7 +144,8 @@ def run_secure_build( print(f"\n✅ Packages generated in: {dist_dir}") -if __name__ == "__main__": +def main() -> None: + """Main function to parse arguments and run secure build.""" parser = argparse.ArgumentParser( description="Securely build Python packages from a Git repository." ) @@ -164,3 +165,7 @@ def run_secure_build( ) args = parser.parse_args() run_secure_build(root_path=args.root_path, prebuild_command=args.prebuild) + + +if __name__ == "__main__": + main()