From 7c898b893ceb3e9773acc577738124e386d2d4ba Mon Sep 17 00:00:00 2001 From: Thomas MALLET Date: Wed, 13 May 2026 16:03:07 +0200 Subject: [PATCH 1/9] Add high dpi configuration on init for Qt5 binding (correct config by default with Qt6 binding) --- guidata/__init__.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/guidata/__init__.py b/guidata/__init__.py index f07831e..c0687ac 100644 --- a/guidata/__init__.py +++ b/guidata/__init__.py @@ -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 From a5dac1b160828324388384d76c39b46f0d075771 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut Date: Thu, 14 May 2026 10:15:10 +0200 Subject: [PATCH 2/9] fix: prevent reactive callback cascade from overwriting edited field Fixes #104 --- guidata/dataset/qtitemwidgets.py | 54 ++++++++++++++++++++++++++------ guidata/dataset/qtwidgets.py | 13 ++++++-- 2 files changed, 56 insertions(+), 11 deletions(-) diff --git a/guidata/dataset/qtitemwidgets.py b/guidata/dataset/qtitemwidgets.py index 3738712..8f512ec 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): 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: From c3e98dc828d9e957d6da2af3f4283b7c998da58a Mon Sep 17 00:00:00 2001 From: Pierre Raybaut Date: Thu, 14 May 2026 10:21:44 +0200 Subject: [PATCH 3/9] chore: bump version to 3.14.4 --- guidata/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/guidata/__init__.py b/guidata/__init__.py index f07831e..e4f20c9 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 From 107c62dec64118295bc85ec03350249c48a3b2b6 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut Date: Thu, 14 May 2026 10:23:34 +0200 Subject: [PATCH 4/9] docs(release): add release notes for version 3.14.4 with bug fix for DataSetEditGroupBox input field corruption --- doc/release_notes/release_3.14.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/doc/release_notes/release_3.14.md b/doc/release_notes/release_3.14.md index 133ab37..992b618 100644 --- a/doc/release_notes/release_3.14.md +++ b/doc/release_notes/release_3.14.md @@ -1,5 +1,11 @@ # 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)) + ## guidata Version 3.14.3 ## šŸ› ļø Bug fixes: From 79bbf6346f5b0be0734181463f679cce40c577c9 Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Wed, 20 May 2026 18:44:34 +0200 Subject: [PATCH 5/9] fix(cleanup): exclude git-tracked files from removal --- guidata/utils/cleanup.py | 45 +++++++++++++++++++++++++++++++++++----- 1 file changed, 40 insertions(+), 5 deletions(-) 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: From c6a3aa1cd57edaf55c924b51be72659a8f0a5531 Mon Sep 17 00:00:00 2001 From: Thomas MALLET Date: Wed, 27 May 2026 16:51:58 +0200 Subject: [PATCH 6/9] fix checkbox clipping when no text --- guidata/dataset/qtitemwidgets.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/guidata/dataset/qtitemwidgets.py b/guidata/dataset/qtitemwidgets.py index 3738712..c309381 100644 --- a/guidata/dataset/qtitemwidgets.py +++ b/guidata/dataset/qtitemwidgets.py @@ -46,6 +46,7 @@ QPushButton, QRadioButton, QSlider, + QStyle, QTabWidget, QTextEdit, QVBoxLayout, @@ -605,6 +606,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) From 3676f40db74b786668caefeda9743e72c787e220 Mon Sep 17 00:00:00 2001 From: Thomas MALLET Date: Wed, 27 May 2026 17:08:04 +0200 Subject: [PATCH 7/9] fix linter --- guidata/dataset/qtitemwidgets.py | 1 - 1 file changed, 1 deletion(-) diff --git a/guidata/dataset/qtitemwidgets.py b/guidata/dataset/qtitemwidgets.py index c309381..d802a55 100644 --- a/guidata/dataset/qtitemwidgets.py +++ b/guidata/dataset/qtitemwidgets.py @@ -46,7 +46,6 @@ QPushButton, QRadioButton, QSlider, - QStyle, QTabWidget, QTextEdit, QVBoxLayout, From 782d01900d99b687b38f1d54e56440455ec36c51 Mon Sep 17 00:00:00 2001 From: Thomas MALLET Date: Thu, 28 May 2026 09:16:22 +0200 Subject: [PATCH 8/9] Renames debug env var to avoid collisions Replaces the generic debug environment variable with a namespaced one to prevent unintended debug mode activation from unrelated tooling or third-party conventions. Makes debug opt-in more explicit and documents the breaking change for the patch release. --- .env.template | 4 +++- guidata/env.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) 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/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") From 9f45fcfe1313adc45558bbed88ce4da52c7aa878 Mon Sep 17 00:00:00 2001 From: Duy Anh Philippe PHAM Date: Thu, 28 May 2026 14:08:00 +0200 Subject: [PATCH 9/9] Fix gbuild --- guidata/utils/securebuild.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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()