diff --git a/docs/manatools_aui_api.md b/docs/manatools_aui_api.md
new file mode 100644
index 0000000..8a8f237
--- /dev/null
+++ b/docs/manatools_aui_api.md
@@ -0,0 +1,198 @@
+# manatools AUI - Application API (YUI.application)
+
+Overview
+--------
+The object returned by `YUI.app()` / `YUI.application()` / `YUI.yApp()` is the backend-specific Application object (Qt, GTK, or NCurses). Obtain it via:
+
+```python
+from manatools.aui.yui import YUI
+app = YUI.app() # backend-specific application object
+# aliases: YUI.application(), YUI.yApp()
+```
+
+This document lists the common methods that application code should use in a backend-agnostic way and highlights differences and missing documentation.
+
+Common public methods (backend-agnostic)
+---------------------------------------
+These methods are implemented by backend application classes (Qt, GTK, NCurses). Signatures below are the expected API the application code should rely on.
+
+- setApplicationTitle(title: str)
+ - Set the application title. Backends attempt to propagate it to windows/dialogs (Qt: QApplication; GTK: active windows; NCurses: terminal window title escape codes).
+
+- applicationTitle() -> str
+ - Return current application title.
+
+- setApplicationIcon(icon_spec: str)
+ - Set application icon specification (theme name or file path). Backends attempt to resolve and apply the icon where possible. Behavior varies by backend.
+
+- applicationIcon() -> str
+ - Return the currently configured icon specification.
+
+- setIconBasePath(path: str) / iconBasePath()
+ - Provide a base path used when resolving icon file names prior to theme lookup.
+
+- setProductName(name: str) / productName() -> str
+ - Set/read product name metadata used by dialogs or platform integration.
+
+- setApplicationName(name: str) / applicationName() -> str
+- setVersion(version: str) / version() -> str
+- setAuthors(authors: str) / authors() -> str
+- setDescription(desc: str) / description() -> str
+- setLicense(text: str) / license() -> str
+- setCredits(credits: str) / credits() -> str
+- setInformation(info: str) / information() -> str
+- setLogo(path: str) / logo() -> str
+ - About dialog and metadata setters/getters.
+
+- isTextMode() -> bool
+ - Returns True for text-mode (NCurses) backend. Use to adapt UI or behavior.
+
+- busyCursor() / normalCursor()
+ - Show/hide busy cursor. Implementations differ (Qt uses override cursor; GTK and NCurses may be no-op or best-effort).
+
+File chooser helpers (common usage)
+-----------------------------------
+These functions present file/directory selection dialogs. Implementations differ across backends; calls should be treated as blocking helpers that return the selected path or an empty string when canceled.
+
+- askForExistingDirectory(startDir: str, headline: str) -> str
+- askForExistingFile(startWith: str, filter: str, headline: str) -> str
+- askForSaveFileName(startWith: str, filter: str, headline: str) -> str
+
+Notes:
+- `filter` is typically a semicolon-separated list of patterns like `"*.txt;*.md"`.
+- Qt uses QFileDialog (synchronous).
+- GTK attempts Gtk.FileDialog (GTK4.10+) and supports portals/fallbacks.
+- NCurses provides an in-UI browsing overlay; behavior and filter parsing are implemented in Python.
+
+Practical examples
+------------------
+Set title and icon (backend-agnostic):
+
+```python
+app = YUI.app()
+app.setApplicationTitle("MyApp")
+app.setIconBasePath("/usr/share/myapp/icons")
+app.setApplicationIcon("myapp-icon") # theme name or absolute path
+```
+
+Open a file chooser:
+
+```python
+fn = app.askForExistingFile("/home/user", "*.iso;*.img", "Open image")
+if fn:
+ print("Selected:", fn)
+```
+
+## Factory createXXX methods
+
+The widget factory (returned by `YUI.ui().widgetFactory()` / `YUI_ui().widgetFactory()`) provides unified constructors for UI widgets across backends. Use the factory to create dialogs, layout containers and widgets in a backend-agnostic way.
+
+General pattern:
+- Call `factory.createXxx(parent, ...)` to create widget X.
+- The returned object is a YWidget subclass; call widget methods (setValue, setLabel, addItem, etc.) and use `dialog.waitForEvent()` loop to handle events.
+
+Common factory methods (signatures and brief notes)
+
+- createMainDialog(color_mode=YDialogColorMode.YDialogNormalColor)
+ - Returns a main dialog (blocking UI container).
+- createPopupDialog(color_mode=YDialogColorMode.YDialogNormalColor)
+ - Returns a popup dialog.
+
+Layout containers
+- createVBox(parent)
+- createHBox(parent)
+ - Vertical / horizontal layout containers (parent is a dialog or another container).
+
+Common leaf widgets
+- createPushButton(parent, label)
+ - Button widget. Use `setNotify()`, `setIcon()`, `setStretchable()`.
+- createIconButton(parent, iconName, fallbackTextLabel)
+ - Convenience: pushbutton with icon.
+- createLabel(parent, text, isHeading=False, isOutputField=False)
+- createHeading(parent, label)
+ - Label / heading above controls.
+
+Input and selection
+- createInputField(parent, label, password_mode=False)
+ - Single-line text input.
+- createMultiLineEdit(parent, label)
+- createIntField(parent, label, minVal, maxVal, initialVal)
+- createCheckBox(parent, label, is_checked=False)
+- createPasswordField(parent, label)
+- createComboBox(parent, label, editable=False)
+ - Combo/select widget. Supports addItem/addItems/deleteAllItems and icons where supported.
+- createSelectionBox(parent, label)
+- createMultiSelectionBox(parent, label)
+ - Single- or multi-selection lists. Use `addItem`, `addItems`, `deleteAllItems`, `selectItem`, `selectedItems()`.
+
+Progress/visual widgets
+- createProgressBar(parent, label, max_value=100)
+- createImage(parent, imageFileName)
+ - Backends may support autoscale/stretch; icon/image loading depends on backend.
+- createTree(parent, label, multiselection=False, recursiveselection=False)
+- createTable(parent, header: YTableHeader, multiSelection=False)
+- createRichText(parent, text="", plainTextMode=False)
+- createLogView(parent, label, visibleLines, storedLines=0)
+
+Grouping / frames / special widgets
+- createFrame(parent, label="")
+- createCheckBoxFrame(parent, label="", checked=False)
+- createRadioButton(parent, label="", isChecked=False)
+- createReplacePoint(parent)
+- createDumbTab(parent)
+
+Layout helpers
+- createSpacing(parent, dim: YUIDimension, stretchable: bool = False, size_px: int = 0)
+- createHStretch(parent), createVStretch(parent)
+- createHSpacing(parent, size_px=8), createVSpacing(parent, size_px=16)
+
+Misc
+- createMenuBar(parent)
+- createSlider(parent, label, minVal, maxVal, initialVal)
+- createDateField(parent, label)
+- createTimeField(parent, label)
+
+Notes and backend differences
+- All `createXXX` methods return backend-specific widget objects implementing the YWidget API. Call generic methods (setLabel, setValue, addItem, deleteAllItems, setStretchable, setWeight) on those objects.
+- Not all backends support icons, autoscaling or the same filter semantics; the factory hides creation differences but some features are best-effort per backend.
+- For selection widgets (ComboBox / SelectionBox / Tree / Table):
+ - Use addItem/addItems/deleteAllItems at runtime to keep the widget and backend in sync.
+ - Only one item should be selected in single-selection widgets; when selecting programmatically ensure previous selection is cleared (most backends handle this if you use widget.setValue or item.setSelected()).
+ - Icons, if provided via YItem.iconName(), are displayed on GUI backends (GTK/Qt) when supported; ncurses ignores icons.
+
+Minimal example
+```python
+# create a dialog with a combo and a button (backend-agnostic)
+ui = YUI_ui()
+factory = ui.widgetFactory()
+dlg = factory.createMainDialog()
+vbox = factory.createVBox(dlg)
+combo = factory.createComboBox(vbox, "Choose:", editable=False)
+combo.addItems([YItem("One"), YItem("Two", selected=True), YItem("Three")])
+btn = factory.createPushButton(vbox, "OK")
+
+# basic event loop
+while True:
+ ev = dlg.waitForEvent()
+ if ev.eventType() == YEventType.CancelEvent:
+ dlg.destroy()
+ break
+ if ev.eventType() == YEventType.WidgetEvent and ev.widget() == btn:
+ print("Selected:", combo.value())
+ dlg.destroy()
+ break
+```
+
+Backend differences and caveats
+-------------------------------
+- Icon resolution: backends first try icon base path, then theme lookup. Icons may not be available or may be treated differently (GTK uses GdkPixbuf, Qt uses QIcon, NCurses ignores icons).
+- File dialogs:
+ - GTK uses Gtk.FileDialog on modern GTK4; availability depends on GTK version and platform (portal fallbacks exist).
+ - NCurses dialog is implemented with an internal overlay; features (filters, navigation) differ from GUI backends.
+- Cursor APIs and certain visual behaviors are backend-specific and may be no-op in text mode.
+- Methods that interact with windows/views (e.g., applying an icon to open dialogs) perform best-effort updates and may silently fail if no dialog/window is available.
+
+
+License and contribution
+------------------------
+This document is provided to help developers use the cross-backend Application API. Please update the code docstrings and this document when API changes occur.
diff --git a/manatools/aui/__init__.py b/manatools/aui/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/manatools/aui/backends/__init__.py b/manatools/aui/backends/__init__.py
new file mode 100644
index 0000000..5dd4929
--- /dev/null
+++ b/manatools/aui/backends/__init__.py
@@ -0,0 +1,36 @@
+"""
+manatools.aui.backends package initializer.
+
+Provides a small, lazy-loading shim so callers can import submodules
+(e.g. 'from manatools.aui.backends.gtk import ...') while keeping the
+package import lightweight and well-logged.
+"""
+import importlib
+import logging
+from types import ModuleType
+
+_logger = logging.getLogger("manatools.aui.backends")
+
+# advertised subpackages (may be absent if not installed)
+__all__ = ["gtk", "qt", "ncurses"]
+
+def __getattr__(name: str) -> ModuleType:
+ """
+ Lazy-import backend submodules on attribute access (PEP 562).
+ Raises AttributeError if submodule cannot be imported.
+ """
+ if name in __all__:
+ full = f"{__name__}.{name}"
+ try:
+ mod = importlib.import_module(full)
+ globals()[name] = mod
+ return mod
+ except Exception:
+ _logger.debug("Failed to import backend submodule %s", full, exc_info=True)
+ # re-raise as AttributeError to match import semantics
+ raise AttributeError(f"cannot import name {name!r} from {__name__}") from None
+ raise AttributeError(f"module {__name__} has no attribute {name!r}")
+
+def __dir__():
+ # expose advertised backends plus any already imported names
+ return sorted(list(__all__) + [k for k in globals().keys() if not k.startswith("_")])
diff --git a/manatools/aui/backends/curses/__init__.py b/manatools/aui/backends/curses/__init__.py
new file mode 100644
index 0000000..9827afc
--- /dev/null
+++ b/manatools/aui/backends/curses/__init__.py
@@ -0,0 +1,62 @@
+from .dialogcurses import YDialogCurses
+from .checkboxcurses import YCheckBoxCurses
+from .hboxcurses import YHBoxCurses
+from .vboxcurses import YVBoxCurses
+from .labelcurses import YLabelCurses
+from .pushbuttoncurses import YPushButtonCurses
+from .treecurses import YTreeCurses
+from .alignmentcurses import YAlignmentCurses
+from .comboboxcurses import YComboBoxCurses
+from .framecurses import YFrameCurses
+from .inputfieldcurses import YInputFieldCurses
+from .selectionboxcurses import YSelectionBoxCurses
+from .checkboxframecurses import YCheckBoxFrameCurses
+from .progressbarcurses import YProgressBarCurses
+from .radiobuttoncurses import YRadioButtonCurses
+from .tablecurses import YTableCurses
+from .richtextcurses import YRichTextCurses
+from .menubarcurses import YMenuBarCurses
+from .replacepointcurses import YReplacePointCurses
+from .intfieldcurses import YIntFieldCurses
+from .datefieldcurses import YDateFieldCurses
+from .multilineeditcurses import YMultiLineEditCurses
+from .spacingcurses import YSpacingCurses
+from .imagecurses import YImageCurses
+from .dumbtabcurses import YDumbTabCurses
+from .slidercurses import YSliderCurses
+from .logviewcurses import YLogViewCurses
+from .timefieldcurses import YTimeFieldCurses
+from .panedcurses import YPanedCurses
+
+__all__ = [
+ "YDialogCurses",
+ "YFrameCurses",
+ "YVBoxCurses",
+ "YHBoxCurses",
+ "YTreeCurses",
+ "YSelectionBoxCurses",
+ "YLabelCurses",
+ "YPushButtonCurses",
+ "YInputFieldCurses",
+ "YCheckBoxCurses",
+ "YComboBoxCurses",
+ "YAlignmentCurses",
+ "YCheckBoxFrameCurses",
+ "YProgressBarCurses",
+ "YRadioButtonCurses",
+ "YTableCurses",
+ "YRichTextCurses",
+ "YMenuBarCurses",
+ "YReplacePointCurses",
+ "YIntFieldCurses",
+ "YDateFieldCurses",
+ "YMultiLineEditCurses",
+ "YSpacingCurses",
+ "YImageCurses",
+ "YDumbTabCurses",
+ "YSliderCurses",
+ "YLogViewCurses",
+ "YTimeFieldCurses",
+ "YPanedCurses",
+ # ... add new widgets here ...
+]
diff --git a/manatools/aui/backends/curses/alignmentcurses.py b/manatools/aui/backends/curses/alignmentcurses.py
new file mode 100644
index 0000000..0e1f94a
--- /dev/null
+++ b/manatools/aui/backends/curses/alignmentcurses.py
@@ -0,0 +1,194 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.curses contains all curses backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.curses
+'''
+import curses
+import curses.ascii
+import sys
+import os
+import time
+import logging
+from ...yui_common import *
+from .commoncurses import pixels_to_chars
+
+# Module-level logger for curses alignment backend
+_mod_logger = logging.getLogger("manatools.aui.curses.alignment.module")
+if not logging.getLogger().handlers:
+ _h = logging.StreamHandler()
+ _h.setFormatter(logging.Formatter("%(asctime)s %(name)s %(levelname)s: %(message)s"))
+ _mod_logger.addHandler(_h)
+ _mod_logger.setLevel(logging.INFO)
+
+
+class YAlignmentCurses(YSingleChildContainerWidget):
+ """
+ Single-child alignment container for ncurses. It becomes stretchable on the
+ requested axes, and positions the child inside its draw area accordingly.
+ """
+ def __init__(self, parent=None, horAlign: YAlignmentType=YAlignmentType.YAlignUnchanged, vertAlign: YAlignmentType=YAlignmentType.YAlignUnchanged):
+ super().__init__(parent)
+ self._halign_spec = horAlign
+ self._valign_spec = vertAlign
+ self._backend_widget = None # not used by curses
+ self._min_width_px = 0
+ self._min_height_px = 0
+ # per-instance logger
+ self._logger = logging.getLogger(f"manatools.aui.ncurses.{self.__class__.__name__}")
+ if not self._logger.handlers and not logging.getLogger().handlers:
+ for h in _mod_logger.handlers:
+ self._logger.addHandler(h)
+ self._logger.debug("%s.__init__ halign=%s valign=%s", self.__class__.__name__, horAlign, vertAlign)
+ self._height = 1
+
+ def widgetClass(self):
+ return "YAlignment"
+
+ def stretchable(self, dim: YUIDimension):
+ ''' Returns the stretchability of the layout box:
+ * The layout box is stretchable if the child is stretchable in
+ * this dimension or if the child widget has a layout weight in
+ * this dimension.
+ '''
+ if self.hasChildren():
+ # get the only child
+ child = self.child()
+ expand = bool(child.stretchable(dim))
+ weight = bool(child.weight(dim))
+ if expand or weight:
+ return True
+ return False
+
+ def _create_backend_widget(self):
+ try:
+ # for curses we don't create a real window; associate backend_widget to self
+ self._backend_widget = self
+ self._height = max(1, getattr(self.child(), "_height", 1) if self.hasChildren() else 1)
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception as e:
+ try:
+ self._logger.error("_create_backend_widget error: %s", e, exc_info=True)
+ except Exception:
+ _mod_logger.error("_create_backend_widget error: %s", e, exc_info=True)
+
+ def _set_backend_enabled(self, enabled):
+ """Enable/disable alignment container and propagate to its logical child."""
+ try:
+ # propagate to logical child so it updates its own focusability/state
+ child = self.child()
+ if child is not None:
+ child.setEnabled(enabled)
+ # nothing else to do for curses backend (no real widget object)
+ except Exception:
+ pass
+
+ def _child_min_width(self, child, max_width):
+ # Heuristic minimal width similar to YHBoxCurses TODO: verify with widget information instead of hardcoded classes
+ try:
+ cls = child.widgetClass() if hasattr(child, "widgetClass") else ""
+ if cls in ("YLabel", "YPushButton", "YCheckBox"):
+ text = getattr(child, "_text", None)
+ if text is None:
+ text = getattr(child, "_label", "")
+ pad = 4 if cls == "YPushButton" else 0
+ return min(max_width, max(1, len(str(text)) + pad))
+ except Exception:
+ pass
+ return max(1, min(10, max_width))
+
+ def _draw(self, window, y, x, width, height):
+
+ if not self.hasChildren() or not hasattr(self.child(), "_draw"):
+ return
+ try:
+ # width to give to the child: minimal needed or full width if stretchable
+ ch_min_w = self._child_min_width(self.child(), width)
+ # honor explicit minimum width in pixels, converting to character cells
+ try:
+ if getattr(self, '_min_width_px', 0) and self._min_width_px > 0:
+ min_chars = pixels_to_chars(int(self._min_width_px), YUIDimension.YD_HORIZ)
+ ch_min_w = max(ch_min_w, min_chars)
+ except Exception:
+ pass
+ # Horizontal position
+ # determine the width we'll give to the child: prefer an
+ # explicit child _width (or the child's minimal width), but
+ # never exceed the available width
+ try:
+ child_pref_w = getattr(self.child(), "_width", None)
+ # If child is stretchable horizontally or has weight, let it use full width
+ is_h_stretch = False
+ try:
+ is_h_stretch = bool(self.child().stretchable(YUIDimension.YD_HORIZ)) or bool(self.child().weight(YUIDimension.YD_HORIZ))
+ except Exception:
+ is_h_stretch = False
+ if is_h_stretch:
+ child_w = width
+ else:
+ if child_pref_w is not None:
+ child_w = min(width, max(ch_min_w, int(child_pref_w)))
+ else:
+ child_w = min(width, ch_min_w)
+ except Exception:
+ child_w = min(width, ch_min_w)
+
+ if self._halign_spec == YAlignmentType.YAlignEnd:
+ cx = x + max(0, width - child_w)
+ elif self._halign_spec == YAlignmentType.YAlignCenter:
+ cx = x + max(0, (width - child_w) // 2)
+ else:
+ cx = x
+ # Vertical position (single line widgets mostly)
+ if self._valign_spec == YAlignmentType.YAlignCenter:
+ cy = y + max(0, (height - 1) // 2)
+ elif self._valign_spec == YAlignmentType.YAlignEnd:
+ cy = y + max(0, height - 1)
+ else:
+ cy = y
+ # height to give to the child: prefer full height if stretchable, else min/explicit
+ ch_height = getattr(self.child(), "_height", 1)
+ try:
+ is_v_stretch = bool(self.child().stretchable(YUIDimension.YD_VERT)) or bool(self.child().weight(YUIDimension.YD_VERT))
+ if is_v_stretch:
+ ch_height = height
+ except Exception:
+ pass
+ try:
+ if getattr(self, '_min_height_px', 0) and self._min_height_px > 0:
+ min_h = pixels_to_chars(int(self._min_height_px), YUIDimension.YD_VERT)
+ ch_height = max(ch_height, min_h)
+ except Exception:
+ pass
+ # give the computed width to the child (at least 1 char)
+ final_w = max(1, child_w)
+
+ #self._logger.debug("Alignment draw: child=%s halign=%s valign=%s container=(%d,%d) size=(%d,%d) child_min=%d child_pref=%s child_w=%d cx=%d cy=%d",
+ # self.child().debugLabel() if hasattr(self.child(), 'debugLabel') else '',
+ # self._halign_spec, self._valign_spec,
+ # x, y, width, height,
+ # ch_min_w, getattr(self.child(), '_width', None), final_w, cx, cy)
+ self.child()._draw(window, cy, cx, final_w, min(height, ch_height))
+ except Exception:
+ pass
+
+ def setMinWidth(self, width_px: int):
+ try:
+ self._min_width_px = int(width_px) if width_px is not None else 0
+ except Exception:
+ self._min_width_px = 0
+
+ def setMinHeight(self, height_px: int):
+ try:
+ self._min_height_px = int(height_px) if height_px is not None else 0
+ except Exception:
+ self._min_height_px = 0
+
+ def setMinSize(self, width_px: int, height_px: int):
+ self.setMinWidth(width_px)
+ self.setMinHeight(height_px)
diff --git a/manatools/aui/backends/curses/checkboxcurses.py b/manatools/aui/backends/curses/checkboxcurses.py
new file mode 100644
index 0000000..2698a42
--- /dev/null
+++ b/manatools/aui/backends/curses/checkboxcurses.py
@@ -0,0 +1,151 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.curses contains all curses backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.curses
+'''
+import curses
+import curses.ascii
+import sys
+import os
+import time
+import logging
+from ...yui_common import *
+
+# Module-level logger for curses checkbox backend
+_mod_logger = logging.getLogger("manatools.aui.curses.checkbox.module")
+if not logging.getLogger().handlers:
+ _h = logging.StreamHandler()
+ _h.setFormatter(logging.Formatter("%(asctime)s %(name)s %(levelname)s: %(message)s"))
+ _mod_logger.addHandler(_h)
+ _mod_logger.setLevel(logging.INFO)
+
+
+class YCheckBoxCurses(YWidget):
+ def __init__(self, parent=None, label="", is_checked=False):
+ super().__init__(parent)
+ self._label = label
+ self._is_checked = is_checked
+ self._focused = False
+ self._can_focus = True
+ self._height = 1
+ # per-instance logger
+ self._logger = logging.getLogger(f"manatools.aui.ncurses.{self.__class__.__name__}")
+ if not self._logger.handlers and not logging.getLogger().handlers:
+ for h in _mod_logger.handlers:
+ self._logger.addHandler(h)
+ self._logger.debug("%s.__init__ label=%s checked=%s", self.__class__.__name__, label, is_checked)
+
+ def widgetClass(self):
+ return "YCheckBox"
+
+ def value(self):
+ return self._is_checked
+
+ def setValue(self, checked):
+ self._is_checked = checked
+
+ def isChecked(self):
+ '''
+ Simplified access to value(): Return 'true' if the CheckBox is checked.
+ '''
+ return self.value()
+
+ def setChecked(self, checked: bool = True):
+ '''
+ Simplified access to setValue(): Set the CheckBox to 'checked' state if 'checked' is true.
+ '''
+ self.setValue(checked)
+
+ def label(self):
+ return self._label
+
+ def _create_backend_widget(self):
+ try:
+ # In curses, there's no actual backend widget; associate placeholder to self
+ self._backend_widget = self
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception as e:
+ try:
+ self._logger.error("_create_backend_widget error: %s", e, exc_info=True)
+ except Exception:
+ _mod_logger.error("_create_backend_widget error: %s", e, exc_info=True)
+
+ def _set_backend_enabled(self, enabled):
+ """Enable/disable checkbox: update focusability and collapse focus if disabling."""
+ try:
+ if not hasattr(self, "_saved_can_focus"):
+ self._saved_can_focus = getattr(self, "_can_focus", True)
+ if not enabled:
+ try:
+ self._saved_can_focus = self._can_focus
+ except Exception:
+ self._saved_can_focus = True
+ self._can_focus = False
+ if getattr(self, "_focused", False):
+ self._focused = False
+ else:
+ try:
+ self._can_focus = bool(getattr(self, "_saved_can_focus", True))
+ except Exception:
+ self._can_focus = True
+ except Exception:
+ pass
+
+ def _draw(self, window, y, x, width, height):
+ if self._visible is False:
+ return
+ try:
+ checkbox_symbol = "[X]" if self._is_checked else "[ ]"
+ text = f"{checkbox_symbol} {self._label}"
+ if len(text) > width:
+ text = text[:max(0, width - 1)] + "…"
+
+ if self._focused and self.isEnabled():
+ window.attron(curses.A_REVERSE)
+ elif not self.isEnabled():
+ # indicate disabled with dim attribute
+ window.attron(curses.A_DIM)
+
+ window.addstr(y, x, text)
+
+ if self._focused and self.isEnabled():
+ window.attroff(curses.A_REVERSE)
+ elif not self.isEnabled():
+ try:
+ window.attroff(curses.A_DIM)
+ except Exception:
+ pass
+ except curses.error:
+ pass
+
+ def _handle_key(self, key):
+ if not self._focused or not self.isEnabled() or not self.visible():
+ return False
+ # Space or Enter to toggle
+ if key in (ord(' '), ord('\n'), curses.KEY_ENTER):
+ self._toggle()
+ return True
+ return False
+
+ def _toggle(self):
+ """Toggle checkbox state and post event"""
+ self._is_checked = not self._is_checked
+
+ if self.notify():
+ # Post a YWidgetEvent to the containing dialog
+ dlg = self.findDialog()
+ if dlg is not None:
+ dlg._post_event(YWidgetEvent(self, YEventReason.ValueChanged))
+ else:
+ print(f"CheckBox toggled (no dialog found): {self._label} = {self._is_checked}")
+
+ def setVisible(self, visible: bool = True):
+ super().setVisible(visible)
+ # in curses backend visibility controls whether widget can receive focus
+ self._can_focus = bool(visible)
\ No newline at end of file
diff --git a/manatools/aui/backends/curses/checkboxframecurses.py b/manatools/aui/backends/curses/checkboxframecurses.py
new file mode 100644
index 0000000..19f3afa
--- /dev/null
+++ b/manatools/aui/backends/curses/checkboxframecurses.py
@@ -0,0 +1,353 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.curses contains all curses backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.curses
+'''
+import curses
+import curses.ascii
+import sys
+import os
+import time
+import logging
+from ...yui_common import *
+from .commoncurses import _curses_recursive_min_height
+
+# Module-level logger for checkbox frame curses backend
+_mod_logger = logging.getLogger("manatools.aui.curses.checkboxframe.module")
+if not logging.getLogger().handlers:
+ _h = logging.StreamHandler()
+ _h.setFormatter(logging.Formatter("%(asctime)s %(name)s %(levelname)s: %(message)s"))
+ _mod_logger.addHandler(_h)
+ _mod_logger.setLevel(logging.INFO)
+
+class YCheckBoxFrameCurses(YSingleChildContainerWidget):
+ """
+ NCurses implementation of a framed container with a checkbox in the title.
+ The checkbox enables/disables the inner child (autoEnable/invertAutoEnable supported).
+ Drawing reuses frame style from framecurses but prepends a checkbox marker to the title.
+ """
+ def __init__(self, parent=None, label: str = "", checked: bool = False):
+ super().__init__(parent)
+ self._label = label or ""
+ self._checked = bool(checked)
+ self._auto_enable = True
+ self._invert_auto = False
+ self._backend_widget = None
+ # minimal height (will be computed from child)
+ self._height = 3
+ self._inner_top_padding = 1
+ # allow focusing the frame title/checkbox and track focus state
+ try:
+ self._can_focus = True
+ except Exception:
+ pass
+ self._focused = False
+ # per-instance logger
+ self._logger = logging.getLogger(f"manatools.aui.ncurses.{self.__class__.__name__}")
+ if not self._logger.handlers and not logging.getLogger().handlers:
+ for h in _mod_logger.handlers:
+ self._logger.addHandler(h)
+ self._logger.debug("%s.__init__ label=%s checked=%s", self.__class__.__name__, self._label, self._checked)
+
+ def widgetClass(self):
+ return "YCheckBoxFrame"
+
+ def label(self):
+ return self._label
+
+ def setLabel(self, new_label):
+ try:
+ self._label = str(new_label)
+ except Exception:
+ self._label = new_label
+
+ def value(self):
+ try:
+ return bool(self._checked)
+ except Exception:
+ return False
+
+ def setValue(self, isChecked: bool):
+ try:
+ self._checked = bool(isChecked)
+ # propagate enablement to children if autoEnable
+ if self._auto_enable:
+ self._apply_children_enablement(self._checked)
+ # notify listeners
+ try:
+ if self.notify():
+ dlg = self.findDialog()
+ if dlg:
+ dlg._post_event(YWidgetEvent(self, YEventReason.ValueChanged))
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def autoEnable(self):
+ return bool(self._auto_enable)
+
+ def setAutoEnable(self, autoEnable: bool):
+ try:
+ self._auto_enable = bool(autoEnable)
+ self._apply_children_enablement(self._checked)
+ except Exception:
+ pass
+
+ def invertAutoEnable(self):
+ return bool(self._invert_auto)
+
+ def setInvertAutoEnable(self, invert: bool):
+ try:
+ self._invert_auto = bool(invert)
+ self._apply_children_enablement(self._checked)
+ except Exception:
+ pass
+
+ def stretchable(self, dim):
+ """Frame is stretchable if its child is."""
+ try:
+ child = self.child()
+ if child is None:
+ return False
+ try:
+ if bool(child.stretchable(dim)):
+ return True
+ except Exception:
+ pass
+ try:
+ if bool(child.weight(dim)):
+ return True
+ except Exception:
+ pass
+ except Exception:
+ pass
+ return False
+
+ def _update_min_height(self):
+ """Recompute minimal height: borders + padding + child's minimal layout."""
+ try:
+ child = self.child()
+ inner_min = _curses_recursive_min_height(child) if child is not None else 1
+ self._height = max(3, 2 + self._inner_top_padding + inner_min)
+ except Exception:
+ self._height = max(self._height, 3)
+
+ def _create_backend_widget(self):
+ try:
+ # no persistent backend object for curses; associate placeholder
+ self._backend_widget = self
+ self._update_min_height()
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ # ensure children enablement matches checkbox initial state
+ try:
+ if self.isEnabled():
+ self._apply_children_enablement(self._checked)
+ except Exception as e:
+ self._logger.error("_create_backend_widget inner error: %s", e, exc_info=True)
+ except Exception as e:
+ try:
+ self._logger.error("_create_backend_widget error: %s", e, exc_info=True)
+ except Exception:
+ _mod_logger.error("_create_backend_widget error: %s", e, exc_info=True)
+
+ def _apply_children_enablement(self, isChecked: bool):
+ try:
+ if not self._auto_enable:
+ return
+ state = bool(isChecked) if self.isEnabled() else False
+ if self._invert_auto:
+ state = not state
+ child = self.child()
+ if child is None:
+ return
+ try:
+ child.setEnabled(state)
+ except Exception:
+ # best-effort try backend widget sensitivity
+ try:
+ w = child.get_backend_widget()
+ if w is not None:
+ try:
+ w.set_sensitive(state)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ except Exception as e:
+ try:
+ self._logger.error("_draw error: %s", e, exc_info=True)
+ except Exception:
+ _mod_logger.error("_draw error: %s", e, exc_info=True)
+
+ def _set_backend_enabled(self, enabled: bool):
+ try:
+ # logical propagation
+ # If auto_enable is enabled, child's enabled state follows checkbox.
+ # Otherwise follow explicit enabled flag.
+ if self._auto_enable:
+ self._apply_children_enablement(self._checked)
+ else:
+ # propagate explicit enabled/disabled to logical children
+ child = self.child()
+ if child is not None:
+ child.setEnabled(enabled)
+ except Exception:
+ pass
+
+ def addChild(self, child):
+ super().addChild(child)
+ self._update_min_height()
+ try:
+ self._apply_children_enablement(self._checked)
+ except Exception:
+ pass
+
+ def _on_toggle_request(self):
+ """Toggle value (helper for key handling if needed)."""
+ try:
+ self.setValue(not self._checked)
+ except Exception:
+ pass
+
+ def _handle_key(self, key):
+ """Handle keyboard toggling when this frame is focused."""
+ try:
+ if key in (ord(' '), 10, 13, curses.KEY_ENTER):
+ # toggle checkbox
+ try:
+ self.setValue(not self._checked)
+ except Exception:
+ pass
+ return True
+ except Exception:
+ pass
+ return False
+
+ def _set_focus(self, focused: bool):
+ """Called by container when focus moves; track focus for drawing."""
+ try:
+ self._focused = bool(focused)
+ except Exception:
+ self._focused = False
+
+ def _draw(self, window, y, x, width, height):
+ """
+ Draw framed box with checkbox marker in the label, then delegate to child.
+ Title shows "[x]" or "[ ]" before the label.
+ """
+ try:
+ if width <= 0 or height <= 0:
+ return
+ self._update_min_height()
+ # if not enough space for full frame, draw compact title line and return
+ if height < 3 or width < 4:
+ try:
+ chk = "x" if self._checked else " "
+ title = f"[{chk}] {self._label}" if self._label else f"[{chk}]"
+ title = title[:max(0, width)]
+ # highlight when focused, dim when disabled
+ attr = curses.A_BOLD
+ try:
+ if getattr(self, "isEnabled", None):
+ enabled = bool(self.isEnabled())
+ else:
+ enabled = True
+ except Exception:
+ enabled = True
+ if not enabled:
+ attr |= curses.A_DIM
+ if getattr(self, "_focused", False):
+ attr |= curses.A_REVERSE
+ window.addstr(y, x, title, attr)
+ except curses.error:
+ pass
+ return
+
+ # choose box chars
+ try:
+ hline = curses.ACS_HLINE
+ vline = curses.ACS_VLINE
+ tl = curses.ACS_ULCORNER
+ tr = curses.ACS_URCORNER
+ bl = curses.ACS_LLCORNER
+ br = curses.ACS_LRCORNER
+ except Exception:
+ hline = ord('-')
+ vline = ord('|')
+ tl = ord('+')
+ tr = ord('+')
+ bl = ord('+')
+ br = ord('+')
+
+ # draw border
+ try:
+ window.addch(y, x, tl)
+ window.addch(y, x + width - 1, tr)
+ window.addch(y + height - 1, x, bl)
+ window.addch(y + height - 1, x + width - 1, br)
+ for cx in range(x + 1, x + width - 1):
+ window.addch(y, cx, hline)
+ window.addch(y + height - 1, cx, hline)
+ for cy in range(y + 1, y + height - 1):
+ window.addch(cy, x, vline)
+ window.addch(cy, x + width - 1, vline)
+ except curses.error:
+ pass
+
+ # build title with checkbox marker
+ try:
+ chk = "x" if self._checked else " "
+ title_body = f"[{chk}] {self._label}" if self._label else f"[{chk}]"
+ max_title_len = max(0, width - 4)
+ if len(title_body) > max_title_len:
+ title_body = title_body[:max(0, max_title_len - 1)] + "…"
+ start_x = x + max(1, (width - len(title_body)) // 2)
+ # choose attributes depending on focus/enable state
+ attr = curses.A_BOLD
+ try:
+ if getattr(self, "isEnabled", None):
+ enabled = bool(self.isEnabled())
+ else:
+ enabled = True
+ except Exception:
+ enabled = True
+ if not enabled:
+ attr |= curses.A_DIM
+ if getattr(self, "_focused", False):
+ attr |= curses.A_REVERSE
+ window.addstr(y, start_x, title_body, attr)
+ except curses.error:
+ pass
+
+ # inner rect
+ inner_x = x + 1
+ inner_y = y + 1
+ inner_w = max(0, width - 2)
+ inner_h = max(0, height - 2)
+
+ pad_top = min(self._inner_top_padding, max(0, inner_h))
+ content_y = inner_y + pad_top
+ content_h = max(0, inner_h - pad_top)
+
+ child = self.child()
+ if child is None:
+ return
+
+ needed = _curses_recursive_min_height(child)
+ # ensure we give at least needed rows if available
+ content_h = min(max(content_h, needed), inner_h)
+
+ if content_h <= 0 or inner_w <= 0:
+ return
+
+ if hasattr(child, "_draw"):
+ child._draw(window, content_y, inner_x, inner_w, content_h)
+ except Exception:
+ pass
diff --git a/manatools/aui/backends/curses/comboboxcurses.py b/manatools/aui/backends/curses/comboboxcurses.py
new file mode 100644
index 0000000..96ef363
--- /dev/null
+++ b/manatools/aui/backends/curses/comboboxcurses.py
@@ -0,0 +1,347 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.curses contains all curses backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.curses
+'''
+import curses
+import curses.ascii
+import sys
+import os
+import time
+import logging
+from ...yui_common import *
+
+# Module-level logger for combobox curses backend
+_mod_logger = logging.getLogger("manatools.aui.curses.combobox.module")
+if not logging.getLogger().handlers:
+ _h = logging.StreamHandler()
+ _h.setFormatter(logging.Formatter("%(asctime)s %(name)s %(levelname)s: %(message)s"))
+ _mod_logger.addHandler(_h)
+ _mod_logger.setLevel(logging.INFO)
+
+
+class YComboBoxCurses(YSelectionWidget):
+ def __init__(self, parent=None, label="", editable=False):
+ super().__init__(parent)
+ self._label = label
+ self._editable = editable
+ self._value = ""
+ self._focused = False
+ self._can_focus = True
+ # Reserve two lines: one for the label (caption) and one for the control
+ self._height = 2 if self._label else 1
+ self._expanded = False
+ self._hover_index = 0
+ self._combo_x = 0
+ self._combo_y = 0
+ self._combo_width = 0
+ # per-instance logger
+ self._logger = logging.getLogger(f"manatools.aui.ncurses.{self.__class__.__name__}")
+ if not self._logger.handlers and not logging.getLogger().handlers:
+ for h in _mod_logger.handlers:
+ self._logger.addHandler(h)
+ self._logger.debug("%s.__init__ label=%s editable=%s", self.__class__.__name__, label, editable)
+
+ def widgetClass(self):
+ return "YComboBox"
+
+ def value(self):
+ return self._value
+
+ def setValue(self, text):
+ self._value = text
+ # Update selected items and ensure only one item selected
+ try:
+ self._selected_items = []
+ for it in self._items:
+ try:
+ sel = (it.label() == text)
+ it.setSelected(sel)
+ if sel:
+ self._selected_items.append(it)
+ except Exception:
+ pass
+ except Exception:
+ self._selected_items = []
+
+ def editable(self):
+ return self._editable
+
+ def _create_backend_widget(self):
+ try:
+ # associate a placeholder backend widget to avoid None
+ self._backend_widget = self
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception as e:
+ try:
+ self._logger.error("_create_backend_widget error: %s", e, exc_info=True)
+ except Exception:
+ _mod_logger.error("_create_backend_widget error: %s", e, exc_info=True)
+
+ def _set_backend_enabled(self, enabled):
+ """Enable/disable combobox: affect focusability, expanded state and focused state."""
+ try:
+ if not hasattr(self, "_saved_can_focus"):
+ self._saved_can_focus = getattr(self, "_can_focus", True)
+ if not enabled:
+ try:
+ self._saved_can_focus = self._can_focus
+ except Exception:
+ self._saved_can_focus = True
+ self._can_focus = False
+ # collapse expanded dropdown if any
+ try:
+ if getattr(self, "_expanded", False):
+ self._expanded = False
+ except Exception:
+ pass
+ if getattr(self, "_focused", False):
+ self._focused = False
+ else:
+ try:
+ self._can_focus = bool(getattr(self, "_saved_can_focus", True))
+ except Exception:
+ self._can_focus = True
+ except Exception:
+ pass
+
+ def setLabel(self, new_label):
+ super().setLabel(new_label)
+ self._height = 2 if self._label else 1
+
+ def _draw(self, window, y, x, width, height):
+ if self._visible is False:
+ return
+ # Store position and dimensions for dropdown drawing
+ # Label is drawn on row `y`, combo control on row `y+1`.
+ self._combo_y = y + 1 if self._label else y
+ self._combo_x = x
+ self._combo_width = width
+
+ # require at least two rows (label + control)
+ if height < self._height:
+ return
+
+ try:
+ # Calculate available space for combo box (full width, label is above)
+ combo_space = width
+ if combo_space <= 3:
+ return
+
+ # Draw label on top row
+ if self._label:
+ label_text = self._label
+ # clip label if too long for width
+ if len(label_text) > width:
+ label_text = label_text[:max(0, width - 1)] + "…"
+ lbl_attr = curses.A_NORMAL
+ if not self.isEnabled():
+ lbl_attr |= curses.A_DIM
+ try:
+ window.addstr(y, x, label_text, lbl_attr)
+ except curses.error:
+ pass
+
+ # Prepare display value and draw combo on next row
+ display_value = self._value if self._value else "Select…"
+ max_display_width = combo_space - 4 # account for " ▼" and padding
+ if len(display_value) -1 > max_display_width:
+ display_value = display_value[:max_display_width] + "…"
+
+ # Draw combo box background on the control row
+ if not self.isEnabled():
+ attr = curses.A_DIM
+ else:
+ attr = curses.A_REVERSE if self._focused else curses.A_NORMAL
+
+ try:
+ combo_bg = " " * combo_space
+ window.addstr(self._combo_y, x, combo_bg, attr)
+ combo_text = f" {display_value} ▼"
+ if len(combo_text) > combo_space:
+ combo_text = combo_text[:combo_space]
+ window.addstr(self._combo_y, x, combo_text, attr)
+ except curses.error:
+ pass
+
+ # Draw expanded list if active and enabled
+ if self._expanded and self.isEnabled():
+ self._draw_expanded_list(window)
+ except curses.error as e:
+ try:
+ self._logger.error("_draw curses.error: %s", e, exc_info=True)
+ except Exception:
+ _mod_logger.error("_draw curses.error: %s", e, exc_info=True)
+
+ def _draw_expanded_list(self, window):
+ """Draw the expanded dropdown list at correct position"""
+ if not self._expanded or not self._items:
+ return
+
+ try:
+ # Make sure we don't draw outside screen
+ screen_height, screen_width = window.getmaxyx()
+
+ list_height = min(len(self._items), screen_height)
+
+ # Calculate dropdown position - right below the combo control row
+ dropdown_y = self._combo_y + 1
+ dropdown_x = self._combo_x
+ try:
+ # longest item label
+ max_label_len = max((len(i.label()) for i in self._items), default=0)
+ except Exception:
+ max_label_len = 0
+ # desired width includes small padding
+ desired_width = max_label_len + 2
+ # never exceed screen usable width (leave 1 column margin)
+ max_allowed = max(5, screen_width - 2)
+ dropdown_width = min(desired_width, max_allowed)
+ # if popup would overflow to the right, shift it left so full text is visible when possible
+ if dropdown_x + dropdown_width >= screen_width:
+ shift = (dropdown_x + dropdown_width) - (screen_width - 1)
+ dropdown_x = max(0, dropdown_x - shift)
+ # ensure reasonable minimum
+ if dropdown_width < 5:
+ dropdown_width = 5
+
+ if dropdown_y + list_height >= screen_height:
+ dropdown_y = max(1, self._combo_y - list_height - 1)
+
+ # Ensure dropdown doesn't go beyond right edge
+ if dropdown_x + dropdown_width >= screen_width:
+ dropdown_width = screen_width - dropdown_x - 1
+
+ # Draw dropdown background for each item
+ for i in range(list_height):
+ if i >= len(self._items):
+ break
+
+ item = self._items[i]
+ item_text = item.label()
+ if len(item_text) > dropdown_width - 2:
+ item_text = item_text[:dropdown_width - 2] + "…"
+
+ # Highlight hovered item
+ attr = curses.A_REVERSE if i == self._hover_index else curses.A_NORMAL
+
+ # Create background for the item
+ bg_text = " " + item_text.ljust(dropdown_width - 2)
+ if len(bg_text) > dropdown_width:
+ bg_text = bg_text[:dropdown_width]
+
+ # Ensure we don't write beyond screen bounds
+ if (dropdown_y + i < screen_height and
+ dropdown_x < screen_width and
+ dropdown_x + len(bg_text) <= screen_width):
+ try:
+ window.addstr(dropdown_y + i, dropdown_x, bg_text, attr)
+ except curses.error:
+ pass
+
+ except curses.error as e:
+ try:
+ self._logger.error("_draw_expanded_list curses.error: %s", e, exc_info=True)
+ except Exception:
+ _mod_logger.error("_draw_expanded_list curses.error: %s", e, exc_info=True)
+
+ def _handle_key(self, key):
+ if not self._focused or not self.isEnabled() or not self.visible():
+ return False
+ handled = True
+
+ # If currently expanded, give expanded-list handling priority so Enter
+ # selects the hovered item instead of simply toggling expansion.
+ if self._expanded:
+ # Handle navigation in expanded list
+ if key == curses.KEY_UP:
+ if self._hover_index > 0:
+ self._hover_index -= 1
+ elif key == curses.KEY_DOWN:
+ if self._hover_index < len(self._items) - 1:
+ self._hover_index += 1
+ elif key == ord('\n') or key == ord(' '):
+ # Select hovered item
+ if self._items and 0 <= self._hover_index < len(self._items):
+ selected_item = self._items[self._hover_index]
+ self.setValue(selected_item.label()) # update internal value/selection
+ self._expanded = False
+ if self.notify():
+ # force parent dialog redraw if present
+ dlg = self.findDialog()
+ if dlg is not None:
+ try:
+ # notify dialog to redraw immediately
+ dlg._last_draw_time = 0
+ # post a widget event for selection change
+ dlg._post_event(YWidgetEvent(self, YEventReason.SelectionChanged))
+ except Exception:
+ pass
+ # selection made -> handled
+ elif key == 27: # ESC key
+ self._expanded = False
+ else:
+ handled = False
+ else:
+ # Not expanded: Enter/Space expands the list
+ if key == ord('\n') or key == ord(' '):
+ self._expanded = not self._expanded
+ if self._expanded and self._items:
+ # Set hover index to current value if exists
+ self._hover_index = 0
+ if self._value:
+ for i, item in enumerate(self._items):
+ if item.label() == self._value:
+ self._hover_index = i
+ break
+ else:
+ handled = False
+
+ return handled
+
+ # New: addItem at runtime
+ def addItem(self, item):
+ try:
+ super().addItem(item)
+ except Exception:
+ try:
+ if isinstance(item, str):
+ super().addItem(item)
+ else:
+ self._items.append(item)
+ except Exception:
+ return
+ try:
+ new_item = self._items[-1]
+ new_item.setIndex(len(self._items) - 1)
+ except Exception:
+ pass
+
+ # enforce single-selection semantics
+ try:
+ if new_item.selected():
+ for it in self._items[:-1]:
+ try:
+ it.setSelected(False)
+ except Exception:
+ pass
+ self._value = new_item.label()
+ self._selected_items = [new_item]
+ except Exception:
+ pass
+
+ def deleteAllItems(self):
+ super().deleteAllItems()
+ self._value = ""
+ self._expanded = False
+ self._hover_index = 0
+
+ def setVisible(self, visible=True):
+ super().setVisible(visible)
+ self._can_focus = visible
\ No newline at end of file
diff --git a/manatools/aui/backends/curses/commoncurses.py b/manatools/aui/backends/curses/commoncurses.py
new file mode 100644
index 0000000..a993b59
--- /dev/null
+++ b/manatools/aui/backends/curses/commoncurses.py
@@ -0,0 +1,296 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Common helpers for the curses backend.
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.curses
+
+Also provides mnemonic extraction from labels that may use Qt ('&X' / '&&')
+or GTK ('_X' / '__') style markers.
+'''
+
+import curses
+import curses.ascii
+import sys
+import os
+import time
+import logging
+from typing import Tuple, Optional
+from ...yui_common import *
+
+def extract_mnemonic(label: Optional[str]) -> Tuple[Optional[str], str]:
+ """Extract mnemonic character and return (mnemonic_char, clean_label).
+
+ - Supports Qt-style '&' mnemonic: '&F' => mnemonic 'f'. '&&' => literal '&'.
+ - Supports GTK-style '_' mnemonic: '_F' => mnemonic 'f'. '__' => literal '_'.
+ - If no mnemonic found, returns (None, original_label).
+
+ The returned clean_label has mnemonic markers removed/unescaped.
+ """
+ if label is None:
+ return None, label
+ s = str(label)
+ if not s:
+ return None, s
+
+ # Prefer Qt-style '&' parsing first
+ mn: Optional[str] = None
+ out = []
+ i = 0
+ n = len(s)
+ while i < n:
+ ch = s[i]
+ if ch == '&':
+ # literal '&&'
+ if i + 1 < n and s[i + 1] == '&':
+ out.append('&')
+ i += 2
+ continue
+ # mnemonic (single '&')
+ if i + 1 < n and mn is None:
+ mn = s[i + 1].lower()
+ # do not add '&' to output; just skip marker and continue
+ i += 1
+ continue
+ # stray '&' at end -> drop
+ i += 1
+ continue
+ out.append(ch)
+ i += 1
+
+ # If no '&' mnemonic found, try '_' GTK style
+ if mn is None and '_' in s:
+ out2 = []
+ i = 0
+ n = len(s)
+ while i < n:
+ ch = s[i]
+ if ch == '_':
+ if i + 1 < n and s[i + 1] == '_':
+ out2.append('_')
+ i += 2
+ continue
+ if i + 1 < n and mn is None:
+ mn = s[i + 1].lower()
+ i += 1
+ continue
+ i += 1
+ continue
+ out2.append(ch)
+ i += 1
+ return mn, ''.join(out2)
+
+ return mn, ''.join(out)
+
+
+def split_mnemonic(label: Optional[str]) -> Tuple[Optional[str], Optional[int], str]:
+ """Return (mnemonic_char, mnemonic_index_in_clean, clean_label).
+
+ Works like extract_mnemonic but also returns the index in the cleaned
+ label where the mnemonic applies, or None if not present.
+ """
+ if label is None:
+ return None, None, label
+ s = str(label)
+ if not s:
+ return None, None, s
+
+ mn = None
+ idx = None
+ out = []
+ i = 0
+ n = len(s)
+ while i < n:
+ ch = s[i]
+ if ch == '&':
+ if i + 1 < n and s[i + 1] == '&':
+ out.append('&')
+ i += 2
+ continue
+ if i + 1 < n and mn is None:
+ mn = s[i + 1].lower()
+ idx = len(out)
+ i += 1
+ continue
+ i += 1
+ continue
+ out.append(ch)
+ i += 1
+ if mn is None and '_' in s:
+ out2 = []
+ i = 0
+ n = len(s)
+ while i < n:
+ ch = s[i]
+ if ch == '_':
+ if i + 1 < n and s[i + 1] == '_':
+ out2.append('_')
+ i += 2
+ continue
+ if i + 1 < n and mn is None:
+ mn = s[i + 1].lower()
+ idx = len(out2)
+ i += 1
+ continue
+ i += 1
+ continue
+ out2.append(ch)
+ i += 1
+ return mn, idx, ''.join(out2)
+ return mn, idx, ''.join(out)
+
+
+__all__ = ["pixels_to_chars", "_curses_recursive_min_height", "_curses_recursive_min_width"]
+
+# Module-level logger for common curses helpers
+_mod_logger = logging.getLogger("manatools.aui.curses.common")
+if not logging.getLogger().handlers:
+ _h = logging.StreamHandler()
+ _h.setFormatter(logging.Formatter("%(asctime)s %(name)s %(levelname)s: %(message)s"))
+ _mod_logger.addHandler(_h)
+ _mod_logger.setLevel(logging.INFO)
+
+def pixels_to_chars(size_px: int, dim: YUIDimension) -> int:
+ """Convert a pixel size into terminal character cells.
+
+ Horizontal (X): 1 character = 8 pixels (i.e., 1 pixel = 0.125 char).
+ Vertical (Y): assumed 1 character row ≈ 16 pixels (typical terminal font).
+
+ The vertical ratio can be adjusted later if needed; this maps pixel sizes
+ to curses units uniformly with Qt/GTK which operate in pixels.
+ """
+ try:
+ px = max(0, int(size_px))
+ except Exception:
+ px = 0
+ if dim == YUIDimension.YD_HORIZ:
+ return max(0, int(round(px / 8.0)))
+ else:
+ return max(0, int(round(px / 16.0)))
+
+def _curses_recursive_min_height(widget):
+ """Compute minimal height for a widget, recursively considering container children."""
+ if widget is None:
+ return 1
+ try:
+ cls = widget.widgetClass() if hasattr(widget, "widgetClass") else ""
+ except Exception:
+ cls = ""
+ try:
+ if cls == "YVBox":
+ chs = list(getattr(widget, "_children", []) or [])
+ spacing = max(0, len(chs) - 1)
+ total = 0
+ for c in chs:
+ total += _curses_recursive_min_height(c)
+ return max(1, total + spacing)
+ elif cls == "YHBox":
+ chs = list(getattr(widget, "_children", []) or [])
+ tallest = 1
+ for c in chs:
+ tallest = max(tallest, _curses_recursive_min_height(c))
+ return max(1, tallest)
+ elif cls == "YAlignment":
+ child = widget.child()
+ return max(1, _curses_recursive_min_height(child))
+ elif cls == "YReplacePoint":
+ # Treat ReplacePoint as a transparent single-child container
+ child = widget.child()
+ return max(1, _curses_recursive_min_height(child))
+ elif cls == "YFrame" or cls == "YCheckBoxFrame":
+ child = widget.child()
+ inner_top = max(0, getattr(widget, "_inner_top_padding", 1))
+ inner_min = _curses_recursive_min_height(child)
+ return max(3, 2 + inner_top + inner_min) # borders(2) + padding + inner
+ else:
+ return max(1, getattr(widget, "_height", 1))
+ except Exception as e:
+ try:
+ _mod_logger.error("_curses_recursive_min_height error: %s", e, exc_info=True)
+ except Exception:
+ pass
+ return max(1, getattr(widget, "_height", 1))
+
+
+def _curses_recursive_min_width(widget):
+ """Compute minimal width for a widget, recursively considering container children.
+
+ Heuristics:
+ - YHBox: sum of children's minimal widths plus 1 char spacing between them
+ - YVBox: maximal minimal width among children
+ - Frames (YFrame/YCheckBoxFrame): border (2) + inner minimal width
+ - Alignment/ReplacePoint: pass-through to child
+ - Basic widgets: use `minWidth()` if available, otherwise infer from text/label
+ """
+ try:
+ if widget is None:
+ return 1
+ cls = widget.widgetClass() if hasattr(widget, "widgetClass") else ""
+ # Helper to infer min width of leaf/basic widgets
+ def _leaf_min_width(w):
+ try:
+ if hasattr(w, "minWidth"):
+ m = int(w.minWidth())
+ return max(1, m)
+ except Exception:
+ pass
+ try:
+ c = w.widgetClass() if hasattr(w, "widgetClass") else ""
+ if c in ("YLabel", "YPushButton", "YCheckBox"):
+ text = getattr(w, "_text", None)
+ if text is None:
+ text = getattr(w, "_label", "")
+ pad = 4 if c == "YPushButton" else 0
+ # Checkbox includes symbol like "[ ] "
+ if c == "YCheckBox":
+ pad = max(pad, 4)
+ return max(1, len(str(text)) + pad)
+ except Exception:
+ pass
+ try:
+ return max(1, int(getattr(w, "_width", 10)))
+ except Exception:
+ return 10
+
+ if cls == "YHBox":
+ chs = list(getattr(widget, "_children", []) or [])
+ if not chs:
+ return 1
+ spacing = max(0, len(chs) - 1)
+ total = 0
+ for c in chs:
+ total += _curses_recursive_min_width(c)
+ return max(1, total + spacing)
+ elif cls == "YVBox":
+ chs = list(getattr(widget, "_children", []) or [])
+ if not chs:
+ return 1
+ widest = 1
+ for c in chs:
+ widest = max(widest, _curses_recursive_min_width(c))
+ return max(1, widest)
+ elif cls == "YAlignment":
+ child = widget.child()
+ return max(1, _curses_recursive_min_width(child))
+ elif cls == "YReplacePoint":
+ child = widget.child()
+ return max(1, _curses_recursive_min_width(child))
+ elif cls == "YFrame" or cls == "YCheckBoxFrame":
+ child = widget.child()
+ inner_min = _curses_recursive_min_width(child) if child is not None else 1
+ return max(3, 2 + inner_min) # borders(2) + inner
+ else:
+ return _leaf_min_width(widget)
+ except Exception as e:
+ try:
+ _mod_logger.error("_curses_recursive_min_width error: %s", e, exc_info=True)
+ except Exception:
+ pass
+ try:
+ return max(1, int(getattr(widget, "_width", 10)))
+ except Exception:
+ return 10
diff --git a/manatools/aui/backends/curses/datefieldcurses.py b/manatools/aui/backends/curses/datefieldcurses.py
new file mode 100644
index 0000000..265b35b
--- /dev/null
+++ b/manatools/aui/backends/curses/datefieldcurses.py
@@ -0,0 +1,245 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.curses contains all curses backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.curses
+'''
+import curses
+import curses.ascii
+import logging
+import locale
+from ...yui_common import *
+
+
+def _locale_date_order():
+ try:
+ locale.setlocale(locale.LC_TIME, '')
+ except Exception:
+ pass
+ try:
+ fmt = locale.nl_langinfo(locale.D_FMT)
+ except Exception:
+ fmt = '%Y-%m-%d'
+ fmt = fmt or '%Y-%m-%d'
+ order = []
+ i = 0
+ while i < len(fmt):
+ if fmt[i] == '%':
+ i += 1
+ if i < len(fmt):
+ c = fmt[i]
+ if c in ('Y', 'y'):
+ order.append('Y')
+ elif c in ('m', 'b', 'B'):
+ order.append('M')
+ elif c in ('d', 'e'):
+ order.append('D')
+ i += 1
+ for x in ['Y', 'M', 'D']:
+ if x not in order:
+ order.append(x)
+ return order[:3]
+
+
+class YDateFieldCurses(YWidget):
+ """NCurses backend YDateField with three integer segments (Y, M, D) ordered per locale.
+ value()/setValue() use ISO format YYYY-MM-DD. No change events posted.
+ Navigation: Left/Right to change segment, Up/Down or +/- to change value, digits to type.
+ """
+ def __init__(self, parent=None, label: str = ""):
+ super().__init__(parent)
+ self._logger = logging.getLogger(f"manatools.aui.ncurses.{self.__class__.__name__}")
+ self._label = label or ""
+ self._y, self._m, self._d = 2000, 1, 1
+ self._order = _locale_date_order()
+ self._seg_index = 0 # 0..2
+ self._editing = False
+ self._edit_buf = ""
+ self._can_focus = True
+ self._focused = False
+ # Default: do not stretch horizontally or vertically; respects external overrides
+ try:
+ self.setStretchable(YUIDimension.YD_HORIZ, False)
+ self.setStretchable(YUIDimension.YD_VERT, False)
+ except Exception:
+ pass
+
+ def widgetClass(self):
+ return "YDateField"
+
+ def value(self) -> str:
+ return f"{self._y:04d}-{self._m:02d}-{self._d:02d}"
+
+ def setValue(self, datestr: str):
+ try:
+ y, m, d = [int(p) for p in str(datestr).split('-')]
+ except Exception:
+ return
+ y = max(1, min(9999, y))
+ m = max(1, min(12, m))
+ dmax = self._days_in_month(y, m)
+ d = max(1, min(dmax, d))
+ self._y, self._m, self._d = y, m, d
+
+ def _days_in_month(self, y, m):
+ if m in (1,3,5,7,8,10,12):
+ return 31
+ if m in (4,6,9,11):
+ return 30
+ leap = (y % 4 == 0 and (y % 100 != 0 or y % 400 == 0))
+ return 29 if leap else 28
+
+ def _create_backend_widget(self):
+ self._backend_widget = self
+ try:
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception:
+ pass
+
+ def _set_backend_enabled(self, enabled):
+ pass
+
+ def _draw(self, window, y, x, width, height):
+ if self._visible is False:
+ return
+ try:
+ line = y
+ label_to_show = self._label if self._label else (self.debugLabel() if hasattr(self, 'debugLabel') else "unknown")
+ try:
+ window.addstr(line, x, label_to_show[:width])
+ except curses.error:
+ pass
+ line += 1
+
+ # build ordered parts
+ parts = {'Y': f"{self._y:04d}", 'M': f"{self._m:02d}", 'D': f"{self._d:02d}"}
+ disp = []
+ for idx, p in enumerate(self._order):
+ seg_text = parts[p]
+ # when focused on segment, show edit buffer if editing
+ if self._focused and idx == self._seg_index:
+ if self._editing:
+ buf = self._edit_buf or ''
+ seg_w = 4 if p == 'Y' else 2
+ # right-align buffer into field width
+ buf_disp = buf.rjust(seg_w)
+ text = f"[{buf_disp}]"
+ else:
+ text = f"[{seg_text}]"
+ else:
+ text = f" {seg_text} "
+ disp.append(text)
+ if idx < 2:
+ disp.append("-")
+ out = ''.join(disp)
+ try:
+ window.addstr(line, x, out[:max(0, width)])
+ except curses.error:
+ pass
+ except curses.error:
+ pass
+
+ def _handle_key(self, key):
+ if not self._focused or not self.isEnabled() or not self.visible():
+ return False
+
+ # Editing
+ if self._editing:
+ if key in (curses.KEY_BACKSPACE, 127, 8):
+ if self._edit_buf:
+ self._edit_buf = self._edit_buf[:-1]
+ return True
+ if curses.ascii.isdigit(key):
+ self._edit_buf += chr(key)
+ return True
+ if key in (ord('\n'), ord(' ')):
+ self._commit_edit()
+ return True
+ if key == 27: # ESC
+ self._cancel_edit()
+ return True
+ # navigation commits and re-handles
+ if key in (curses.KEY_LEFT, curses.KEY_RIGHT, curses.KEY_UP, curses.KEY_DOWN, ord('+'), ord('-')):
+ self._commit_edit()
+ # fall through
+
+ # Non-editing navigation
+ if key in (curses.KEY_LEFT,):
+ self._seg_index = (self._seg_index - 1) % 3
+ return True
+ if key in (curses.KEY_RIGHT,):
+ self._seg_index = (self._seg_index + 1) % 3
+ return True
+ if key in (curses.KEY_UP, ord('+')):
+ self._bump(+1)
+ return True
+ if key in (curses.KEY_DOWN, ord('-')):
+ self._bump(-1)
+ return True
+ if curses.ascii.isdigit(key):
+ self._begin_edit(chr(key))
+ return True
+ return False
+
+ def _seg_ref(self, idx):
+ seg = self._order[idx]
+ if seg == 'Y':
+ return '_y', 1, 9999
+ if seg == 'M':
+ return '_m', 1, 12
+ # D
+ return '_d', 1, self._days_in_month(self._y, self._m)
+
+ def _bump(self, delta):
+ name, lo, hi = self._seg_ref(self._seg_index)
+ val = getattr(self, name)
+ if name == '_d':
+ hi = self._days_in_month(self._y, self._m)
+ val += delta
+ if val < lo: val = lo
+ if val > hi: val = hi
+ setattr(self, name, val)
+ # adjust day when year/month change affects max days
+ if name in ('_y','_m'):
+ dmax = self._days_in_month(self._y, self._m)
+ if self._d > dmax:
+ self._d = dmax
+
+ def _begin_edit(self, initial_char=None):
+ self._editing = True
+ self._edit_buf = '' if initial_char is None else str(initial_char)
+
+ def _cancel_edit(self):
+ self._editing = False
+ self._edit_buf = ''
+
+ def _commit_edit(self):
+ if self._edit_buf in ('', '-'):
+ self._cancel_edit()
+ return
+ try:
+ v = int(self._edit_buf)
+ except ValueError:
+ self._cancel_edit()
+ return
+ name, lo, hi = self._seg_ref(self._seg_index)
+ if name == '_d':
+ hi = self._days_in_month(self._y, self._m)
+ v = max(lo, min(hi, v))
+ setattr(self, name, v)
+ # adjust day if needed
+ if name in ('_y','_m'):
+ dmax = self._days_in_month(self._y, self._m)
+ if self._d > dmax:
+ self._d = dmax
+ self._cancel_edit()
+
+ def setVisible(self, visible: bool = True):
+ super().setVisible(visible)
+ # in curses backend visibility controls whether widget can receive focus
+ self._can_focus = bool(visible)
\ No newline at end of file
diff --git a/manatools/aui/backends/curses/dialogcurses.py b/manatools/aui/backends/curses/dialogcurses.py
new file mode 100644
index 0000000..ef292dc
--- /dev/null
+++ b/manatools/aui/backends/curses/dialogcurses.py
@@ -0,0 +1,739 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.curses contains all curses backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.curses
+'''
+import curses
+import curses.ascii
+import sys
+import os
+import time
+import logging
+from ...yui_common import *
+from ... import yui as yui_mod
+
+# Module-level safe logging setup for dialog backend; main application may override
+_mod_logger = logging.getLogger("manatools.aui.curses.dialog.module")
+if not logging.getLogger().handlers:
+ _h = logging.StreamHandler()
+ _h.setFormatter(logging.Formatter("%(asctime)s %(name)s %(levelname)s: %(message)s"))
+ _mod_logger.addHandler(_h)
+ _mod_logger.setLevel(logging.INFO)
+
+class YDialogCurses(YSingleChildContainerWidget):
+ """Ncurses dialog container with focus, help, and default button support."""
+ _open_dialogs = []
+ _current_dialog = None
+
+ def __init__(self, dialog_type=YDialogType.YMainDialog, color_mode=YDialogColorMode.YDialogNormalColor):
+ super().__init__()
+ # per-instance logger
+ self._logger = logging.getLogger(f"manatools.aui.ncurses.{self.__class__.__name__}")
+ if not self._logger.handlers and not logging.getLogger().handlers:
+ for h in _mod_logger.handlers:
+ self._logger.addHandler(h)
+ self._logger.debug("YDialogCurses.__init__ dialog_type=%s color_mode=%s", dialog_type, color_mode)
+ self._dialog_type = dialog_type
+ self._color_mode = color_mode
+ self._is_open = False
+ self._window = None
+ self._focused_widget = None
+ self._last_draw_time = 0
+ self._draw_interval = 0.1 # seconds
+ self._event_result = None
+ # Debounce for resize handling (avoid flicker)
+ self._resize_pending_until = 0.0
+ self._last_term_size = (0, 0) # (h, w)
+ # Transient help overlay (triggered by F1)
+ self._help_overlay_text = None
+ self._help_overlay_until = 0.0
+ self._help_overlay_pos = None # (y, x) where to draw the overlay; None = auto fallback
+ self._default_button = None
+ YDialogCurses._open_dialogs.append(self)
+
+ def widgetClass(self):
+ return "YDialog"
+
+ @staticmethod
+ def currentDialog(doThrow=True):
+ open_dialog = YDialogCurses._open_dialogs[-1] if YDialogCurses._open_dialogs else None
+ if not open_dialog and doThrow:
+ raise YUINoDialogException("No dialog is currently open")
+ return open_dialog
+
+ @staticmethod
+ def topmostDialog(doThrow=True):
+ ''' same as currentDialog '''
+ return YDialogCurses.currentDialog(doThrow=doThrow)
+
+ def isTopmostDialog(self):
+ '''Return whether this dialog is the topmost open dialog.'''
+ return YDialogCurses._open_dialogs[-1] == self if YDialogCurses._open_dialogs else False
+
+ @classmethod
+ def deleteAllDialogs(cls, doThrow=True):
+ """Delete all open dialogs (best-effort)."""
+ ok = True
+ try:
+ while cls._open_dialogs:
+ try:
+ dlg = cls._open_dialogs[-1]
+ dlg.destroy(doThrow)
+ except Exception:
+ ok = False
+ try:
+ cls._open_dialogs.pop()
+ except Exception:
+ break
+ except Exception:
+ ok = False
+ return ok
+
+ def open(self):
+ if not self._window:
+ self._create_backend_widget()
+
+ self._is_open = True
+ YDialogCurses._current_dialog = self
+ self._logger.debug("dialog opened; current_dialog set")
+
+ # Find first focusable widget
+ focusable = self._find_focusable_widgets()
+ if focusable:
+ self._focused_widget = self._default_button if self._default_button else focusable[0]
+ self._focused_widget._focused = True
+
+ # open() must be non-blocking (finalize and show). Event loop is
+ # started by waitForEvent() to match libyui semantics.
+ return True
+
+ def isOpen(self):
+ return self._is_open
+
+ def destroy(self, doThrow=True):
+ self._clear_default_button()
+ self._is_open = False
+ if self in YDialogCurses._open_dialogs:
+ YDialogCurses._open_dialogs.remove(self)
+ if YDialogCurses._current_dialog == self:
+ YDialogCurses._current_dialog = None
+ try:
+ self._logger.debug("dialog destroyed; remaining open=%d", len(YDialogCurses._open_dialogs))
+ except Exception:
+ pass
+ return True
+
+ @classmethod
+ def deleteTopmostDialog(cls, doThrow=True):
+ if cls._open_dialogs:
+ dialog = cls._open_dialogs[-1]
+ return dialog.destroy(doThrow)
+ return False
+
+ @classmethod
+ def currentDialog(cls, doThrow=True):
+ if not cls._open_dialogs:
+ if doThrow:
+ raise YUINoDialogException("No dialog open")
+ return None
+ return cls._open_dialogs[-1]
+
+ def setDefaultButton(self, button):
+ """Set or clear the dialog default push button."""
+ if button is None:
+ self._clear_default_button()
+ return True
+ try:
+ if button.widgetClass() != "YPushButton":
+ raise ValueError("Default button must be a YPushButton instance")
+ except Exception:
+ self._logger.error("Invalid widget passed to setDefaultButton", exc_info=True)
+ return False
+ try:
+ dlg = button.findDialog() if hasattr(button, "findDialog") else None
+ except Exception:
+ dlg = None
+ if dlg not in (None, self):
+ try:
+ self._logger.error("Button belongs to another dialog; cannot set as default")
+ except Exception:
+ pass
+ return False
+ try:
+ button.setDefault(True)
+ except Exception:
+ self._logger.exception("Failed to mark button as default")
+ return False
+ # Mirror GTK/Qt behavior: highlight default button and give it focus so
+ # the user can immediately activate it with space/enter.
+ try:
+ self._focus_widget(button)
+ except Exception:
+ pass
+ return True
+
+ def _create_backend_widget(self):
+ # Use the main screen
+ self._backend_widget = curses.newwin(0, 0, 0, 0)
+ try:
+ self._logger.debug("_create_backend_widget created backend window")
+ except Exception:
+ pass
+
+ def _set_backend_enabled(self, enabled):
+ """Enable/disable the dialog and propagate to contained widgets."""
+ try:
+ # propagate logical enabled state to entire subtree using setEnabled on children
+ # so each widget's hook executes and updates its state.
+ child = self.child()
+ if child is not None:
+ child.setEnabled(enabled)
+
+ # If disabling and dialog had focused widget, clear focus
+ if not enabled:
+ try:
+ if getattr(self, "_focused_widget", None):
+ self._focused_widget._focused = False
+ self._focused_widget = None
+ except Exception:
+ pass
+ # Force a redraw so disabled/enabled visual state appears immediately
+ try:
+ self._last_draw_time = 0
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def _draw_dialog(self):
+ """Draw the entire dialog (called by event loop)"""
+ if not hasattr(self, '_backend_widget') or not self._backend_widget:
+ return
+
+ try:
+ height, width = self._backend_widget.getmaxyx()
+ #self._logger.debug("Dialog window size: height=%d width=%d", height, width)
+
+ # Clear screen
+ self._backend_widget.clear()
+
+ # Draw border
+ self._backend_widget.border()
+
+ # Draw title
+ title = " manatools YUI NCurses Dialog "
+ try:
+ appobj = yui_mod.YUI.ui().application()
+ atitle = appobj.applicationTitle()
+ if atitle:
+ title = atitle
+ appobj.setApplicationTitle(title)
+ except Exception:
+ # ignore and keep default
+ pass
+ title_x = max(0, (width - len(title)) // 2)
+ self._backend_widget.addstr(0, title_x, title, curses.A_BOLD)
+
+ # Draw content area - fixed coordinates for child
+ content_height = height - 4
+ content_width = width - 4
+ content_y = 2
+ content_x = 2
+ #self._logger.debug("Dialog content area: y=%d x=%d h=%d w=%d", content_y, content_x, content_height, content_width)
+
+ # Draw child content
+ if self.hasChildren():
+ self._draw_child_content(content_y, content_x, content_width, content_height)
+
+ # Draw footer with instructions
+ footer_text = " TAB=Navigate | SPACE=Expand | ENTER=Select | F10=Quit "
+ footer_x = max(0, (width - len(footer_text)) // 2)
+ if footer_x + len(footer_text) < width:
+ self._backend_widget.addstr(height - 1, footer_x, footer_text, curses.A_DIM)
+
+ # Draw focus indicator: prefer widget label, otherwise use debugLabel() or 'unknown'
+ if self._focused_widget:
+ lbl = getattr(self._focused_widget, '_label', None)
+ if not lbl:
+ lbl = (self._focused_widget.debugLabel()
+ if hasattr(self._focused_widget, 'debugLabel') else 'Unknown')
+ focus_text = f" Focus: {lbl} "
+ if len(focus_text) < width:
+ self._backend_widget.addstr(height - 1, 2, focus_text, curses.A_REVERSE)
+ #ifthe focused widget has an expnded list (menus, combos,...), draw it on top
+ if hasattr(self._focused_widget, "_draw_expanded_list"):
+ self._focused_widget._draw_expanded_list(self._backend_widget)
+
+ # Draw help overlay if active and not expired
+ try:
+ now = time.time()
+ if getattr(self, "_help_overlay_text", None) and getattr(self, "_help_overlay_until", 0) > now:
+ help_txt = str(self._help_overlay_text)
+ # Prefer to draw the help text inside the focused widget's area when possible.
+ overlay_y = None
+ overlay_x = None
+ overlay_width = None
+ try:
+ h, w = self._backend_widget.getmaxyx()
+ except Exception:
+ h, w = 24, 80
+ # 1) explicit suggested position (from when F1 was pressed)
+ if getattr(self, "_help_overlay_pos", None):
+ overlay_y, overlay_x = self._help_overlay_pos
+ else:
+ fw = self._focused_widget
+ if fw is not None:
+ # Try a number of heuristic attributes that widgets commonly expose
+ try:
+ if getattr(fw, "_combo_y", None) is not None and getattr(fw, "_combo_x", None) is not None:
+ overlay_y, overlay_x = fw._combo_y, fw._combo_x
+ elif getattr(fw, "_y", None) is not None and getattr(fw, "_x", None) is not None:
+ overlay_y, overlay_x = fw._y, fw._x
+ elif getattr(fw, "_widget_y", None) is not None and getattr(fw, "_widget_x", None) is not None:
+ overlay_y, overlay_x = fw._widget_y, fw._widget_x
+ elif getattr(fw, "_draw_y", None) is not None and getattr(fw, "_draw_x", None) is not None:
+ overlay_y, overlay_x = fw._draw_y, fw._draw_x
+ except Exception:
+ overlay_y = overlay_x = None
+ # Attempt to obtain widget width if provided to limit overlay width to widget area
+ try:
+ overlay_width = getattr(fw, "_combo_width", None) or getattr(fw, "_width", None) or getattr(fw, "_w", None)
+ except Exception:
+ overlay_width = None
+ # Fallback to safe location near footer/content if we couldn't determine widget area
+ if overlay_y is None:
+ overlay_y = max(1, h - 3)
+ if overlay_x is None:
+ overlay_x = 2
+ # Compute remaining / available width
+ try:
+ max_allowed = max(5, w - overlay_x - 1)
+ except Exception:
+ max_allowed = 10
+ # If widget-specific width was provided, prefer it (but clamp to available)
+ if overlay_width:
+ try:
+ overlay_width = int(overlay_width)
+ except Exception:
+ overlay_width = None
+ remain = overlay_width if overlay_width and overlay_width > 0 else max_allowed
+ remain = min(remain, max_allowed)
+ if remain < 5:
+ remain = 5
+ # Clip/truncate help text to fit
+ display = help_txt
+ if len(display) > remain:
+ display = display[: max(0, remain - 1)] + "…"
+ # Draw overlay inside widget area (standout)
+ try:
+ self._backend_widget.addnstr(overlay_y, overlay_x, display, remain, curses.A_STANDOUT)
+ except Exception:
+ try:
+ self._backend_widget.addnstr(overlay_y, overlay_x, display, remain)
+ except Exception:
+ pass
+ else:
+ # overlay expired -> cleanup fields
+ if getattr(self, "_help_overlay_text", None):
+ self._help_overlay_text = None
+ self._help_overlay_until = 0.0
+ self._help_overlay_pos = None
+ except Exception:
+ pass
+
+ # Refresh main window first
+ self._backend_widget.refresh()
+
+ except curses.error as e:
+ # Ignore curses errors (like writing beyond screen bounds)
+ pass
+
+ def _draw_child_content(self, start_y, start_x, max_width, max_height):
+ """Draw the child widget content respecting container hierarchy"""
+ if not self.hasChildren():
+ return
+
+ # Draw only the root child - it will handle drawing its own children
+ child = self.child()
+ if hasattr(child, '_draw'):
+ child._draw(self._backend_widget, start_y, start_x, max_width, max_height)
+
+
+ def _cycle_focus(self, forward=True):
+ """Cycle focus between focusable widgets"""
+ focusable = self._find_focusable_widgets()
+ if not focusable:
+ return
+
+ current_index = -1
+ if self._focused_widget:
+ for i, widget in enumerate(focusable):
+ if widget == self._focused_widget:
+ current_index = i
+ break
+
+ if current_index == -1:
+ new_index = 0
+ else:
+ if forward:
+ new_index = (current_index + 1) % len(focusable)
+ else:
+ new_index = (current_index - 1) % len(focusable)
+
+ # If the currently focused widget is an expanded combo, collapse it
+ # so tabbing away closes the dropdown but does not change selection.
+ if self._focused_widget:
+ try:
+ if getattr(self._focused_widget, "_expanded", False):
+ self._focused_widget._expanded = False
+ except Exception:
+ pass
+ self._focused_widget._focused = False
+
+ self._focused_widget = focusable[new_index]
+ self._focused_widget._focused = True
+ # Force redraw on focus change
+ self._last_draw_time = 0
+
+ def _focus_widget(self, widget):
+ """Force focus on a specific widget when possible."""
+ if widget is None:
+ return False
+ try:
+ if not getattr(widget, "_can_focus", False):
+ return False
+ except Exception:
+ return False
+ try:
+ if not widget.isEnabled() or not widget.visible():
+ return False
+ except Exception:
+ return False
+ # Skip updates if the widget already owns focus
+ if getattr(widget, "_focused", False):
+ self._focused_widget = widget
+ return True
+ # Clear previous focus
+ if getattr(self, "_focused_widget", None) is not None:
+ try:
+ self._focused_widget._focused = False
+ except Exception:
+ pass
+ self._focused_widget = widget
+ try:
+ widget._focused = True
+ except Exception:
+ pass
+ try:
+ self._last_draw_time = 0
+ except Exception:
+ pass
+ return True
+
+ def _find_focusable_widgets(self):
+ """Find all widgets that can receive focus"""
+ focusable = []
+
+ def find_in_widget(widget):
+ if hasattr(widget, '_can_focus') and widget._can_focus:
+ focusable.append(widget)
+ for child in widget._children:
+ find_in_widget(child)
+
+ if self.hasChildren():
+ find_in_widget(self.child())
+
+ return focusable
+
+
+ def _post_event(self, event):
+ """Post an event to this dialog; waitForEvent will return it."""
+ self._event_result = event
+ # If dialog is not open anymore, ensure cleanup
+ if isinstance(event, YCancelEvent):
+ # Mark closed so loop can clean up
+ self._is_open = False
+
+ def waitForEvent(self, timeout_millisec=0):
+ """
+ Run the ncurses event loop until an event is posted or timeout occurs.
+ timeout_millisec == 0 -> block indefinitely until an event (no timeout).
+ Returns a YEvent (YWidgetEvent, YTimeoutEvent, YCancelEvent, ...).
+ """
+ from manatools.aui.yui import YUI
+ ui = YUI.ui()
+
+ # Ensure dialog is open/finalized
+ if not self._is_open:
+ self.open()
+
+ self._event_result = None
+ deadline = None
+ if timeout_millisec and timeout_millisec > 0:
+ deadline = time.time() + (timeout_millisec / 1000.0)
+
+ while self._is_open and self._event_result is None:
+ try:
+ now = time.time()
+
+ # Apply pending resize once debounce expires
+ if self._resize_pending_until and now >= self._resize_pending_until:
+ try:
+ ui._stdscr.clear()
+ ui._stdscr.refresh()
+ new_h, new_w = ui._stdscr.getmaxyx()
+ try:
+ curses.resizeterm(new_h, new_w)
+ except Exception:
+ pass
+ # Recreate backend window (full-screen)
+ self._backend_widget = curses.newwin(new_h, new_w, 0, 0)
+ self._last_term_size = (new_h, new_w)
+ except Exception:
+ pass
+ # Clear pending flag and force immediate redraw
+ self._resize_pending_until = 0.0
+ self._last_draw_time = 0
+
+ # Draw at most every _draw_interval; forced redraw uses last_draw_time = 0
+ if (now - self._last_draw_time) >= self._draw_interval:
+ self._draw_dialog()
+ self._last_draw_time = now
+
+ # Non-blocking input
+ ui._stdscr.nodelay(True)
+ key = ui._stdscr.getch()
+
+ if key == -1:
+ if deadline and time.time() >= deadline:
+ self._event_result = YTimeoutEvent()
+ break
+ time.sleep(0.01)
+ continue
+
+ #any key received, clear help overlay if active
+ self._help_overlay_text = None
+ self._help_overlay_until = 0.0
+ self._help_overlay_pos = None
+ # Global keys
+ if key == curses.KEY_F10:
+ self._post_event(YCancelEvent())
+ break
+ elif key == curses.KEY_RESIZE:
+ # Debounce resize; do not redraw immediately to avoid flicker
+ try:
+ new_h, new_w = ui._stdscr.getmaxyx()
+ self._last_term_size = (new_h, new_w)
+ except Exception:
+ pass
+ # Wait 150ms after the last resize event before applying
+ self._resize_pending_until = time.time() + 0.15
+ continue
+
+ # Handle hide/show for paned via focused child
+ if key in (ord('+'), ord('-')):
+ try:
+ if self._bubble_key_to_paned(key):
+ # handled by a YPanedCurses ancestor; force redraw
+ self._last_draw_time = 0
+ continue
+ except Exception:
+ pass
+
+ # Focus navigation
+ if key == ord('\t'):
+ self._cycle_focus(forward=True)
+ self._last_draw_time = 0
+ continue
+ elif key == curses.KEY_BTAB:
+ self._cycle_focus(forward=False)
+ self._last_draw_time = 0
+ continue
+
+ # Dispatch key to focused widget
+ # Handle transient help overlay dismissal: any key (other than F1) should remove it and continue
+ if getattr(self, "_help_overlay_text", None) and key != curses.KEY_F1:
+ # clear overlay immediately and force redraw, then proceed to normal handling
+ try:
+ self._help_overlay_text = None
+ self._help_overlay_until = 0.0
+ self._help_overlay_pos = None
+ self._last_draw_time = 0
+ except Exception:
+ pass
+ handled = False
+ # F1: show help overlay if possible
+ if key == curses.KEY_F1:
+ try:
+ fw = self._focused_widget
+ if fw is not None:
+ ht = None
+ try:
+ ht = fw.helpText() if callable(getattr(fw, "helpText", None)) else None
+ except Exception:
+ ht = None
+ if ht:
+ # compute suggested overlay position (try widget coords)
+ pos = None
+ try:
+ y = getattr(fw, "_combo_y", None)
+ x = getattr(fw, "_combo_x", None)
+ if y is not None and x is not None:
+ pos = (y, x)
+ except Exception:
+ pos = None
+ self._help_overlay_text = str(ht)
+ self._help_overlay_pos = pos
+ self._help_overlay_until = time.time() + 5.0
+ self._last_draw_time = 0
+ # F1 handled
+ handled = True
+ # force immediate redraw next loop iteration
+ except Exception:
+ pass
+
+ # If F1 handled we skip further dispatch to widgets
+ if handled:
+ continue
+
+ # Dispatch key to focused widget
+ if self._focused_widget and hasattr(self._focused_widget, '_handle_key'):
+ handled = self._focused_widget._handle_key(key)
+ if handled:
+ self._last_draw_time = 0
+
+ # Global mnemonic fallback for pushbuttons when not handled
+ if not handled and ((key >= ord('a') and key <= ord('z')) or (key >= ord('A') and key <= ord('Z'))):
+ if self._activate_pushbutton_mnemonic(chr(key)):
+ self._last_draw_time = 0
+ handled = True
+
+ # Trigger default button when Enter/Return pressed and no widget handled it
+ if not handled and key in (curses.KEY_ENTER, ord('\n'), ord('\r')):
+ if self._activate_default_button():
+ self._last_draw_time = 0
+ continue
+
+ except KeyboardInterrupt:
+ self._post_event(YCancelEvent())
+ break
+ except Exception:
+ time.sleep(0.05)
+
+ if self._event_result is None:
+ if not self._is_open:
+ self._event_result = YCancelEvent()
+ elif deadline and time.time() >= deadline:
+ self._event_result = YTimeoutEvent()
+
+ if not self._is_open:
+ try:
+ self.destroy()
+ except Exception:
+ pass
+
+ return self._event_result if self._event_result is not None else YEvent()
+
+ def _activate_pushbutton_mnemonic(self, ch):
+ """Find an enabled pushbutton with matching mnemonic and activate it.
+ Returns True if a button was found and an event posted.
+ """
+ try:
+ target = None
+ ch_low = ch.lower()
+
+ def visit(widget):
+ nonlocal target
+ if target is not None:
+ return
+ try:
+ if getattr(widget, 'widgetClass', lambda: '')() == 'YPushButton':
+ if widget.isEnabled() and getattr(widget, '_mnemonic', None):
+ if str(widget._mnemonic).lower() == ch_low:
+ target = widget
+ return
+ except Exception:
+ pass
+ for c in getattr(widget, '_children', []) or []:
+ visit(c)
+
+ if self.hasChildren():
+ visit(self.child())
+ if target is not None:
+ try:
+ self._post_event(YWidgetEvent(target, YEventReason.Activated))
+ except Exception:
+ pass
+ return True
+ except Exception:
+ pass
+ return False
+
+ def _bubble_key_to_paned(self, key) -> bool:
+ """Send '+' or '-' to the nearest YPanedCurses ancestor of the focused widget."""
+ try:
+ fw = self._focused_widget
+ w = fw
+ while w is not None:
+ try:
+ if hasattr(w, "widgetClass") and w.widgetClass() == "YPaned":
+ if hasattr(w, "_handle_key"):
+ return bool(w._handle_key(key))
+ return False
+ except Exception:
+ pass
+ try:
+ w = w.parent() if hasattr(w, "parent") else getattr(w, "_parent", None)
+ except Exception:
+ w = getattr(w, "_parent", None)
+ return False
+ except Exception:
+ return False
+
+ def _register_default_button(self, button):
+ """Internal: ensure only one default button is tracked for this dialog."""
+ if getattr(self, "_default_button", None) == button:
+ return
+ if getattr(self, "_default_button", None) is not None:
+ try:
+ self._default_button._apply_default_state(False, notify_dialog=False)
+ except Exception:
+ self._logger.exception("Failed to clear previous default button")
+ self._default_button = button
+
+ def _unregister_default_button(self, button):
+ """Internal: drop reference when button is no longer default."""
+ if getattr(self, "_default_button", None) == button:
+ self._default_button = None
+
+ def _clear_default_button(self):
+ """Clear the current default button if any."""
+ if getattr(self, "_default_button", None) is not None:
+ try:
+ self._default_button._apply_default_state(False, notify_dialog=False)
+ except Exception:
+ self._logger.exception("Failed to reset default button state")
+ self._default_button = None
+
+ def _activate_default_button(self):
+ """Post an activation event for the default button if available."""
+ button = getattr(self, "_default_button", None)
+ if button is None:
+ return False
+ try:
+ if not button.isEnabled() or not button.visible():
+ return False
+ except Exception:
+ return False
+ try:
+ self._post_event(YWidgetEvent(button, YEventReason.Activated))
+ return True
+ except Exception:
+ self._logger.exception("Failed to activate default button")
+ return False
diff --git a/manatools/aui/backends/curses/dumbtabcurses.py b/manatools/aui/backends/curses/dumbtabcurses.py
new file mode 100644
index 0000000..8bb773e
--- /dev/null
+++ b/manatools/aui/backends/curses/dumbtabcurses.py
@@ -0,0 +1,189 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.curses contains all curses backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.curses
+'''
+'''
+NCurses backend DumbTab: simple single-selection tab bar with one content area.
+
+- Renders a one-line tab bar with labels; the selected tab is highlighted.
+- Acts as a single-child container: applications typically attach a ReplacePoint.
+- Emits WidgetEvent(Activated) when the active tab changes.
+'''
+import curses
+import curses.ascii
+import logging
+from ...yui_common import *
+
+_mod_logger = logging.getLogger("manatools.aui.curses.dumbtab.module")
+if not logging.getLogger().handlers:
+ _h = logging.StreamHandler()
+ _h.setFormatter(logging.Formatter("%(asctime)s %(name)s %(levelname)s: %(message)s"))
+ _mod_logger.addHandler(_h)
+ _mod_logger.setLevel(logging.INFO)
+
+class YDumbTabCurses(YSelectionWidget):
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self._logger = logging.getLogger(f"manatools.aui.ncurses.{self.__class__.__name__}")
+ if not self._logger.handlers and not logging.getLogger().handlers:
+ for h in _mod_logger.handlers:
+ self._logger.addHandler(h)
+ self._backend_widget = self # curses uses self for drawing
+ self._active_index = -1
+ self._focused = False
+ self._can_focus = True
+ self._height = 2 # tab bar + at least one row for content
+ self.setStretchable(YUIDimension.YD_HORIZ, True)
+ self.setStretchable(YUIDimension.YD_VERT, True)
+
+ def widgetClass(self):
+ return "YDumbTab"
+
+ def _create_backend_widget(self):
+ # Initialize selection state based on items
+ try:
+ idx = -1
+ for i, it in enumerate(self._items):
+ if it.selected():
+ idx = i
+ if idx < 0 and len(self._items) > 0:
+ idx = 0
+ try:
+ self._items[0].setSelected(True)
+ except Exception:
+ pass
+ self._active_index = idx
+ self._selected_items = [ self._items[idx] ] if idx >= 0 else []
+ self._logger.debug("_create_backend_widget: active=%d items=%d", self._active_index, len(self._items))
+ except Exception:
+ pass
+ self._backend_widget = self
+
+ def addChild(self, child):
+ # single child only
+ if self.hasChildren():
+ raise YUIInvalidWidgetException("YDumbTab can only have one child")
+ super().addChild(child)
+
+ def addItem(self, item):
+ super().addItem(item)
+ # If this is the first item and nothing selected, select it
+ try:
+ if self._active_index < 0 and len(self._items) > 0:
+ self._active_index = 0
+ try:
+ self._items[0].setSelected(True)
+ except Exception:
+ pass
+ self._selected_items = [ self._items[0] ]
+ except Exception:
+ pass
+
+ def selectItem(self, item, selected=True):
+ # single selection: set as active
+ if not selected:
+ return
+ try:
+ target = None
+ for i, it in enumerate(self._items):
+ if it is item or it.label() == item.label():
+ target = i
+ break
+ if target is None:
+ return
+ self._active_index = target
+ for i, it in enumerate(self._items):
+ try:
+ it.setSelected(i == target)
+ except Exception:
+ pass
+ self._selected_items = [ self._items[target] ] if target >= 0 else []
+ except Exception:
+ pass
+
+ def _set_backend_enabled(self, enabled):
+ try:
+ self._can_focus = bool(enabled)
+ if not enabled and self._focused:
+ self._focused = False
+ except Exception:
+ pass
+
+ def _draw(self, window, y, x, width, height):
+ try:
+ # Draw tab bar (one line)
+ bar_y = y
+ col = x
+ for i, it in enumerate(self._items):
+ label = it.label()
+ text = f" {label} "
+ attr = curses.A_REVERSE if i == self._active_index and self.isEnabled() else curses.A_BOLD
+ if not self.isEnabled():
+ attr |= curses.A_DIM
+ # clip if needed
+ if col >= x + width:
+ break
+ avail = x + width - col
+ to_draw = text[:max(0, avail)]
+ try:
+ window.addstr(bar_y, col, to_draw, attr)
+ except curses.error:
+ pass
+ col += len(text) + 1
+ # Draw a separator line below tabs if space
+ if height > 1:
+ try:
+ sep = "-" * max(0, width)
+ window.addstr(y + 1, x, sep[:width])
+ except curses.error:
+ pass
+ # Delegate content to single child (if any)
+ ch = self.firstChild()
+ if ch is not None and height > 2:
+ try:
+ ch._draw(window, y + 2, x, width, height - 2)
+ except Exception:
+ pass
+ except curses.error:
+ pass
+
+ def _handle_key(self, key):
+ if not self._can_focus or not self.isEnabled():
+ return False
+ handled = True
+ if key in (curses.KEY_LEFT, curses.ascii.TAB):
+ if self._active_index > 0:
+ self._active_index -= 1
+ self._update_from_active(change_reason=YEventReason.Activated)
+ elif key in (curses.KEY_RIGHT,):
+ if self._active_index < len(self._items) - 1:
+ self._active_index += 1
+ self._update_from_active(change_reason=YEventReason.Activated)
+ elif key in (ord('\n'), ord(' ')):
+ # activate current
+ self._update_from_active(change_reason=YEventReason.Activated)
+ else:
+ handled = False
+ return handled
+
+ def _update_from_active(self, change_reason=YEventReason.SelectionChanged):
+ try:
+ for i, it in enumerate(self._items):
+ try:
+ it.setSelected(i == self._active_index)
+ except Exception:
+ pass
+ self._selected_items = [ self._items[self._active_index] ] if 0 <= self._active_index < len(self._items) else []
+ if self.notify():
+ dlg = self.findDialog()
+ if dlg is not None:
+ dlg._post_event(YWidgetEvent(self, change_reason))
+ except Exception:
+ pass
diff --git a/manatools/aui/backends/curses/framecurses.py b/manatools/aui/backends/curses/framecurses.py
new file mode 100644
index 0000000..d1cf863
--- /dev/null
+++ b/manatools/aui/backends/curses/framecurses.py
@@ -0,0 +1,214 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.curses contains all curses backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.curses
+'''
+import curses
+import curses.ascii
+import sys
+import os
+import time
+import logging
+from ...yui_common import *
+
+from .commoncurses import _curses_recursive_min_height
+
+# Module-level logger for frame curses backend
+_mod_logger = logging.getLogger("manatools.aui.curses.frame.module")
+if not logging.getLogger().handlers:
+ _h = logging.StreamHandler()
+ _h.setFormatter(logging.Formatter("%(asctime)s %(name)s %(levelname)s: %(message)s"))
+ _mod_logger.addHandler(_h)
+ _mod_logger.setLevel(logging.INFO)
+
+class YFrameCurses(YSingleChildContainerWidget):
+ """
+ NCurses implementation of YFrame.
+ - Draws a framed box with a title.
+ - Hosts a single child inside the frame with inner margins so the child's
+ own label does not overlap the frame title.
+ - Reports stretchability based on its child.
+ """
+ def __init__(self, parent=None, label=""):
+ super().__init__(parent)
+ self._label = label or ""
+ self._backend_widget = None
+ # Preferred minimal height is computed from child (see _update_min_height)
+ self._height = 3
+ # inner top padding to separate frame title from child's label
+ self._inner_top_padding = 1
+ # per-instance logger
+ self._logger = logging.getLogger(f"manatools.aui.ncurses.{self.__class__.__name__}")
+ if not self._logger.handlers and not logging.getLogger().handlers:
+ for h in _mod_logger.handlers:
+ self._logger.addHandler(h)
+ self._logger.debug("%s.__init__ label=%s", self.__class__.__name__, self._label)
+
+ def widgetClass(self):
+ return "YFrame"
+
+ def _update_min_height(self):
+ """Recompute minimal height: at least 3 rows or child layout min + borders + padding."""
+ try:
+ child = self.child()
+ inner_min = _curses_recursive_min_height(child) if child is not None else 1
+ self._height = max(3, 2 + self._inner_top_padding + inner_min)
+ except Exception:
+ self._height = max(self._height, 3)
+
+ def label(self):
+ return self._label
+
+ def setLabel(self, new_label):
+ try:
+ self._label = str(new_label)
+ except Exception:
+ self._label = new_label
+
+ def stretchable(self, dim):
+ """Frame is stretchable if its child is stretchable or has a weight."""
+ try:
+ child = self.child()
+ if child is None:
+ return False
+ try:
+ if bool(child.stretchable(dim)):
+ return True
+ except Exception:
+ pass
+ try:
+ if bool(child.weight(dim)):
+ return True
+ except Exception:
+ pass
+ except Exception:
+ pass
+ return False
+
+ def _create_backend_widget(self):
+ try:
+ # curses backend does not create a separate widget object for frames;
+ # associate placeholder backend_widget to self to avoid None problems
+ self._backend_widget = self
+ # Update minimal height based on the child
+ self._update_min_height()
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception as e:
+ try:
+ self._logger.error("_create_backend_widget error: %s", e, exc_info=True)
+ except Exception:
+ _mod_logger.error("_create_backend_widget error: %s", e, exc_info=True)
+
+ def _set_backend_enabled(self, enabled):
+ """Propagate enabled state to the child."""
+ try:
+ child = self.child()
+ if child is not None and hasattr(child, "setEnabled"):
+ try:
+ child.setEnabled(enabled)
+ except Exception:
+ pass
+ except Exception as e:
+ try:
+ self._logger.error("_draw error: %s", e, exc_info=True)
+ except Exception:
+ _mod_logger.error("_draw error: %s", e, exc_info=True)
+
+ def addChild(self, child):
+ super().addChild(child)
+ # Update minimal height based on the child
+ self._update_min_height()
+
+ def _draw(self, window, y, x, width, height):
+ """Draw frame border and title, then draw child inside inner area with margins."""
+ try:
+ if width <= 0 or height <= 0:
+ return
+ # Ensure minimal height based on child layout before drawing
+ self._update_min_height()
+ # Graceful fallback for very small areas
+ if height < 3 or width < 4:
+ try:
+ if self._label and height >= 1 and width > 2:
+ title = f" {self._label} "
+ title = title[:max(0, width - 2)]
+ window.addstr(y, x, title, curses.A_BOLD)
+ except curses.error:
+ pass
+ return
+ # Choose box characters (prefer ACS if available)
+ try:
+ hline = curses.ACS_HLINE
+ vline = curses.ACS_VLINE
+ tl = curses.ACS_ULCORNER
+ tr = curses.ACS_URCORNER
+ bl = curses.ACS_LLCORNER
+ br = curses.ACS_LRCORNER
+ except Exception:
+ hline = ord('-')
+ vline = ord('|')
+ tl = ord('+')
+ tr = ord('+')
+ bl = ord('+')
+ br = ord('+')
+
+ # Draw corners and edges
+ try:
+ window.addch(y, x, tl)
+ window.addch(y, x + width - 1, tr)
+ window.addch(y + height - 1, x, bl)
+ window.addch(y + height - 1, x + width - 1, br)
+ for cx in range(x + 1, x + width - 1):
+ window.addch(y, cx, hline)
+ window.addch(y + height - 1, cx, hline)
+ for cy in range(y + 1, y + height - 1):
+ window.addch(cy, x, vline)
+ window.addch(cy, x + width - 1, vline)
+ except curses.error:
+ # best-effort: ignore drawing errors when area is too small
+ pass
+
+ # Draw title centered on top border (leave at least one space from corners)
+ if self._label:
+ try:
+ title = f" {self._label} "
+ max_title_len = max(0, width - 4)
+ if len(title) > max_title_len:
+ title = title[:max(0, max_title_len - 1)] + "…"
+ start_x = x + max(1, (width - len(title)) // 2)
+ # overwrite part of top border with title text
+ window.addstr(y, start_x, title, curses.A_BOLD)
+ except curses.error:
+ pass
+
+ # Compute inner content rectangle
+ inner_x = x + 1
+ inner_y = y + 1
+ inner_w = max(0, width - 2)
+ inner_h = max(0, height - 2)
+
+ pad_top = min(self._inner_top_padding, max(0, inner_h))
+ content_y = inner_y + pad_top
+ content_h = max(0, inner_h - pad_top)
+
+ child = self.child()
+ if child is None:
+ return
+
+ # Clamp content height to at least the child layout minimal height
+ needed = _curses_recursive_min_height(child)
+ # Do not exceed available area; this only influences the draw area passed down
+ content_h = min(max(content_h, needed), inner_h)
+
+ if content_h <= 0 or inner_w <= 0:
+ return
+ if hasattr(child, "_draw"):
+ child._draw(window, content_y, inner_x, inner_w, content_h)
+ except Exception:
+ pass
diff --git a/manatools/aui/backends/curses/hboxcurses.py b/manatools/aui/backends/curses/hboxcurses.py
new file mode 100644
index 0000000..ac2ee19
--- /dev/null
+++ b/manatools/aui/backends/curses/hboxcurses.py
@@ -0,0 +1,361 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.curses contains all curses backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.curses
+'''
+import curses
+import curses.ascii
+import sys
+import os
+import time
+import logging
+from ...yui_common import *
+
+from .commoncurses import _curses_recursive_min_height
+
+# Module-level logger for hbox curses backend
+_mod_logger = logging.getLogger("manatools.aui.curses.hbox.module")
+if not logging.getLogger().handlers:
+ _h = logging.StreamHandler()
+ _h.setFormatter(logging.Formatter("%(asctime)s %(name)s %(levelname)s: %(message)s"))
+ _mod_logger.addHandler(_h)
+ _mod_logger.setLevel(logging.INFO)
+
+class YHBoxCurses(YWidget):
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ # Minimum height will be computed from children
+ self._height = 1
+ self._logger = logging.getLogger(f"manatools.aui.ncurses.{self.__class__.__name__}")
+ if not self._logger.handlers and not logging.getLogger().handlers:
+ for h in _mod_logger.handlers:
+ self._logger.addHandler(h)
+ self._logger.debug("%s.__init__", self.__class__.__name__)
+
+ def widgetClass(self):
+ return "YHBox"
+
+ def _create_backend_widget(self):
+ try:
+ # No real curses widget; associate placeholder to self
+ self._backend_widget = self
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception as e:
+ try:
+ self._logger.error("_create_backend_widget error: %s", e, exc_info=True)
+ except Exception:
+ _mod_logger.error("_create_backend_widget error: %s", e, exc_info=True)
+
+ def _recompute_min_height(self):
+ """Compute minimal height for this horizontal box as the tallest child's minimum."""
+ try:
+ if not self._children:
+ self._height = 1
+ return
+ self._height = max(1, max(_curses_recursive_min_height(c) for c in self._children))
+ except Exception:
+ self._height = 1
+
+ def addChild(self, child):
+ """Ensure internal children list and recompute minimal height."""
+ try:
+ super().addChild(child)
+ except Exception:
+ try:
+ if not hasattr(self, "_children") or self._children is None:
+ self._children = []
+ self._children.append(child)
+ child._parent = self
+ except Exception:
+ pass
+ self._recompute_min_height()
+
+ def _set_backend_enabled(self, enabled):
+ """Enable/disable HBox and propagate to logical children."""
+ try:
+ for c in list(getattr(self, "_children", []) or []):
+ try:
+ c.setEnabled(enabled)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ # Returns the stretchability of the layout box:
+ # * The layout box is stretchable if one of the children is stretchable in
+ # * this dimension or if one of the child widgets has a layout weight in
+ # * this dimension.
+ def stretchable(self, dim):
+ for child in self._children:
+ widget = child.get_backend_widget()
+ expand = bool(child.stretchable(dim))
+ weight = bool(child.weight(dim))
+ if expand or weight:
+ return True
+ # No child is stretchable in this dimension
+ return False
+
+ def _child_min_width(self, child, max_width):
+ # Best-effort minimal width heuristic
+ try:
+ if hasattr(child, "minWidth"):
+ return min(max_width, max(1, int(child.minWidth())))
+ except Exception:
+ pass
+ # If the child is a container, compute its recursive minimal width
+ try:
+ cls = child.widgetClass() if hasattr(child, "widgetClass") else ""
+ if cls in ("YVBox", "YHBox", "YFrame", "YCheckBoxFrame", "YAlignment", "YReplacePoint"):
+ try:
+ return min(max_width, max(1, _curses_recursive_min_width(child)))
+ except Exception:
+ pass
+ except Exception:
+ pass
+ # Heuristics based on common attributes
+ try:
+ cls = child.widgetClass() if hasattr(child, "widgetClass") else ""
+ if cls in ("YLabel", "YPushButton", "YCheckBox"):
+ text = getattr(child, "_text", None)
+ if text is None:
+ text = getattr(child, "_label", "")
+ pad = 4 if cls in ("YPushButton", "YCheckBox") else 0
+ return min(max_width, max(1, len(str(text)) + pad))
+ except Exception:
+ pass
+ return max(1, min(10, max_width)) # safe default
+
+ def _draw(self, window, y, x, width, height):
+ # Ensure minimal height reflects children so the parent allocated enough rows
+ self._recompute_min_height()
+ num_children = len(self._children)
+ if num_children == 0 or width <= 0 or height <= 0:
+ return
+
+ spacing = max(0, num_children - 1)
+ available = max(0, width - spacing)
+
+ # Compute a safe minimal reservation for every child so that
+ # stretchable spacers cannot steal the minimum space required
+ # by subsequent fixed widgets (e.g. the final checkbox).
+ widths = [0] * num_children
+ stretchables = []
+ min_reserved = [0] * num_children
+ pref_reserved = [0] * num_children
+ child_weights = [0] * num_children
+ for i, child in enumerate(self._children):
+ if child.visible() is False:
+ continue
+ # compute each child's minimal width (best-effort)
+ m = self._child_min_width(child, available)
+ min_reserved[i] = max(1, m)
+ # preferred width: allow explicit _width or min_reserved as default
+ try:
+ pref_w = int(getattr(child, "_width", min_reserved[i]))
+ except Exception:
+ pref_w = min_reserved[i]
+ pref_reserved[i] = max(min_reserved[i], pref_w)
+ # gather declared weight (if any)
+ try:
+ w = int(child.weight(YUIDimension.YD_HORIZ))
+ except Exception:
+ try:
+ w = int(getattr(child, "_weight", 0))
+ except Exception:
+ w = 0
+ if w < 0:
+ w = 0
+ child_weights[i] = w
+ # consider as stretchable if explicitly stretchable or has non-zero weight
+ if child.stretchable(YUIDimension.YD_HORIZ) or child_weights[i] > 0:
+ stretchables.append(i)
+
+ # Sum fixed (non-stretchable) minimal widths and minimal total for stretchables
+ fixed_total = sum(min_reserved[i] for i, c in enumerate(self._children) if not (c.stretchable(YUIDimension.YD_HORIZ) or child_weights[i] > 0))
+ min_stretch_total = sum(min_reserved[i] for i, c in enumerate(self._children) if (c.stretchable(YUIDimension.YD_HORIZ) or child_weights[i] > 0))
+
+ # Start allocation from preferred widths, then adjust to fit available
+ widths = list(pref_reserved)
+
+ # collect stretchable weights (0 means equal-share fallback)
+ stretch_weights = []
+ for idx in stretchables:
+ w = child_weights[idx] if idx < len(child_weights) else 0
+ # normalize weight to 0..100 range if out of bounds
+ try:
+ if w > 100:
+ w = 100
+ elif w < 0:
+ w = 0
+ except Exception:
+ w = 0
+ stretch_weights.append(w)
+
+ try:
+ # Detailed per-child diagnostics
+ details = []
+ for i, child in enumerate(self._children):
+ try:
+ lbl = child.debugLabel() if hasattr(child, 'debugLabel') else f'child_{i}'
+ except Exception:
+ lbl = f'child_{i}'
+ try:
+ sw = bool(child.stretchable(YUIDimension.YD_HORIZ))
+ except Exception:
+ sw = False
+ try:
+ wv = int(child.weight(YUIDimension.YD_HORIZ))
+ except Exception:
+ wv = 0
+ details.append((i, lbl, min_reserved[i], pref_reserved[i], sw, wv))
+ #self._logger.debug("HBox allocation inputs: available=%d spacing=%d details=%s",
+ # available, spacing, details)
+ except Exception:
+ pass
+
+ total_pref = sum(widths)
+ # If preferred total fits, distribute surplus among stretchables by weight
+ if total_pref <= available:
+ surplus = available - total_pref
+ if stretchables:
+ total_weight = sum(stretch_weights)
+ if total_weight <= 0:
+ per = surplus // len(stretchables) if stretchables else 0
+ extra = surplus % len(stretchables) if stretchables else 0
+ for k, idx in enumerate(stretchables):
+ widths[idx] += per + (1 if k < extra else 0)
+ else:
+ assigned = [0] * len(stretchables)
+ base = 0
+ for k, w in enumerate(stretch_weights):
+ add = (surplus * w) // total_weight
+ assigned[k] = add
+ base += add
+ leftover = surplus - base
+ for k in range(len(stretchables)):
+ if leftover <= 0:
+ break
+ assigned[k] += 1
+ leftover -= 1
+ for k, idx in enumerate(stretchables):
+ widths[idx] += assigned[k]
+ else:
+ # No stretchables: spread leftover evenly among all children
+ per = surplus // num_children if num_children else 0
+ extra = surplus % num_children if num_children else 0
+ for i in range(num_children):
+ widths[i] += per + (1 if i < extra else 0)
+ else:
+ # Need to shrink preferred sizes down to minima to fit available
+ total_with_spacing = total_pref
+ if total_with_spacing + spacing > available:
+ overflow = total_with_spacing + spacing - available
+ reducible = [max(0, widths[i] - min_reserved[i]) for i in range(num_children)]
+ order = sorted(range(num_children), key=lambda ii: reducible[ii], reverse=True)
+ for idx in order:
+ if overflow <= 0:
+ break
+ can = reducible[idx]
+ if can <= 0:
+ continue
+ take = min(can, overflow)
+ widths[idx] -= take
+ overflow -= take
+ if overflow > 0:
+ for i in range(num_children):
+ if overflow <= 0:
+ break
+ can = max(0, widths[i] - 1)
+ take = min(can, overflow)
+ widths[i] -= take
+ overflow -= take
+
+ # Final debug of allocated widths
+ #self._logger.debug("HBox allocated widths=%s total=%d (available=%d)", widths, sum(widths), available)
+
+ # Ensure containers get at least the width required by their children
+ def _required_width_for(widget):
+ try:
+ cls = widget.widgetClass() if hasattr(widget, 'widgetClass') else ''
+ except Exception:
+ cls = ''
+ try:
+ if cls == 'YPushButton':
+ lbl = getattr(widget, '_label', '')
+ return max(1, len(f"[ {lbl} ]"))
+ if cls == 'YLabel':
+ txt = getattr(widget, '_text', '') or getattr(widget, '_label', '')
+ return max(1, len(str(txt)))
+ if cls in ('YVBox', 'YHBox', 'YFrame', 'YCheckBoxFrame', 'YAlignment', 'YReplacePoint'):
+ # for containers, required width is max of children's required widths
+ mx = 1
+ for ch in getattr(widget, '_children', []) or []:
+ try:
+ r = _required_width_for(ch)
+ if r > mx:
+ mx = r
+ except Exception:
+ pass
+ return mx
+ except Exception:
+ pass
+ # fallback to minimal reserved
+ try:
+ return max(1, _curses_recursive_min_width(widget))
+ except Exception:
+ return 1
+
+ # try to satisfy required widths by borrowing from others
+ for i, child in enumerate(self._children):
+ if child.visible() is False:
+ continue
+ req = _required_width_for(child)
+ if widths[i] < req:
+ need = req - widths[i]
+ # try to reduce other children that are above their minima
+ for j in range(num_children):
+ if j == i:
+ continue
+ avail = widths[j] - min_reserved[j]
+ if avail <= 0:
+ continue
+ take = min(avail, need)
+ widths[j] -= take
+ widths[i] += take
+ need -= take
+ if need <= 0:
+ break
+ # if still need, clamp to available overall (best-effort)
+ if widths[i] < req:
+ # nothing else we can do; leave as-is
+ pass
+
+ # Draw children and pass full container height to stretchable children
+ cx = x
+ for i, child in enumerate(self._children):
+ w = widths[i]
+ if w <= 0 or child.visible() is False:
+ continue
+ # Give full container height to vertically-stretchable children
+ # and to nested VBoxes so their internal layout can use the
+ # available vertical space. Otherwise fall back to the child's
+ # declared minimal height.
+ try:
+ cls = child.widgetClass() if hasattr(child, "widgetClass") else ""
+ except Exception:
+ cls = ""
+ if child.stretchable(YUIDimension.YD_VERT) or cls == "YVBox":
+ ch = height
+ else:
+ ch = min(height, max(1, getattr(child, "_height", 1)))
+ if hasattr(child, "_draw"):
+ child._draw(window, y, cx, w, ch)
+ cx += w
+ if i < num_children - 1:
+ cx += 1
diff --git a/manatools/aui/backends/curses/imagecurses.py b/manatools/aui/backends/curses/imagecurses.py
new file mode 100644
index 0000000..908925f
--- /dev/null
+++ b/manatools/aui/backends/curses/imagecurses.py
@@ -0,0 +1,134 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+"""
+Python manatools.aui.backends.curses contains curses backend for YImage
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.curses
+"""
+import curses
+import logging
+import os
+from ...yui_common import *
+
+# Module-level logger for image curses backend
+_mod_logger = logging.getLogger("manatools.aui.curses.image.module")
+if not logging.getLogger().handlers:
+ _h = logging.StreamHandler()
+ _h.setFormatter(logging.Formatter("%(asctime)s %(name)s %(levelname)s: %(message)s"))
+ _mod_logger.addHandler(_h)
+ _mod_logger.setLevel(logging.INFO)
+
+
+class YImageCurses(YWidget):
+ def __init__(self, parent=None, imageFileName=""):
+ super().__init__(parent)
+ self._imageFileName = imageFileName
+ self._auto_scale = False
+ self._zero_size = {YUIDimension.YD_HORIZ: False, YUIDimension.YD_VERT: False}
+ self._height = 3
+ self._width = 10
+ self._logger = logging.getLogger(f"manatools.aui.ncurses.{self.__class__.__name__}")
+ if not self._logger.handlers and not logging.getLogger().handlers:
+ for h in _mod_logger.handlers:
+ self._logger.addHandler(h)
+ self._logger.debug("%s.__init__ file=%s", self.__class__.__name__, imageFileName)
+
+ def widgetClass(self):
+ return "YImage"
+
+ def imageFileName(self):
+ return self._imageFileName
+
+ def setImage(self, imageFileName):
+ try:
+ self._imageFileName = imageFileName
+ except Exception:
+ self._logger.exception("setImage failed")
+
+ def autoScale(self):
+ return bool(self._auto_scale)
+
+ def setAutoScale(self, on=True):
+ try:
+ self._auto_scale = bool(on)
+ except Exception:
+ self._logger.exception("setAutoScale failed")
+
+ def hasZeroSize(self, dim):
+ return bool(self._zero_size.get(dim, False))
+
+ def setZeroSize(self, dim, zeroSize=True):
+ self._zero_size[dim] = bool(zeroSize)
+
+ def _create_backend_widget(self):
+ try:
+ self._backend_widget = self
+ # nothing to create for curses; drawing uses _draw
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception:
+ self._logger.exception("_create_backend_widget failed")
+
+ def _draw(self, window, y, x, width, height):
+ if self._visible is False:
+ return
+ try:
+ if width <= 0 or height <= 0:
+ return
+ # draw a box using box-drawing unicode characters
+ try:
+ horiz = '═'
+ vert = '║'
+ tl = '╔'
+ tr = '╗'
+ bl = '╚'
+ br = '╝'
+ except Exception:
+ horiz = '-'
+ vert = '|'
+ tl = '+'
+ tr = '+'
+ bl = '+'
+ br = '+'
+
+ # top border
+ try:
+ window.addstr(y, x, tl + horiz * max(0, width - 2) + tr)
+ except curses.error:
+ pass
+ # middle
+ for row in range(1, max(0, height - 1)):
+ try:
+ window.addstr(y + row, x, vert)
+ # fill space
+ try:
+ window.addstr(y + row, x + 1, ' ' * max(0, width - 2))
+ except curses.error:
+ pass
+ window.addstr(y + row, x + max(0, width - 1), vert)
+ except curses.error:
+ pass
+ # bottom border
+ if height >= 2:
+ try:
+ window.addstr(y + max(0, height - 1), x, bl + horiz * max(0, width - 2) + br)
+ except curses.error:
+ pass
+
+ # show filename centered (single line) if space
+ if self._imageFileName:
+ fname = os.path.basename(self._imageFileName)
+ line = f" {fname} "
+ if len(line) > max(0, width - 2):
+ line = line[:max(0, width - 5)] + '...'
+ try:
+ cx = x + max(1, (width - len(line)) // 2)
+ cy = y + max(1, height // 2)
+ window.addstr(cy, cx, line)
+ except curses.error:
+ pass
+ except Exception:
+ self._logger.exception("_draw failed")
diff --git a/manatools/aui/backends/curses/inputfieldcurses.py b/manatools/aui/backends/curses/inputfieldcurses.py
new file mode 100644
index 0000000..60bd0fc
--- /dev/null
+++ b/manatools/aui/backends/curses/inputfieldcurses.py
@@ -0,0 +1,214 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.curses contains all curses backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.curses
+'''
+import curses
+import curses.ascii
+import sys
+import os
+import time
+import logging
+from ...yui_common import *
+
+# Module-level logger for inputfield curses backend
+_mod_logger = logging.getLogger("manatools.aui.curses.inputfield.module")
+if not logging.getLogger().handlers:
+ _h = logging.StreamHandler()
+ _h.setFormatter(logging.Formatter("%(asctime)s %(name)s %(levelname)s: %(message)s"))
+ _mod_logger.addHandler(_h)
+ _mod_logger.setLevel(logging.INFO)
+
+
+class YInputFieldCurses(YWidget):
+ def __init__(self, parent=None, label="", password_mode=False):
+ super().__init__(parent)
+ self._label = label
+ self._value = ""
+ self._password_mode = password_mode
+ self._cursor_pos = 0
+ self._focused = False
+ self._can_focus = True
+ # one row for field + optional label row on top
+ self._height = 2 if self._label else 1
+ self._x = 0
+ self._y = 0
+ # per-instance logger
+ self._logger = logging.getLogger(f"manatools.aui.ncurses.{self.__class__.__name__}")
+ if not self._logger.handlers and not logging.getLogger().handlers:
+ for h in _mod_logger.handlers:
+ self._logger.addHandler(h)
+ self._logger.debug("%s.__init__ label=%s password_mode=%s", self.__class__.__name__, label, password_mode)
+
+ def widgetClass(self):
+ return "YInputField"
+
+ def value(self):
+ return self._value
+
+ def setValue(self, text):
+ self._value = text
+ self._cursor_pos = len(text)
+
+ def label(self):
+ return self._label
+
+ def setLabel(self, label):
+ self._label = label
+ self._height = 2 if self._label else 1
+
+ def _create_backend_widget(self):
+ try:
+ self._backend_widget = self
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception as e:
+ try:
+ self._logger.error("_create_backend_widget error: %s", e, exc_info=True)
+ except Exception:
+ _mod_logger.error("_create_backend_widget error: %s", e, exc_info=True)
+
+ def _set_backend_enabled(self, enabled):
+ """Enable/disable the input field: affect focusability and focused state."""
+ try:
+ # Save/restore _can_focus when toggling
+ if not hasattr(self, "_saved_can_focus"):
+ self._saved_can_focus = getattr(self, "_can_focus", True)
+ if not enabled:
+ # disable focusable behavior
+ try:
+ self._saved_can_focus = self._can_focus
+ except Exception:
+ self._saved_can_focus = False
+ self._can_focus = False
+ # if currently focused, remove focus
+ if getattr(self, "_focused", False):
+ self._focused = False
+ else:
+ # restore previous focusability
+ try:
+ self._can_focus = bool(getattr(self, "_saved_can_focus", True))
+ except Exception:
+ self._can_focus = True
+ except Exception:
+ pass
+
+ def _draw(self, window, y, x, width, height):
+ if self._visible is False:
+ return
+ try:
+ self._x = x
+ self._y = y
+ line = y
+ # Draw label on its own row above field
+ if self._label:
+ label_text = str(self._label)
+ lbl_attr = curses.A_BOLD if getattr(self, '_is_heading', False) else curses.A_NORMAL
+ if not self.isEnabled():
+ lbl_attr |= curses.A_DIM
+ window.addstr(line, x, label_text[:max(0, width)], lbl_attr)
+ line += 1
+
+ # Effective width respecting horizontal stretch
+ desired_w = self.minWidth()
+ eff_width = width if self.stretchable(YUIDimension.YD_HORIZ) else min(width, desired_w)
+ if eff_width <= 0:
+ return
+
+ # Prepare display value
+ if self._password_mode and self._value:
+ display_value = '*' * len(self._value)
+ else:
+ display_value = self._value
+
+ # Handle scrolling for long values
+ if len(display_value) > eff_width:
+ if self._cursor_pos >= eff_width:
+ start_pos = self._cursor_pos - eff_width + 1
+ display_value = display_value[start_pos:start_pos + eff_width]
+ else:
+ display_value = display_value[:eff_width]
+
+ # Draw input field background
+ if not self.isEnabled():
+ attr = curses.A_DIM
+ else:
+ attr = curses.A_REVERSE if self._focused else curses.A_NORMAL
+
+ field_bg = ' ' * eff_width
+ window.addstr(line, x, field_bg, attr)
+
+ # Draw text
+ if display_value:
+ window.addstr(line, x, display_value, attr)
+
+ # Show cursor if focused and enabled
+ if self._focused and self.isEnabled():
+ cursor_display_pos = min(self._cursor_pos, eff_width - 1)
+ if cursor_display_pos < len(display_value):
+ window.chgat(line, x + cursor_display_pos, 1, curses.A_REVERSE | curses.A_BOLD)
+ except curses.error as e:
+ try:
+ self._logger.error("_draw curses.error: %s", e, exc_info=True)
+ except Exception:
+ _mod_logger.error("_draw curses.error: %s", e, exc_info=True)
+
+ def _handle_key(self, key):
+ if not self._focused or not self.isEnabled() or not self.visible():
+ return False
+
+ handled = True
+
+ if key == curses.KEY_BACKSPACE or key == 127 or key == 8:
+ if self._cursor_pos > 0:
+ self._value = self._value[:self._cursor_pos-1] + self._value[self._cursor_pos:]
+ self._cursor_pos -= 1
+ elif key == curses.KEY_DC: # Delete key
+ if self._cursor_pos < len(self._value):
+ self._value = self._value[:self._cursor_pos] + self._value[self._cursor_pos+1:]
+ elif key == curses.KEY_LEFT:
+ if self._cursor_pos > 0:
+ self._cursor_pos -= 1
+ elif key == curses.KEY_RIGHT:
+ if self._cursor_pos < len(self._value):
+ self._cursor_pos += 1
+ elif key == curses.KEY_HOME:
+ self._cursor_pos = 0
+ elif key == curses.KEY_END:
+ self._cursor_pos = len(self._value)
+ elif 32 <= key <= 126: # Printable characters
+ self._value = self._value[:self._cursor_pos] + chr(key) + self._value[self._cursor_pos:]
+ self._cursor_pos += 1
+ # Post ValueChanged immediately
+ try:
+ dlg = self.findDialog()
+ if dlg is not None and self.notify():
+ dlg._post_event(YWidgetEvent(self, YEventReason.ValueChanged))
+ except Exception:
+ pass
+ else:
+ handled = False
+
+ return handled
+
+ def minWidth(self):
+ """Preferred minimal width in columns when not horizontally stretchable."""
+ try:
+ return 20
+ except Exception:
+ return 20
+
+ def _desired_height_for_width(self, width: int) -> int:
+ try:
+ return max(1, 2 if self._label else 1)
+ except Exception:
+ return 1
+
+ def setVisible(self, visible=True):
+ super().setVisible(visible)
+ self._can_focus = visible
\ No newline at end of file
diff --git a/manatools/aui/backends/curses/intfieldcurses.py b/manatools/aui/backends/curses/intfieldcurses.py
new file mode 100644
index 0000000..6de3d69
--- /dev/null
+++ b/manatools/aui/backends/curses/intfieldcurses.py
@@ -0,0 +1,280 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.curses contains all curses backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.curses
+'''
+import curses
+import logging
+from ...yui_common import *
+
+_mod_logger = logging.getLogger("manatools.aui.curses.intfield.module")
+if not logging.getLogger().handlers:
+ _h = logging.StreamHandler()
+ _h.setFormatter(logging.Formatter("%(asctime)s %(name)s %(levelname)s: %(message)s"))
+ _mod_logger.addHandler(_h)
+ _mod_logger.setLevel(logging.INFO)
+
+
+class YIntFieldCurses(YWidget):
+ def __init__(self, parent=None, label="", minValue=0, maxValue=100, initialValue=0):
+ super().__init__(parent)
+ self._label = label
+ self._min = int(minValue)
+ self._max = int(maxValue)
+ self._value = int(initialValue)
+ # height: 2 lines if label present (label above control), else 1
+ self._height = 2 if bool(self._label) else 1
+ self._focused = False
+ self._can_focus = True
+ # editing buffer when user types numbers
+ self._editing = False
+ self._edit_buffer = ""
+ self._logger = logging.getLogger(f"manatools.aui.ncurses.{self.__class__.__name__}")
+ if not self._logger.handlers and _mod_logger.handlers:
+ for h in _mod_logger.handlers:
+ self._logger.addHandler(h)
+
+ def widgetClass(self):
+ return "YIntField"
+
+ def value(self):
+ return int(self._value)
+
+ def setValue(self, val):
+ try:
+ v = int(val)
+ except Exception:
+ return
+ if v < self._min:
+ v = self._min
+ if v > self._max:
+ v = self._max
+ self._value = v
+ # reset edit buffer when value programmatically changed
+ try:
+ self._editing = False
+ self._edit_buffer = ""
+ except Exception:
+ pass
+
+ def label(self):
+ return self._label
+
+ def setLabel(self, label):
+ self._label = label
+ try:
+ self._height = 2 if bool(self._label) else 1
+ except Exception:
+ pass
+
+ def _create_backend_widget(self):
+ try:
+ self._backend_widget = self
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception as e:
+ try:
+ self._logger.exception("Error creating curses IntField backend: %s", e)
+ except Exception:
+ pass
+
+ def _set_backend_enabled(self, enabled):
+ try:
+ # curses backend: affect focusability if needed
+ if not enabled:
+ self._can_focus = False
+ except Exception:
+ pass
+
+ def _draw(self, window, y, x, width, height):
+ if self._visible is False:
+ return
+ try:
+ # Draw label on the first line, control on the second line (if available)
+ line_label = y
+ line_ctrl = y if height <= 1 else y + 1
+
+ # Draw label (if present) on its line
+ if self._label:
+ try:
+ lbl_txt = str(self._label) + ':'
+ # truncate if necessary
+ lbl_out = lbl_txt[:max(0, width)]
+ window.addstr(line_label, x, lbl_out)
+ except Exception:
+ pass
+
+ # Remaining width for the control
+ ctrl_x = x
+ ctrl_width = max(1, width)
+
+ # Format control as: ↓ ↑
+ try:
+ up_ch = '↑'
+ down_ch = '↓'
+ except Exception:
+ up_ch = '^'
+ down_ch = 'v'
+
+ # hide arrows when at limits: show space to preserve layout
+ try:
+ if self._value >= self._max:
+ up_ch = ' '
+ if self._value <= self._min:
+ down_ch = ' '
+ except Exception:
+ pass
+
+ # if editing, show the edit buffer; otherwise show committed value
+ num_s = self._edit_buffer if getattr(self, '_editing', False) and self._edit_buffer != None and self._edit_buffer != "" else str(self._value)
+ inner_width = max(1, ctrl_width - 4)
+ if len(num_s) > inner_width:
+ num_s = num_s[-inner_width:]
+
+ pad_left = max(0, (inner_width - len(num_s)) // 2)
+ pad_right = inner_width - len(num_s) - pad_left
+ display = f"{down_ch} " + (' ' * pad_left) + num_s + (' ' * pad_right) + f" {up_ch}"
+
+ if not self.isEnabled():
+ attr = curses.A_DIM
+ else:
+ attr = curses.A_REVERSE if getattr(self, '_focused', False) else curses.A_NORMAL
+
+ try:
+ window.addstr(line_ctrl, ctrl_x, display[:ctrl_width], attr)
+ except Exception:
+ try:
+ window.addstr(line_ctrl, ctrl_x, num_s[:ctrl_width], attr)
+ except Exception:
+ pass
+ except curses.error as e:
+ try:
+ self._logger.error("_draw curses.error: %s", e, exc_info=True)
+ except Exception:
+ pass
+
+ def _handle_key(self, key):
+ """Handle keys for focusable spin-like behaviour: up/down to change value."""
+ if not getattr(self, '_focused', False) or not self.isEnabled() or not self.visible():
+ return False
+ try:
+ # If currently editing, handle editing keys
+ if getattr(self, '_editing', False):
+ # Enter -> commit
+ if key == ord('\n') or key == 10 or key == 13:
+ try:
+ val = int(self._edit_buffer)
+ # clamp
+ if val < self._min:
+ val = self._min
+ if val > self._max:
+ val = self._max
+ old = self._value
+ self._value = val
+ self._editing = False
+ self._edit_buffer = ""
+ # redraw
+ dlg = self.findDialog()
+ if dlg is not None:
+ try:
+ dlg._last_draw_time = 0
+ except Exception:
+ pass
+ try:
+ if self.notify():
+ dlg._post_event(YWidgetEvent(self, YEventReason.ValueChanged))
+ except Exception:
+ pass
+ except Exception:
+ # invalid buffer: cancel edit and keep previous value
+ self._editing = False
+ self._edit_buffer = ""
+ return True
+ # ESC -> cancel edit
+ if key == 27:
+ self._editing = False
+ self._edit_buffer = ""
+ return True
+ # Backspace/delete
+ if key in (curses.KEY_BACKSPACE, 127, 8):
+ try:
+ if self._edit_buffer:
+ self._edit_buffer = self._edit_buffer[:-1]
+ except Exception:
+ pass
+ return True
+ # digits or leading minus
+ if 48 <= key <= 57 or key == ord('-'):
+ ch = chr(key)
+ try:
+ # prevent multiple leading zeros weirdness; accept minus only at start
+ if ch == '-' and self._edit_buffer == "":
+ self._edit_buffer = ch
+ elif ch.isdigit():
+ self._edit_buffer += ch
+ # else ignore
+ except Exception:
+ pass
+ return True
+ # ignore other keys while editing
+ return False
+
+ # Not editing: handle navigation and start-edit keys
+ # Arrow up/down adjust value
+ if key == curses.KEY_UP:
+ if self._value < self._max:
+ self._value += 1
+ # notify and redraw
+ dlg = self.findDialog()
+ if dlg is not None:
+ try:
+ dlg._last_draw_time = 0
+ except Exception:
+ pass
+ try:
+ if self.notify():
+ dlg._post_event(YWidgetEvent(self, YEventReason.ValueChanged))
+ except Exception:
+ pass
+ return True
+ if key == curses.KEY_DOWN:
+ if self._value > self._min:
+ self._value -= 1
+ dlg = self.findDialog()
+ if dlg is not None:
+ try:
+ dlg._last_draw_time = 0
+ except Exception:
+ pass
+ try:
+ if self.notify():
+ dlg._post_event(YWidgetEvent(self, YEventReason.ValueChanged))
+ except Exception:
+ pass
+ return True
+
+ # Start editing on digit or minus
+ if 48 <= key <= 57 or key == ord('-'):
+ try:
+ ch = chr(key)
+ self._editing = True
+ self._edit_buffer = ch if ch != '+' else ''
+ except Exception:
+ self._editing = True
+ self._edit_buffer = ''
+ return True
+
+ except Exception:
+ return False
+
+ return False
+
+ def setVisible(self, visible: bool = True):
+ super().setVisible(visible)
+ # in curses backend visibility controls whether widget can receive focus
+ self._can_focus = bool(visible)
\ No newline at end of file
diff --git a/manatools/aui/backends/curses/labelcurses.py b/manatools/aui/backends/curses/labelcurses.py
new file mode 100644
index 0000000..680e292
--- /dev/null
+++ b/manatools/aui/backends/curses/labelcurses.py
@@ -0,0 +1,172 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.curses contains all curses backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.curses
+'''
+import curses
+import curses.ascii
+import sys
+import os
+import time
+import logging
+import textwrap
+from ...yui_common import *
+
+# Module-level logger for label curses backend
+_mod_logger = logging.getLogger("manatools.aui.curses.label.module")
+if not logging.getLogger().handlers:
+ _h = logging.StreamHandler()
+ _h.setFormatter(logging.Formatter("%(asctime)s %(name)s %(levelname)s: %(message)s"))
+ _mod_logger.addHandler(_h)
+ _mod_logger.setLevel(logging.INFO)
+
+
+class YLabelCurses(YWidget):
+ def __init__(self, parent=None, text="", isHeading=False, isOutputField=False):
+ super().__init__(parent)
+ self._text = text
+ self._is_heading = isHeading
+ self._is_output_field = isOutputField
+ self._auto_wrap = False
+ self._height = 1
+ self._focused = False
+ self._can_focus = False # Labels don't get focus
+ self.setStretchable(YUIDimension.YD_HORIZ, False)
+ self.setStretchable(YUIDimension.YD_VERT, False)
+ # per-instance logger
+ self._logger = logging.getLogger(f"manatools.aui.ncurses.{self.__class__.__name__}")
+ if not self._logger.handlers and not logging.getLogger().handlers:
+ for h in _mod_logger.handlers:
+ self._logger.addHandler(h)
+ self._logger.debug("%s.__init__ text=%s heading=%s", self.__class__.__name__, text, isHeading)
+
+ def widgetClass(self):
+ return "YLabel"
+
+ def text(self):
+ return self._text
+
+ def isHeading(self) -> bool:
+ return bool(self._is_heading)
+
+ def isOutputField(self) -> bool:
+ return bool(self._is_output_field)
+
+ def label(self):
+ return self.text()
+
+ def value(self):
+ return self.text()
+
+ def setText(self, new_text):
+ self._text = new_text
+
+ def setValue(self, newValue):
+ self.setText(newValue)
+
+ def setLabel(self, newLabel):
+ self.setText(newLabel)
+
+ def autoWrap(self) -> bool:
+ return bool(self._auto_wrap)
+
+ def setAutoWrap(self, on: bool = True):
+ self._auto_wrap = bool(on)
+ # no backend widget; wrapping handled in _draw
+ # update cached minimal height for simple cases (explicit newlines)
+ try:
+ if not self._auto_wrap:
+ self._height = max(1, len(self._text.splitlines()) if "\n" in self._text else 1)
+ except Exception:
+ pass
+
+ def _desired_height_for_width(self, width: int) -> int:
+ """Return desired height in rows for given width, considering wrapping/newlines."""
+ try:
+ if width <= 1:
+ return 1
+ if self._auto_wrap:
+ total = 0
+ paragraphs = self._text.splitlines() if self._text is not None else [""]
+ if not paragraphs:
+ paragraphs = [""]
+ for para in paragraphs:
+ if para == "":
+ total += 1
+ else:
+ wrapped = textwrap.wrap(para, width=max(1, width), break_long_words=True, break_on_hyphens=False) or [""]
+ total += len(wrapped)
+ return max(1, total)
+ else:
+ if "\n" in (self._text or ""):
+ return max(1, len(self._text.splitlines()))
+ return 1
+ except Exception:
+ return max(1, getattr(self, "_height", 1))
+
+ def _create_backend_widget(self):
+ try:
+ self._backend_widget = self
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception as e:
+ try:
+ self._logger.error("_create_backend_widget error: %s", e, exc_info=True)
+ except Exception:
+ _mod_logger.error("_create_backend_widget error: %s", e, exc_info=True)
+
+ def _set_backend_enabled(self, enabled):
+ """Enable/disable label: labels are not focusable; just keep enabled state for drawing."""
+ try:
+ # labels don't accept focus; nothing to change except state used by draw
+ # draw() will consult self._enabled from base class
+ pass
+ except Exception:
+ pass
+
+ def _draw(self, window, y, x, width, height):
+ if self._visible is False:
+ return
+ try:
+ attr = 0
+ if self._is_heading:
+ attr |= curses.A_BOLD
+ # dim if disabled
+ if not self.isEnabled():
+ attr |= curses.A_DIM
+ if width > 1:
+ # Build lines to draw. Respect explicit newlines and wrap paragraphs.
+ lines = []
+ if self._auto_wrap:
+ # Wrap each paragraph independently (preserve blank lines)
+ for para in self._text.splitlines():
+ if para == "":
+ lines.append("")
+ else:
+ wrapped_para = textwrap.wrap(para, width=max(1, width), break_long_words=True, break_on_hyphens=False) or [""]
+ lines.extend(wrapped_para)
+ else:
+ # No auto-wrap: respect explicit newlines but do not reflow paragraphs
+ if "\n" in self._text:
+ lines = self._text.splitlines()
+ else:
+ lines = [self._text]
+
+ # Draw only up to available height
+ max_lines = max(1, height)
+ for i, line in enumerate(lines[:max_lines]):
+ try:
+ window.addstr(y + i, x, line[:max(0, width)], attr)
+ except curses.error:
+ pass
+ except curses.error as e:
+ try:
+ self._logger.error("_draw curses.error: %s", e, exc_info=True)
+ except Exception:
+ _mod_logger.error("_draw curses.error: %s", e, exc_info=True)
+
diff --git a/manatools/aui/backends/curses/logviewcurses.py b/manatools/aui/backends/curses/logviewcurses.py
new file mode 100644
index 0000000..c285457
--- /dev/null
+++ b/manatools/aui/backends/curses/logviewcurses.py
@@ -0,0 +1,236 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.curses contains all curses backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.curses
+'''
+import curses
+import logging
+from ...yui_common import *
+
+
+class YLogViewCurses(YWidget):
+ """ncurses backend for YLogView: renders a scrollable output-only log area.
+ - Stores lines with optional retention limit (storedLines==0 means unlimited).
+ - Stretchable horizontally and vertically.
+ """
+ def __init__(self, parent=None, label: str = "", visibleLines: int = 10, storedLines: int = 0):
+ super().__init__(parent)
+ self._logger = logging.getLogger(f"manatools.aui.ncurses.{self.__class__.__name__}")
+ self._label = label or ""
+ self._visible = max(1, int(visibleLines or 10))
+ self._max_lines = max(0, int(storedLines or 0))
+ self._lines = []
+ self._backend_widget = self
+ # focus + scrolling state
+ self._can_focus = True
+ self._focused = False
+ self._scroll_y = 0 # top line index
+ self._scroll_x = 0 # left column index
+ try:
+ self.setStretchable(YUIDimension.YD_HORIZ, True)
+ self.setStretchable(YUIDimension.YD_VERT, True)
+ except Exception:
+ pass
+
+ def widgetClass(self):
+ return "YLogView"
+
+ def label(self) -> str:
+ return self._label
+
+ def setLabel(self, label: str):
+ self._label = label or ""
+
+ def visibleLines(self) -> int:
+ return int(self._visible)
+
+ def setVisibleLines(self, newVisibleLines: int):
+ self._visible = max(1, int(newVisibleLines or 1))
+
+ def maxLines(self) -> int:
+ return int(self._max_lines)
+
+ def setMaxLines(self, newMaxLines: int):
+ self._max_lines = max(0, int(newMaxLines or 0))
+ self._trim_if_needed()
+
+ def logText(self) -> str:
+ return "\n".join(self._lines)
+
+ def setLogText(self, text: str):
+ try:
+ raw = [] if text is None else str(text).splitlines()
+ self._lines = raw
+ self._trim_if_needed()
+ except Exception:
+ self._logger.exception("setLogText failed")
+
+ def lastLine(self) -> str:
+ return self._lines[-1] if self._lines else ""
+
+ def appendLines(self, text: str):
+ try:
+ if text is None:
+ return
+ for ln in str(text).splitlines():
+ self._lines.append(ln)
+ self._trim_if_needed()
+ except Exception:
+ self._logger.exception("appendLines failed")
+
+ def clearText(self):
+ self._lines = []
+ self._scroll_y = 0
+ self._scroll_x = 0
+
+ def lines(self) -> int:
+ return len(self._lines)
+
+ # internals
+ def _trim_if_needed(self):
+ try:
+ if self._max_lines > 0 and len(self._lines) > self._max_lines:
+ self._lines = self._lines[-self._max_lines:]
+ except Exception:
+ self._logger.exception("trim failed")
+
+ # curses drawing
+ def _draw(self, window, y, x, width, height):
+ if self._visible is False:
+ return
+ try:
+ line = y
+ # label
+ label_to_show = self._label if self._label else (self.debugLabel() if hasattr(self, 'debugLabel') else "")
+ if label_to_show:
+ try:
+ window.addstr(line, x, label_to_show[:max(0, width)])
+ except curses.error:
+ pass
+ line += 1
+
+ # compute content area reserving space for scrollbars if needed
+ total_lines = len(self._lines)
+ max_len = 0
+ try:
+ max_len = max((len(s) for s in self._lines)) if self._lines else 0
+ except Exception:
+ max_len = 0
+
+ content_h = max(0, height - (line - y))
+ need_hbar = max_len > width and content_h > 1
+ need_vbar = total_lines > content_h
+
+ # reserve one row/col for scrollbars if needed
+ if need_hbar:
+ content_h -= 1
+ content_w = width - (1 if need_vbar else 0)
+ if content_h <= 0 or content_w <= 0:
+ return
+
+ # clamp scroll offsets
+ max_scroll_y = max(0, total_lines - content_h)
+ if self._scroll_y > max_scroll_y:
+ self._scroll_y = max_scroll_y
+ if self._scroll_y < 0:
+ self._scroll_y = 0
+ max_scroll_x = max(0, max_len - content_w)
+ if self._scroll_x > max_scroll_x:
+ self._scroll_x = max_scroll_x
+ if self._scroll_x < 0:
+ self._scroll_x = 0
+ # remember viewport for key handling
+ self._last_height = content_h
+ self._last_width = content_w
+
+ # draw visible lines with horizontal scroll
+ for i in range(content_h):
+ idx = self._scroll_y + i
+ s = self._lines[idx] if 0 <= idx < total_lines else ""
+ try:
+ window.addstr(line + i, x, (s[self._scroll_x:self._scroll_x + content_w]).ljust(content_w))
+ except curses.error:
+ pass
+
+ # draw vertical scrollbar
+ if need_vbar:
+ bar_x = x + content_w
+ # track
+ for i in range(content_h):
+ try:
+ window.addstr(line + i, bar_x, "│")
+ except curses.error:
+ pass
+ # thumb size and position
+ thumb_h = max(1, int(content_h * content_h / max(1, total_lines)))
+ thumb_y = line + int(self._scroll_y * content_h / max(1, total_lines))
+ for i in range(thumb_h):
+ if thumb_y + i < line + content_h:
+ try:
+ window.addstr(thumb_y + i, bar_x, "█")
+ except curses.error:
+ pass
+
+ # draw horizontal scrollbar
+ if need_hbar:
+ bar_y = line + content_h
+ # track
+ try:
+ window.addstr(bar_y, x, "─" * content_w)
+ except curses.error:
+ pass
+ thumb_w = max(1, int(content_w * content_w / max(1, max_len)))
+ thumb_x = x + int(self._scroll_x * content_w / max(1, max_len))
+ for i in range(thumb_w):
+ if thumb_x + i < x + content_w:
+ try:
+ window.addstr(bar_y, thumb_x + i, "█")
+ except curses.error:
+ pass
+ except curses.error:
+ pass
+
+ def _handle_key(self, key):
+ if not self._focused or not self.isEnabled() or not self.visible():
+ return False
+
+ handled = True
+ # Estimate viewport for scrolling steps; if no layout info, choose 5
+ view_h = getattr(self, "_last_height", 5) or 5
+ view_w = getattr(self, "_last_width", 10) or 10
+
+ if key in (curses.KEY_UP, ord('k')):
+ self._scroll_y = max(0, self._scroll_y - 1)
+ elif key in (curses.KEY_DOWN, ord('j')):
+ self._scroll_y = self._scroll_y + 1
+ elif key == curses.KEY_PPAGE:
+ self._scroll_y = max(0, self._scroll_y - max(1, view_h - 1))
+ elif key == curses.KEY_NPAGE:
+ self._scroll_y = self._scroll_y + max(1, view_h - 1)
+ elif key == curses.KEY_HOME:
+ self._scroll_y = 0
+ elif key == curses.KEY_END:
+ # will be clamped in draw
+ self._scroll_y = 1 << 30
+ elif key in (curses.KEY_LEFT, ord('h')):
+ self._scroll_x = max(0, self._scroll_x - 1)
+ elif key in (curses.KEY_RIGHT, ord('l')):
+ self._scroll_x = self._scroll_x + 1
+ else:
+ handled = False
+
+ return handled
+
+ def _set_backend_enabled(self, enabled):
+ pass
+
+ def setVisible(self, visible: bool = True):
+ super().setVisible(visible)
+ # in curses backend visibility controls whether widget can receive focus
+ self._can_focus = bool(visible)
\ No newline at end of file
diff --git a/manatools/aui/backends/curses/menubarcurses.py b/manatools/aui/backends/curses/menubarcurses.py
new file mode 100644
index 0000000..7cb5274
--- /dev/null
+++ b/manatools/aui/backends/curses/menubarcurses.py
@@ -0,0 +1,659 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+NCurses backend: YMenuBar implementation with keyboard navigation.
+- Left/Right: switch top-level menus when focused
+- Space: expand/collapse current menu
+- Up/Down: navigate items when expanded
+- Enter: activate item and emit YMenuEvent
+'''
+import curses
+import logging
+from ...yui_common import YWidget, YMenuEvent, YMenuItem, YUIDimension
+from .commoncurses import extract_mnemonic, split_mnemonic
+
+
+class YMenuBarCurses(YWidget):
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self._logger = logging.getLogger(f"manatools.aui.ncurses.{self.__class__.__name__}")
+ self._menus = [] # list of YMenuItem (is_menu=True)
+ self._focused = False
+ self._can_focus = True
+ self._expanded = False
+ self._current_menu_index = 0
+ self._current_item_index = 0
+ self._height = 2 # one row for bar, one for dropdown baseline
+ # For popup menu navigation: stack of menus and indices per level
+ self._menu_path = [] # list of YMenuItem for current path (top menu -> submenus)
+ self._menu_indices = [] # list of int current index per level
+ # positions of top-level menu labels on the bar (start x)
+ self._menu_positions = []
+ # scrolling support: offsets per level and max visible rows
+ self._scroll_offsets = [] # list of int per level for first visible item
+ self._visible_rows_max = 8
+ # remember last drawn bar geometry for overlay drawing
+ self._bar_y = 0
+ self._bar_x = 0
+ self._bar_width = 0
+ # Default stretch flags: horizontal True, vertical False
+ try:
+ self.setStretchable(YUIDimension.YD_HORIZ, True)
+ self.setStretchable(YUIDimension.YD_VERT, False)
+ except Exception:
+ pass
+
+ def widgetClass(self):
+ return "YMenuBar"
+
+ def addMenu(self, label: str = "", icon_name: str = "", menu: YMenuItem = None) -> YMenuItem:
+ """Add a top-level menu by label or attach an existing YMenuItem.
+
+ Mirrors the Qt/GTK signatures for parameter consistency.
+ """
+ m=None
+ if menu is not None:
+ if not menu.isMenu():
+ raise ValueError("Provided YMenuItem is not a menu (is_menu=True)")
+ m = menu
+ else:
+ if not label:
+ raise ValueError("Menu label must be provided when no YMenuItem is given")
+ m = YMenuItem(label, icon_name, enabled=True, is_menu=True)
+ self._menus.append(m)
+ return m
+
+ def addItem(self, menu: YMenuItem, label: object, icon_name: str = "", enabled: bool = True) -> YMenuItem:
+ """Add an item to a menu, accepting either a label or an existing YMenuItem.
+
+ If `label` is a YMenuItem instance, attach it to `menu`. Otherwise,
+ create a new item using the label and optional icon.
+ """
+ if isinstance(label, YMenuItem):
+ item = label
+ # attach to parent if not already
+ try:
+ setattr(item, "_parent", menu)
+ except Exception:
+ pass
+ try:
+ menu._children.append(item)
+ except Exception:
+ # fallback: use provided API if available
+ try:
+ menu.addItem(item.label(), item.iconName())
+ item = menu._children[-1]
+ except Exception:
+ raise
+ else:
+ item = menu.addItem(label, icon_name)
+ try:
+ item.setEnabled(enabled)
+ except Exception:
+ pass
+ return item
+
+ def setItemEnabled(self, item: YMenuItem, on: bool = True):
+ item.setEnabled(on)
+ # If current selection becomes disabled while expanded, move to next selectable
+ try:
+ if self._expanded and self._menu_path:
+ cur_menu = self._menu_path[-1]
+ idx = self._menu_indices[-1] if self._menu_indices else 0
+ items = list(cur_menu._children)
+ if 0 <= idx < len(items) and items[idx] == item and not item.enabled():
+ nxt = self._next_selectable_index(items, idx, +1)
+ if nxt is None:
+ nxt = self._next_selectable_index(items, idx, -1)
+ if nxt is not None:
+ self._menu_indices[-1] = nxt
+ except Exception:
+ pass
+
+ def _path_for_item(self, item: YMenuItem) -> str:
+ labels = []
+ cur = item
+ while cur is not None:
+ labels.append(cur.label())
+ cur = getattr(cur, "_parent", None)
+ return "/".join(reversed(labels))
+
+ def _emit_activation(self, item: YMenuItem):
+ try:
+ dlg = self.findDialog()
+ if dlg and self.notify():
+ dlg._post_event(YMenuEvent(item=item, id=self._path_for_item(item)))
+ except Exception:
+ pass
+
+ def _is_separator(self, item: YMenuItem) -> bool:
+ try:
+ return item.isSeparator()
+ except Exception:
+ return False
+
+ def _is_selectable(self, item: YMenuItem) -> bool:
+ try:
+ if self._is_separator(item):
+ return False
+ return bool(item.enabled())
+ except Exception:
+ return False
+
+ def _first_selectable_index(self, items):
+ for i, it in enumerate(items):
+ if self._is_selectable(it):
+ return i
+ return None
+
+ def _next_selectable_index(self, items, start_idx, direction):
+ """Return next selectable index from start_idx stepping by direction; None if none."""
+ if not items:
+ return None
+ if direction > 0:
+ rng = range(start_idx + 1, len(items))
+ else:
+ rng = range(start_idx - 1, -1, -1)
+ for i in rng:
+ it = items[i]
+ if self._is_selectable(it):
+ return i
+ return None
+
+ def _draw(self, window, y, x, width, height):
+ if self._visible is False:
+ return
+ try:
+ # remember bar area
+ self._bar_y = y
+ self._bar_x = x
+ self._bar_width = width
+ # draw menubar on first line
+ bar_attr = curses.A_REVERSE if self._focused else curses.A_NORMAL
+ cx = x
+ # reset menu positions
+ self._menu_positions = []
+ for idx, menu in enumerate(self._menus):
+ mn, mpos, clean = split_mnemonic(menu.label())
+ label = f" {clean} "
+ attr = bar_attr
+ if idx == self._current_menu_index:
+ attr |= curses.A_BOLD
+ try:
+ # record start position for this menu label (keep index alignment)
+ try:
+ if not menu.visible():
+ # keep alignment with None placeholder
+ self._menu_positions.append(None)
+ cx += len(label)
+ if cx >= x + width:
+ break
+ continue
+ except Exception:
+ pass
+ self._menu_positions.append(cx)
+ # draw label with underline for mnemonic
+ maxlen = max(0, width - (cx - x))
+ vis = label[:maxlen]
+ window.addstr(y, cx, vis, attr)
+ if mpos is not None:
+ try:
+ # underline position inside label (account for leading space)
+ ul_x = cx + 1 + mpos
+ if ul_x < cx + len(vis):
+ ch = clean[mpos] if 0 <= mpos < len(clean) else ' '
+ window.addstr(y, ul_x, ch, attr | curses.A_UNDERLINE)
+ except curses.error:
+ pass
+ except curses.error:
+ self._menu_positions.append(cx)
+ pass
+ cx += len(label)
+ if cx >= x + width:
+ break
+ # dropdown area: drawn in _draw_expanded_list by dialog to ensure overlay on top
+ except curses.error:
+ pass
+
+ def _draw_expanded_list(self, window):
+ """Draw expanded popups on top of other widgets (overlay), similar to combobox."""
+ if not self._expanded or not (0 <= self._current_menu_index < len(self._menus)):
+ return
+ try:
+ # Start with top-level menu position, relative to dialog
+ try:
+ if self._current_menu_index < len(self._menu_positions):
+ pos = self._menu_positions[self._current_menu_index]
+ start_x = pos if pos is not None else self._bar_x
+ else:
+ start_x = self._bar_x
+ except Exception:
+ start_x = self._bar_x
+ popup_x = start_x
+ popup_y = self._bar_y + 1
+ # ensure menu_path initialized
+ if not self._menu_path:
+ self._menu_path = [self._menus[self._current_menu_index]]
+ self._menu_indices = [0]
+ self._scroll_offsets = [0]
+ for level, menu in enumerate(self._menu_path):
+ # only visible children
+ items = [it for it in list(menu._children) if getattr(it, 'visible', lambda: True)()]
+ # compute width for this popup
+ max_label = 0
+ for it in items:
+ txt = it.label()
+ if it.isMenu():
+ txt = txt + " ►"
+ if len(txt) > max_label:
+ max_label = len(txt)
+ popup_width = max(10, max_label + 4)
+ # check screen bounds
+ screen_h, screen_w = window.getmaxyx()
+ if popup_x + popup_width >= screen_w:
+ popup_x = max(0, start_x - popup_width)
+ # compute visible rows; draw above if not enough space below
+ available_rows_below = max(1, screen_h - (popup_y) - 1)
+ visible_rows = min(len(items), min(self._visible_rows_max, available_rows_below))
+ if popup_y + visible_rows >= screen_h:
+ popup_y = max(1, self._bar_y - visible_rows)
+ visible_rows = min(len(items), min(self._visible_rows_max, popup_y))
+ # ensure scroll_offsets entry
+ if level >= len(self._scroll_offsets):
+ self._scroll_offsets.append(0)
+ # keep selection visible
+ sel_idx = self._menu_indices[level] if level < len(self._menu_indices) else 0
+ offset = self._scroll_offsets[level]
+ if sel_idx < offset:
+ offset = sel_idx
+ if sel_idx >= offset + visible_rows:
+ offset = max(0, sel_idx - visible_rows + 1)
+ self._scroll_offsets[level] = offset
+ # opaque background
+ try:
+ for i in range(visible_rows):
+ bg = " " * popup_width
+ window.addstr(popup_y + i, popup_x, bg[:popup_width], curses.A_NORMAL)
+ # optional separator line above
+ except curses.error:
+ pass
+ # visible items slice
+ vis_items = items[offset:offset + visible_rows]
+ for i, item in enumerate(vis_items):
+ real_i = offset + i
+ # separators are drawn as a centered line of underscores
+ if self._is_separator(item):
+ try:
+ us_count = max(1, popup_width - 2)
+ pad = max(0, (popup_width - us_count) // 2)
+ sep_text = (" " * pad) + ("–" * us_count)
+ sep_text = sep_text.ljust(popup_width)[:popup_width]
+ window.addstr(popup_y + i, popup_x, sep_text, curses.A_NORMAL)
+ except curses.error:
+ pass
+ continue
+ sel = (self._menu_indices[level] == real_i) if level < len(self._menu_indices) else (i == 0)
+ prefix = "* " if sel else " "
+ mn, mpos, clean = split_mnemonic(item.label())
+ marker = " ►" if item.isMenu() else ""
+ text = prefix + clean + marker
+ attr = curses.A_REVERSE if sel else curses.A_NORMAL
+ if not item.enabled():
+ attr |= curses.A_DIM
+ try:
+ line = text.ljust(popup_width)[:popup_width]
+ window.addstr(popup_y + i, popup_x, line, attr)
+ if mpos is not None:
+ try:
+ ul_x = popup_x + len(prefix) + mpos
+ if ul_x < popup_x + popup_width and (0 <= mpos < len(clean)):
+ window.addstr(popup_y + i, ul_x, clean[mpos], attr | curses.A_UNDERLINE)
+ except curses.error:
+ pass
+ except curses.error:
+ pass
+ # scroll indicators
+ try:
+ if self._scroll_offsets[level] > 0 and visible_rows > 0:
+ window.addstr(popup_y, popup_x + popup_width - 1, "▲")
+ except curses.error:
+ pass
+ try:
+ if self._scroll_offsets[level] + visible_rows < len(items) and visible_rows > 0:
+ window.addstr(popup_y + visible_rows - 1, popup_x + popup_width - 1, "▼")
+ except curses.error:
+ pass
+ # next level to right
+ popup_x = popup_x + popup_width + 1
+ if level + 1 >= len(self._menu_path):
+ break
+ except curses.error:
+ pass
+
+ def _prev_visible_menu_index(self, start_idx):
+ for i in range(start_idx - 1, -1, -1):
+ try:
+ if self._menus[i].visible():
+ return i
+ except Exception:
+ # assume visible if error
+ return i
+ return None
+
+ def _next_visible_menu_index(self, start_idx):
+ for i in range(start_idx + 1, len(self._menus)):
+ try:
+ if self._menus[i].visible():
+ return i
+ except Exception:
+ return i
+ return None
+
+ def rebuildMenus(self):
+ """Rebuild all menus for curses backend.
+
+ There are no native widgets per-menu; reset transient caches and
+ request a dialog redraw to reflect model changes.
+ """
+ try:
+ # reset transient layout caches
+ self._menu_positions = []
+ self._menu_path = []
+ self._menu_indices = []
+ self._scroll_offsets = []
+ # request dialog redraw
+ try:
+ dlg = self.findDialog()
+ if dlg is not None:
+ dlg._last_draw_time = 0
+ except Exception:
+ pass
+ except Exception:
+ try:
+ self._logger.exception("rebuildMenus failed for curses backend")
+ except Exception:
+ pass
+
+ # Backward compatibility shim
+ def rebuildMenu(self, menu: YMenuItem = None):
+ # adjust current menu index if a specific menu becomes invisible
+ try:
+ if menu is not None and hasattr(menu, 'isMenu') and menu.isMenu():
+ try:
+ if not menu.visible():
+ if menu in self._menus and self._menus.index(menu) == self._current_menu_index:
+ nxt = self._next_visible_menu_index(self._current_menu_index)
+ if nxt is None:
+ prv = self._prev_visible_menu_index(self._current_menu_index)
+ if prv is not None:
+ self._current_menu_index = prv
+ else:
+ self._current_menu_index = nxt
+ except Exception:
+ pass
+ except Exception:
+ pass
+ self.rebuildMenus()
+
+ def deleteMenus(self):
+ """Clear all menus and reset transient state, then request redraw."""
+ try:
+ try:
+ self._menus.clear()
+ except Exception:
+ self._menus = []
+ # reset indices and caches
+ self._expanded = False
+ self._current_menu_index = 0
+ self._current_item_index = 0
+ self._menu_positions = []
+ self._menu_path = []
+ self._menu_indices = []
+ self._scroll_offsets = []
+ # request redraw
+ try:
+ dlg = self.findDialog()
+ if dlg is not None:
+ dlg._last_draw_time = 0
+ except Exception:
+ pass
+ except Exception:
+ try:
+ self._logger.exception("deleteMenus failed for curses backend")
+ except Exception:
+ pass
+
+ def _handle_key(self, key):
+ if not self._focused or not self.isEnabled() or not self.visible():
+ return False
+ handled = True
+ if key in (curses.KEY_LEFT, ord('h')):
+ if self._expanded:
+ # go up one level if possible, otherwise move to previous top-level menu
+ if len(self._menu_path) > 1:
+ self._menu_path.pop()
+ self._menu_indices.pop()
+ else:
+ prev = self._prev_visible_menu_index(self._current_menu_index)
+ if prev is not None:
+ self._current_menu_index = prev
+ # reset path to new top menu
+ self._menu_path = [self._menus[self._current_menu_index]]
+ self._menu_indices = [0]
+ self._scroll_offsets = [0]
+ else:
+ prev = self._prev_visible_menu_index(self._current_menu_index)
+ if prev is not None:
+ self._current_menu_index = prev
+ elif key in (curses.KEY_RIGHT, ord('l')):
+ if self._expanded:
+ # try to descend into submenu if selected item is a menu
+ cur_menu = self._menu_path[-1]
+ idx = self._menu_indices[-1] if self._menu_indices else 0
+ items = [it for it in list(cur_menu._children) if getattr(it, 'visible', lambda: True)()]
+ if 0 <= idx < len(items) and items[idx].isMenu():
+ self._menu_path.append(items[idx])
+ self._menu_indices.append(0)
+ else:
+ # otherwise move to next top-level menu
+ nxt = self._next_visible_menu_index(self._current_menu_index)
+ if nxt is not None:
+ self._current_menu_index = nxt
+ self._menu_path = [self._menus[self._current_menu_index]]
+ self._menu_indices = [0]
+ self._scroll_offsets = [0]
+ else:
+ nxt = self._next_visible_menu_index(self._current_menu_index)
+ if nxt is not None:
+ self._current_menu_index = nxt
+ elif key in (ord(' '), curses.KEY_DOWN):
+ # expand
+ if not self._expanded:
+ self._expanded = True
+ # initialize path to current top menu
+ if 0 <= self._current_menu_index < len(self._menus):
+ # ensure current top menu is visible; if not find next visible
+ try:
+ if not self._menus[self._current_menu_index].visible():
+ nxt = self._next_visible_menu_index(self._current_menu_index)
+ if nxt is not None:
+ self._current_menu_index = nxt
+ except Exception:
+ pass
+ self._menu_path = [self._menus[self._current_menu_index]]
+ # start at first selectable item
+ items = list(self._menu_path[0]._children)
+ first = self._first_selectable_index(items)
+ if first is None:
+ # no selectable items -> keep expanded false
+ self._expanded = False
+ self._menu_path = []
+ self._menu_indices = []
+ self._scroll_offsets = []
+ return True
+ self._menu_indices = [first]
+ self._scroll_offsets = [0]
+ else:
+ # move down in current popup
+ cur_idx = self._menu_indices[-1]
+ cur_menu = self._menu_path[-1]
+ items = [it for it in list(cur_menu._children) if getattr(it, 'visible', lambda: True)()]
+ if items:
+ nxt = self._next_selectable_index(items, cur_idx, +1)
+ if nxt is not None:
+ self._menu_indices[-1] = nxt
+ # adjust scroll offset
+ level = len(self._menu_indices) - 1
+ visible_rows = self._visible_rows_max
+ offset = self._scroll_offsets[level] if level < len(self._scroll_offsets) else 0
+ if nxt >= offset + visible_rows:
+ self._scroll_offsets[level] = max(0, nxt - visible_rows + 1)
+ elif key == curses.KEY_UP:
+ if self._expanded:
+ cur_idx = self._menu_indices[-1]
+ cur_menu = self._menu_path[-1]
+ items = list(cur_menu._children)
+ if items:
+ # If at first selectable item on top-level, collapse to menubar
+ first = self._first_selectable_index(items)
+ level = len(self._menu_indices) - 1
+ if level == 0 and first is not None and cur_idx == first:
+ self._expanded = False
+ self._menu_path = []
+ self._menu_indices = []
+ self._scroll_offsets = []
+ return True
+ prev = self._next_selectable_index(items, cur_idx, -1)
+ if prev is not None:
+ self._menu_indices[-1] = prev
+ visible_rows = self._visible_rows_max
+ offset = self._scroll_offsets[level] if level < len(self._scroll_offsets) else 0
+ if prev < offset:
+ self._scroll_offsets[level] = prev
+ elif key == curses.KEY_NPAGE:
+ # page down
+ if self._expanded and self._menu_path:
+ level = len(self._menu_indices) - 1
+ cur_menu = self._menu_path[-1]
+ total = len(cur_menu._children)
+ visible_rows = self._visible_rows_max
+ # advance through selectable items
+ idx = self._menu_indices[-1]
+ items = list(cur_menu._children)
+ steps = visible_rows
+ while steps > 0:
+ nxt = self._next_selectable_index(items, idx, +1)
+ if nxt is None:
+ break
+ idx = nxt
+ steps -= 1
+ self._menu_indices[-1] = idx
+ offset = self._scroll_offsets[level]
+ self._scroll_offsets[level] = min(max(0, total - visible_rows), max(offset, idx - visible_rows + 1))
+ elif key == curses.KEY_PPAGE:
+ # page up
+ if self._expanded and self._menu_path:
+ level = len(self._menu_indices) - 1
+ cur_menu = self._menu_path[-1]
+ visible_rows = self._visible_rows_max
+ idx = self._menu_indices[-1]
+ items = list(cur_menu._children)
+ steps = visible_rows
+ while steps > 0:
+ prv = self._next_selectable_index(items, idx, -1)
+ if prv is None:
+ break
+ idx = prv
+ steps -= 1
+ self._menu_indices[-1] = idx
+ offset = self._scroll_offsets[level]
+ self._scroll_offsets[level] = min(offset, idx)
+ elif key in (curses.KEY_ENTER, 10, 13):
+ if self._expanded:
+ cur_menu = self._menu_path[-1]
+ idx = self._menu_indices[-1]
+ items = [it for it in list(cur_menu._children) if getattr(it, 'visible', lambda: True)()]
+ if 0 <= idx < len(items):
+ item = items[idx]
+ if item.isMenu() and item.enabled():
+ # descend
+ self._menu_path.append(item)
+ self._menu_indices.append(0)
+ self._scroll_offsets.append(0)
+ else:
+ if item.enabled():
+ self._emit_activation(item)
+ # collapse after activation
+ self._expanded = False
+ self._menu_path = []
+ self._menu_indices = []
+ self._scroll_offsets = []
+ elif key in (27, ord('q')):
+ # collapse menus
+ self._expanded = False
+ self._menu_path = []
+ self._menu_indices = []
+ self._scroll_offsets = []
+ else:
+ # mnemonic handling
+ ch = None
+ try:
+ ch = chr(key)
+ except Exception:
+ ch = None
+ if ch is not None and ch.isprintable():
+ letter = ch.lower()
+ if not self._expanded:
+ # jump to top-level menu by mnemonic and expand
+ for idx, menu in enumerate(self._menus):
+ try:
+ if not getattr(menu, 'visible', lambda: True)():
+ continue
+ except Exception:
+ pass
+ m, _ = extract_mnemonic(menu.label())
+ if m and m.lower() == letter:
+ self._current_menu_index = idx
+ # expand this menu
+ self._expanded = True
+ self._menu_path = [self._menus[self._current_menu_index]]
+ # select first selectable or the mnemonic-matching item if present
+ items = list(self._menu_path[0]._children)
+ first = self._first_selectable_index(items)
+ self._menu_indices = [first if first is not None else 0]
+ self._scroll_offsets = [0]
+ return True
+ else:
+ # when expanded: select/activate an item by mnemonic in current popup
+ cur_menu = self._menu_path[-1]
+ items = [it for it in list(cur_menu._children) if getattr(it, 'visible', lambda: True)()]
+ target_idx = None
+ for i, it in enumerate(items):
+ m, _ = extract_mnemonic(it.label())
+ if m and m.lower() == letter and self._is_selectable(it):
+ target_idx = i
+ break
+ if target_idx is not None:
+ self._menu_indices[-1] = target_idx
+ it = items[target_idx]
+ if it.isMenu():
+ # descend into submenu
+ self._menu_path.append(it)
+ self._menu_indices.append(0)
+ self._scroll_offsets.append(0)
+ else:
+ if it.enabled():
+ self._emit_activation(it)
+ # collapse after activation
+ self._expanded = False
+ self._menu_path = []
+ self._menu_indices = []
+ self._scroll_offsets = []
+ return True
+ handled = False
+ return handled
+
+ def setVisible(self, visible: bool = True):
+ super().setVisible(visible)
+ # in curses backend visibility controls whether widget can receive focus
+ self._can_focus = bool(visible)
\ No newline at end of file
diff --git a/manatools/aui/backends/curses/multilineeditcurses.py b/manatools/aui/backends/curses/multilineeditcurses.py
new file mode 100644
index 0000000..79c30da
--- /dev/null
+++ b/manatools/aui/backends/curses/multilineeditcurses.py
@@ -0,0 +1,383 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.curses contains curses backend for YMultiLineEdit
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.curses
+'''
+import curses
+import logging
+from ...yui_common import *
+
+_mod_logger = logging.getLogger("manatools.aui.curses.multiline.module")
+if not logging.getLogger().handlers:
+ _h = logging.StreamHandler()
+ _h.setFormatter(logging.Formatter("%(asctime)s %(name)s %(levelname)s: %(message)s"))
+ _mod_logger.addHandler(_h)
+ _mod_logger.setLevel(logging.INFO)
+
+
+class YMultiLineEditCurses(YWidget):
+ def __init__(self, parent=None, label=""):
+ super().__init__(parent)
+ self._label = label
+ self._lines = [""]
+ self._editing = False
+ self._focused = False
+ self._can_focus = True
+ self._cursor_row = 0
+ self._cursor_col = 0
+ self._scroll_offset = 0 # topmost visible line index
+ # default visible content lines (consistent across backends)
+ self._default_visible_lines = 3
+ # -1 means no input length limit
+ self._input_max_length = -1
+ # desired height (content lines + optional label row)
+ self._height = self._default_visible_lines + (1 if bool(self._label) else 0)
+ self._logger = logging.getLogger(f"manatools.aui.ncurses.{self.__class__.__name__}")
+ if not self._logger.handlers and _mod_logger.handlers:
+ for h in _mod_logger.handlers:
+ self._logger.addHandler(h)
+
+ def widgetClass(self):
+ return "YMultiLineEdit"
+
+ def minWidth(self):
+ """Return minimal preferred width in columns when not horizontally stretchable.
+
+ Heuristic: about 20 characters plus 1 column for a scrollbar when needed.
+ Containers like `YHBoxCurses` use this to allocate space for non-stretchable
+ children.
+ """
+ try:
+ desired_chars = 20
+ # reserve one column for potential scrollbar
+ return int(desired_chars + 1)
+ except Exception:
+ return 21
+
+ def value(self):
+ try:
+ return "\n".join(self._lines)
+ except Exception:
+ return ""
+
+ def setValue(self, text):
+ try:
+ s = str(text) if text is not None else ""
+ except Exception:
+ s = ""
+ try:
+ self._lines = s.split('\n')
+ except Exception:
+ self._lines = [s]
+ self._editing = False
+
+ def label(self):
+ return self._label
+
+ def setLabel(self, label):
+ try:
+ self._label = label
+ self._height = self._default_visible_lines + (1 if bool(self._label) else 0)
+ except Exception:
+ pass
+
+ def setStretchable(self, dim, new_stretch):
+ try:
+ super().setStretchable(dim, new_stretch)
+ except Exception:
+ pass
+ try:
+ if dim == YUIDimension.YD_VERT and not self.stretchable(YUIDimension.YD_VERT):
+ self._height = self._default_visible_lines + (1 if bool(self._label) else 0)
+ except Exception:
+ pass
+
+ def _create_backend_widget(self):
+ try:
+ self._backend_widget = self
+ except Exception as e:
+ try:
+ self._logger.exception("Error creating curses MultiLineEdit backend: %s", e)
+ except Exception:
+ pass
+
+ def _set_backend_enabled(self, enabled):
+ try:
+ # preserve/restore focusability similar to other input widgets
+ if not hasattr(self, "_saved_can_focus"):
+ self._saved_can_focus = getattr(self, "_can_focus", True)
+ if not enabled:
+ try:
+ self._saved_can_focus = self._can_focus
+ except Exception:
+ self._saved_can_focus = False
+ self._can_focus = False
+ self._editing = False
+ self._focused = False
+ else:
+ try:
+ self._can_focus = bool(getattr(self, "_saved_can_focus", True))
+ except Exception:
+ self._can_focus = True
+ except Exception:
+ pass
+
+ def inputMaxLength(self):
+ return int(getattr(self, '_input_max_length', -1))
+
+ def setInputMaxLength(self, numberOfChars):
+ try:
+ self._input_max_length = int(numberOfChars)
+ except Exception:
+ self._input_max_length = -1
+
+ def defaultVisibleLines(self):
+ return int(getattr(self, '_default_visible_lines', 3))
+
+ def setDefaultVisibleLines(self, newVisibleLines):
+ try:
+ self._default_visible_lines = int(newVisibleLines)
+ self._height = self._default_visible_lines + (1 if bool(self._label) else 0)
+ except Exception:
+ pass
+
+ def _desired_height_for_width(self, width: int) -> int:
+ try:
+ # minimal height independent from width: default visible lines + label row
+ return max(1, self._default_visible_lines + (1 if bool(self._label) else 0))
+ except Exception:
+ return max(1, getattr(self, '_height', 1))
+
+ def _draw(self, window, y, x, width, height):
+ if self._visible is False:
+ return
+ try:
+ # Draw label (if present) and up to available lines
+ line = y
+ if self._label:
+ try:
+ lbl_txt = str(self._label) + ':'
+ lbl_out = lbl_txt[:max(0, width)]
+ window.addstr(line, x, lbl_out)
+ except Exception:
+ pass
+ line += 1
+
+ # content area excluding label
+ content_h = max(1, height - (1 if self._label else 0))
+ if content_h <= 0:
+ return
+
+ # Compute effective width respecting horizontal stretch
+ desired_w = self.minWidth()
+ eff_width = width if self.stretchable(YUIDimension.YD_HORIZ) else min(width, desired_w)
+
+ # Reserve 1 column for scrollbar if needed
+ bar_w = 1 if len(self._lines) > content_h else 0
+ content_w = max(1, eff_width - bar_w)
+
+ # Ensure scroll offset keeps cursor visible
+ try:
+ if self._cursor_row < self._scroll_offset:
+ self._scroll_offset = self._cursor_row
+ elif self._cursor_row >= self._scroll_offset + content_h:
+ self._scroll_offset = self._cursor_row - content_h + 1
+ self._scroll_offset = max(0, min(self._scroll_offset, max(0, len(self._lines) - content_h)))
+ except Exception:
+ pass
+
+ # Draw visible lines
+ for i in range(content_h):
+ idx = self._scroll_offset + i
+ out = self._lines[idx] if 0 <= idx < len(self._lines) else ""
+ # clip horizontally
+ clipped = out[:max(0, content_w)]
+ try:
+ attr = curses.A_REVERSE if self._focused else curses.A_NORMAL
+ window.addstr(line + i, x, clipped.ljust(content_w), attr)
+ except Exception:
+ pass
+
+ # Draw cursor if focused and enabled
+ try:
+ if self._focused and self.isEnabled():
+ vis_row = self._cursor_row - self._scroll_offset
+ if 0 <= vis_row < content_h:
+ vis_col = min(max(0, self._cursor_col), content_w - 1)
+ try:
+ window.chgat(line + vis_row, x + vis_col, 1, curses.A_REVERSE | curses.A_BOLD)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ # Draw vertical scrollbar
+ if bar_w == 1:
+ try:
+ for r in range(content_h):
+ window.addch(line + r, x + content_w, '|')
+ if len(self._lines) > content_h:
+ pos = int((self._scroll_offset / max(1, len(self._lines) - content_h)) * (content_h - 1))
+ pos = max(0, min(content_h - 1, pos))
+ window.addch(line + pos, x + content_w, '#')
+ except curses.error:
+ pass
+ except curses.error as e:
+ try:
+ self._logger.error("_draw curses.error: %s", e, exc_info=True)
+ except Exception:
+ pass
+
+ def _handle_key(self, key):
+ """Multiline edit with cursor navigation and scrolling.
+
+ - Arrow keys move the cursor; Home/End to line ends.
+ - Enter inserts a new line; Backspace/Delete remove chars across lines.
+ - Printable characters insert at cursor.
+ - PageUp/PageDown scroll the view.
+ - Ctrl-S posts ValueChanged (explicit commit), but we also post on each edit.
+ """
+ if not getattr(self, '_focused', False) or not self.isEnabled() or not self.visible():
+ return False
+
+ try:
+ total_lines = len(self._lines) if self._lines else 1
+ if self._cursor_row >= total_lines:
+ self._cursor_row = max(0, total_lines - 1)
+ if self._cursor_col > len(self._lines[self._cursor_row]):
+ self._cursor_col = len(self._lines[self._cursor_row])
+
+ edited = False
+
+ # Navigation
+ if key == curses.KEY_UP:
+ if self._cursor_row > 0:
+ self._cursor_row -= 1
+ self._cursor_col = min(self._cursor_col, len(self._lines[self._cursor_row]))
+ return True
+ if key == curses.KEY_DOWN:
+ if self._cursor_row + 1 < len(self._lines):
+ self._cursor_row += 1
+ self._cursor_col = min(self._cursor_col, len(self._lines[self._cursor_row]))
+ return True
+ if key == curses.KEY_LEFT:
+ if self._cursor_col > 0:
+ self._cursor_col -= 1
+ elif self._cursor_row > 0:
+ self._cursor_row -= 1
+ self._cursor_col = len(self._lines[self._cursor_row])
+ return True
+ if key == curses.KEY_RIGHT:
+ line_len = len(self._lines[self._cursor_row])
+ if self._cursor_col < line_len:
+ self._cursor_col += 1
+ elif self._cursor_row + 1 < len(self._lines):
+ self._cursor_row += 1
+ self._cursor_col = 0
+ return True
+ if key == curses.KEY_HOME:
+ self._cursor_col = 0
+ return True
+ if key == curses.KEY_END:
+ self._cursor_col = len(self._lines[self._cursor_row])
+ return True
+ if key == curses.KEY_PPAGE: # PageUp
+ self._scroll_offset = max(0, self._scroll_offset - max(1, self._default_visible_lines))
+ return True
+ if key == curses.KEY_NPAGE: # PageDown
+ max_off = max(0, len(self._lines) - max(1, self._default_visible_lines))
+ self._scroll_offset = min(max_off, self._scroll_offset + max(1, self._default_visible_lines))
+ return True
+
+ # Commit (explicit)
+ if key == 19: # Ctrl-S
+ dlg = self.findDialog()
+ if dlg is not None and self.notify():
+ try:
+ dlg._post_event(YWidgetEvent(self, YEventReason.ValueChanged))
+ except Exception:
+ pass
+ return True
+
+ # Cancel editing (ESC) - keep content
+ if key == 27:
+ self._editing = False
+ return True
+
+ # Backspace
+ if key in (curses.KEY_BACKSPACE, 127, 8):
+ if self._cursor_col > 0:
+ line = self._lines[self._cursor_row]
+ self._lines[self._cursor_row] = line[:self._cursor_col - 1] + line[self._cursor_col:]
+ self._cursor_col -= 1
+ edited = True
+ else:
+ if self._cursor_row > 0:
+ # merge with previous line
+ prev_len = len(self._lines[self._cursor_row - 1])
+ self._lines[self._cursor_row - 1] += self._lines[self._cursor_row]
+ self._lines.pop(self._cursor_row)
+ self._cursor_row -= 1
+ self._cursor_col = prev_len
+ edited = True
+
+ # Delete
+ elif key == curses.KEY_DC:
+ line = self._lines[self._cursor_row]
+ if self._cursor_col < len(line):
+ self._lines[self._cursor_row] = line[:self._cursor_col] + line[self._cursor_col + 1:]
+ edited = True
+ else:
+ # merge with next line
+ if self._cursor_row + 1 < len(self._lines):
+ self._lines[self._cursor_row] += self._lines[self._cursor_row + 1]
+ self._lines.pop(self._cursor_row + 1)
+ edited = True
+
+ # Enter -> new line
+ elif key in (10, ord('\n')):
+ line = self._lines[self._cursor_row]
+ left, right = line[:self._cursor_col], line[self._cursor_col:]
+ self._lines[self._cursor_row] = left
+ self._lines.insert(self._cursor_row + 1, right)
+ self._cursor_row += 1
+ self._cursor_col = 0
+ edited = True
+
+ # Printable char
+ elif 32 <= key <= 126:
+ ch = chr(key)
+ line = self._lines[self._cursor_row]
+ # enforce per-line input max length
+ if getattr(self, '_input_max_length', -1) >= 0 and len(line) >= self._input_max_length:
+ return True
+ self._lines[self._cursor_row] = line[:self._cursor_col] + ch + line[self._cursor_col:]
+ self._cursor_col += 1
+ edited = True
+ else:
+ return False
+
+ if edited:
+ # post value-changed on each edit
+ dlg = self.findDialog()
+ if dlg is not None and self.notify():
+ try:
+ dlg._post_event(YWidgetEvent(self, YEventReason.ValueChanged))
+ except Exception:
+ pass
+ return True
+
+ return True
+ except Exception:
+ return False
+
+ def setVisible(self, visible: bool = True):
+ super().setVisible(visible)
+ # in curses backend visibility controls whether widget can receive focus
+ self._can_focus = bool(visible)
\ No newline at end of file
diff --git a/manatools/aui/backends/curses/panedcurses.py b/manatools/aui/backends/curses/panedcurses.py
new file mode 100644
index 0000000..0decfa4
--- /dev/null
+++ b/manatools/aui/backends/curses/panedcurses.py
@@ -0,0 +1,212 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.curses contains curses backend for YMultiLineEdit
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.curses
+
+YPanedCurses: Curses Paned widget.
+
+- Splits area horizontally or vertically into up to two panes.
+- Draws only visible children; hidden ones are not drawn and do not receive keys.
+- Special keys when a child has focus:
+ '+' => make focused child visible
+ '-' => make focused child invisible
+- Hidden children remain focus-reachable via parent navigation so '+' can restore visibility.
+'''
+
+import curses
+import logging
+from ...yui_common import YWidget, YUIDimension
+from .commoncurses import _curses_recursive_min_height
+
+class YPanedCurses(YWidget):
+ """
+ ncurses implementation of YPaned with two child panes.
+ """
+
+ def __init__(self, parent=None, dimension: YUIDimension = YUIDimension.YD_HORIZ):
+ super().__init__(parent)
+ self._logger = logging.getLogger("manatools.aui.curses.YPanedCurses")
+ self._orientation = dimension
+ self._backend_widget = self # self-managed drawing
+ self._hidden = [False, False] # visibility flags per child index
+ # Minimum height will be computed from children
+ self._height = 1
+ self._logger.debug("YPanedCurses created orientation=%s", "H" if dimension == YUIDimension.YD_HORIZ else "V")
+
+ def widgetClass(self):
+ return "YPaned"
+
+ def _create_backend_widget(self):
+ """
+ No external backend widget required; drawing is managed in _draw.
+ """
+ self._backend_widget = self
+ self._logger.debug("_create_backend_widget: using self-managed backend")
+
+ def _recompute_min_height(self):
+ """Compute minimal height for this horizontal box as the tallest child's minimum."""
+ try:
+ if not self._children:
+ self._height = 1
+ return
+ self._height = max(1, max(_curses_recursive_min_height(c) for c in self._children))
+ except Exception:
+ self._height = 1
+
+ def addChild(self, child):
+ """
+ Add a child to the paned container. First child is 'start', second is 'end'.
+ """
+ if len(self._children) == 2:
+ self._logger.warning("YPanedGtk can only manage two children; ignoring extra child")
+ return
+ super().addChild(child)
+ self._recompute_min_height()
+ self._logger.debug("Added start child: %s", getattr(child, "debugLabel", lambda: repr(child))())
+
+ def setStartChild(self, child: YWidget):
+ """Explicitly set the start child."""
+ try:
+ self._children[0] = child
+ self._hidden[0] = False
+ self._logger.debug("setStartChild: %s", getattr(child, "debugLabel", lambda: repr(child))())
+ except Exception as e:
+ self._logger.error("setStartChild error: %s", e, exc_info=True)
+
+ def setEndChild(self, child: YWidget):
+ """Explicitly set the end child."""
+ try:
+ self._children[1] = child
+ self._hidden[1] = False
+ self._logger.debug("setEndChild: %s", getattr(child, "debugLabel", lambda: repr(child))())
+ except Exception as e:
+ self._logger.error("setEndChild error: %s", e, exc_info=True)
+
+ def _get_focused_child_index(self):
+ """
+ Try to detect which child currently has focus. Fallback to first present child.
+ """
+ try:
+ for idx, ch in enumerate(self._children):
+ if ch is None:
+ continue
+ # Heuristic focus detection compatible with existing widgets
+ if hasattr(ch, "hasFocus") and callable(getattr(ch, "hasFocus")):
+ if ch.hasFocus(): # type: ignore[attr-defined]
+ return idx
+ elif getattr(ch, "_focused", False):
+ return idx
+ except Exception as e:
+ self._logger.debug("focus detection failed: %s", e)
+ # fallback
+ return 0 if self._children[0] is not None else 1
+
+ def _set_child_visible(self, idx: int, visible: bool):
+ """
+ Toggle child visibility without touching the child's own setVisible(),
+ so it stays focus-reachable even when hidden.
+ """
+ try:
+ if idx not in (0, 1):
+ return
+ if self._children[idx] is None:
+ return
+ self._hidden[idx] = not bool(visible)
+ self._children[idx].setVisible(visible)
+ self._logger.debug("Child %d visibility -> %s", idx, "visible" if visible else "hidden")
+ except Exception as e:
+ self._logger.error("_set_child_visible error: %s", e, exc_info=True)
+
+ def _draw(self, window, y, x, width, height):
+ """
+ Draw visible children split by orientation.
+ - If both visible: split area evenly.
+ - If one visible: give full area to that child.
+ - Hidden children are not drawn.
+ """
+ try:
+ start, end = self._children
+ start_vis = (start is not None) and (not self._hidden[0])
+ end_vis = (end is not None) and (not self._hidden[1])
+
+ # If neither is visible, fill with spaces and return
+ if not start_vis and not end_vis:
+ try:
+ for r in range(max(0, height)):
+ window.addstr(y + r, x, " " * max(0, width))
+ except curses.error:
+ pass
+ return
+
+ if self._orientation == YUIDimension.YD_HORIZ:
+ # horizontal split: left | right
+ if start_vis and end_vis:
+ w1 = max(0, width // 2)
+ w2 = max(0, width - w1)
+ if start is not None:
+ start._draw(window, y, x, w1, height)
+ if end is not None:
+ end._draw(window, y, x + w1, w2, height)
+ elif start_vis:
+ if start is not None:
+ start._draw(window, y, x, width, height)
+ else:
+ if end is not None:
+ end._draw(window, y, x, width, height)
+ else:
+ # vertical split: top / bottom
+ if start_vis and end_vis:
+ h1 = max(0, height // 2)
+ h2 = max(0, height - h1)
+ if start is not None:
+ start._draw(window, y, x, width, h1)
+ if end is not None:
+ end._draw(window, y + h1, x, width, h2)
+ elif start_vis:
+ if start is not None:
+ start._draw(window, y, x, width, height)
+ else:
+ if end is not None:
+ end._draw(window, y, x, width, height)
+ except Exception as e:
+ try:
+ self._logger.error("_draw error: %s", e, exc_info=True)
+ except Exception:
+ pass
+
+ def _handle_key(self, key):
+ """
+ Intercept '+' and '-' to toggle visibility of the focused child.
+ - '+' makes focused child visible.
+ - '-' makes focused child hidden.
+ Hidden child does not receive keys until made visible again.
+ """
+ try:
+ idx = self._get_focused_child_index()
+ child = self._children[idx]
+ if key in (ord('+'),):
+ self._set_child_visible(idx, True)
+ return True
+ if key in (ord('-'),):
+ self._set_child_visible(idx, False)
+ return True
+
+ # Delegate key handling to focused child only if visible
+ if child is not None and not self._hidden[idx]:
+ if hasattr(child, "_handle_key"):
+ try:
+ return bool(child._handle_key(key)) # type: ignore[attr-defined]
+ except Exception as e:
+ self._logger.error("child _handle_key error: %s", e, exc_info=True)
+ return False
+ # If hidden (or no child), do not handle other keys
+ return False
+ except Exception as e:
+ self._logger.error("_handle_key error: %s", e, exc_info=True)
+ return False
diff --git a/manatools/aui/backends/curses/progressbarcurses.py b/manatools/aui/backends/curses/progressbarcurses.py
new file mode 100644
index 0000000..afe8006
--- /dev/null
+++ b/manatools/aui/backends/curses/progressbarcurses.py
@@ -0,0 +1,174 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.curses contains all curses backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.curses
+'''
+import curses
+import curses.ascii
+import sys
+import os
+import time
+import logging
+from ...yui_common import *
+
+# Module-level logger for progressbar curses backend
+_mod_logger = logging.getLogger("manatools.aui.curses.progressbar.module")
+if not logging.getLogger().handlers:
+ _h = logging.StreamHandler()
+ _h.setFormatter(logging.Formatter("%(asctime)s %(name)s %(levelname)s: %(message)s"))
+ _mod_logger.addHandler(_h)
+ _mod_logger.setLevel(logging.INFO)
+
+class YProgressBarCurses(YWidget):
+ def __init__(self, parent=None, label="", maxValue=100):
+ super().__init__(parent)
+ self._label = label
+ self._max_value = int(maxValue) if maxValue is not None else 100
+ self._value = 0
+ self._x = 0
+ self._y = 0
+ # progress bar occupies 2 rows when label present, otherwise 1
+ self._height = 2 if self._label else 1
+ self._backend_widget = None
+ #default stretchable in horizontal direction
+ self.setStretchable(YUIDimension.YD_HORIZ, True)
+ self._logger = logging.getLogger(f"manatools.aui.ncurses.{self.__class__.__name__}")
+ if not self._logger.handlers and not logging.getLogger().handlers:
+ for h in _mod_logger.handlers:
+ self._logger.addHandler(h)
+ self._logger.debug("%s.__init__ label=%s maxValue=%s", self.__class__.__name__, label, maxValue)
+
+ def widgetClass(self):
+ return "YProgressBar"
+
+ def label(self):
+ return self._label
+
+ def setLabel(self, newLabel):
+ try:
+ self._label = str(newLabel)
+ # adjust height when label toggles
+ try:
+ self._height = 2 if self._label else 1
+ except Exception:
+ pass
+ # request immediate redraw of parent dialog if present
+ dlg = self.findDialog()
+ if dlg is not None:
+ try:
+ dlg._last_draw_time = 0
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def maxValue(self):
+ return int(self._max_value)
+
+ def value(self):
+ return int(self._value)
+
+ def setValue(self, newValue):
+ try:
+ v = int(newValue)
+ if v < 0:
+ v = 0
+ if v > self._max_value:
+ v = self._max_value
+ self._value = v
+ # request immediate redraw of parent dialog
+ dlg = self.findDialog()
+ if dlg is not None:
+ try:
+ dlg._last_draw_time = 0
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def _create_backend_widget(self):
+ try:
+ # associate placeholder backend widget to self
+ self._backend_widget = self
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception as e:
+ try:
+ self._logger.error("_create_backend_widget error: %s", e, exc_info=True)
+ except Exception:
+ _mod_logger.error("_create_backend_widget error: %s", e, exc_info=True)
+
+ def _draw(self, window, y, x, width, height):
+ if self._visible is False:
+ return
+ try:
+ if width <= 0 or height <= 0:
+ return
+
+ # Determine where to draw label and bar
+ draw_label = bool(self._label)
+ label_y = y if draw_label else None
+ bar_y = y + (1 if draw_label else 0)
+
+ # Draw label (single line) above the bar
+ if draw_label and label_y is not None and width > 0:
+ try:
+ attr = 0
+ if not self.isEnabled():
+ attr |= curses.A_DIM
+ text = str(self._label)[:max(0, width)]
+ window.addstr(label_y, x, text, attr)
+ except curses.error:
+ pass
+
+ # Draw progress bar line
+ try:
+ # Compute fill fraction and percent text
+ maxv = max(1, int(self._max_value))
+ val = max(0, int(self._value))
+ frac = float(val) / float(maxv)
+ fill = int(frac * width)
+
+ # Bar characters
+ filled_char = '='
+ empty_char = ' '
+ bar_str = (filled_char * fill) + (empty_char * max(0, width - fill))
+
+ # Center percentage text
+ perc = int(frac * 100)
+ perc_text = f"{perc}%"
+ pt_len = len(perc_text)
+ pt_x = x + max(0, (width - pt_len) // 2)
+
+ # Attrs
+ bar_attr = curses.A_REVERSE if self.isEnabled() else curses.A_DIM
+ perc_attr = curses.A_BOLD if self.isEnabled() else curses.A_DIM
+
+ # Tooltip positioning
+ self._x = x
+ self._y = bar_y
+ # Draw base bar
+ try:
+ window.addstr(bar_y, x, bar_str[:width], bar_attr)
+ except curses.error:
+ pass
+
+ # Overlay percentage text
+ try:
+ # Ensure percentage fits inside bar
+ if pt_x + pt_len <= x + width:
+ window.addstr(bar_y, pt_x, perc_text, perc_attr)
+ except curses.error:
+ pass
+ except Exception:
+ pass
+ except Exception as e:
+ try:
+ self._logger.error("_draw error: %s", e, exc_info=True)
+ except Exception:
+ _mod_logger.error("_draw error: %s", e, exc_info=True)
diff --git a/manatools/aui/backends/curses/pushbuttoncurses.py b/manatools/aui/backends/curses/pushbuttoncurses.py
new file mode 100644
index 0000000..6a3b134
--- /dev/null
+++ b/manatools/aui/backends/curses/pushbuttoncurses.py
@@ -0,0 +1,216 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.curses contains all curses backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.curses
+'''
+import curses
+import curses.ascii
+import sys
+import os
+import time
+import logging
+from typing import Optional
+from ...yui_common import *
+from .commoncurses import extract_mnemonic, split_mnemonic
+
+# Module-level logger for pushbutton curses backend
+_mod_logger = logging.getLogger("manatools.aui.curses.pushbutton.module")
+if not logging.getLogger().handlers:
+ _h = logging.StreamHandler()
+ _h.setFormatter(logging.Formatter("%(asctime)s %(name)s %(levelname)s: %(message)s"))
+ _mod_logger.addHandler(_h)
+ _mod_logger.setLevel(logging.INFO)
+
+
+class YPushButtonCurses(YWidget):
+ """Curses push button widget with mnemonic handling and default state."""
+ def __init__(self, parent=None, label: str="", icon_name: Optional[str]=None, icon_only: Optional[bool]=False):
+ super().__init__(parent)
+ self._label = label
+ self._focused = False
+ self._can_focus = True
+ self._icon_name = icon_name
+ self._icon_only = bool(icon_only)
+ self._x = 0
+ self._y = 0
+ self._height = 1 # Fixed height - buttons are always one line
+ self._is_default = False
+ self._logger = logging.getLogger(f"manatools.aui.ncurses.{self.__class__.__name__}")
+ # derive mnemonic and cleaned label if present
+ try:
+ self._mnemonic, self._mnemonic_index, self._clean_label = split_mnemonic(self._label)
+ except Exception:
+ self._mnemonic, self._mnemonic_index, self._clean_label = None, None, self._label
+ if not self._logger.handlers and not logging.getLogger().handlers:
+ for h in _mod_logger.handlers:
+ self._logger.addHandler(h)
+ self._logger.debug("%s.__init__ label=%s", self.__class__.__name__, label)
+
+ def widgetClass(self):
+ return "YPushButton"
+
+ def label(self):
+ return self._label
+
+ def setLabel(self, label):
+ self._label = label
+ try:
+ self._mnemonic, self._mnemonic_index, self._clean_label = split_mnemonic(self._label)
+ except Exception:
+ self._mnemonic, self._mnemonic_index, self._clean_label = None, None, self._label
+
+ def setDefault(self, default: bool):
+ """Mark this button as the dialog default (or clear it)."""
+ self._apply_default_state(bool(default), notify_dialog=True)
+
+ def default(self) -> bool:
+ """Return True when this button is the dialog default."""
+ return bool(getattr(self, "_is_default", False))
+
+ def _create_backend_widget(self):
+ try:
+ self._backend_widget = self
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception as e:
+ try:
+ self._logger.error("_create_backend_widget error: %s", e, exc_info=True)
+ except Exception:
+ _mod_logger.error("_create_backend_widget error: %s", e, exc_info=True)
+
+ def _set_backend_enabled(self, enabled):
+ """Enable/disable push button: update focusability and collapse focus if disabling."""
+ try:
+ if not hasattr(self, "_saved_can_focus"):
+ self._saved_can_focus = getattr(self, "_can_focus", True)
+ if not enabled:
+ try:
+ self._saved_can_focus = self._can_focus
+ except Exception:
+ self._saved_can_focus = True
+ self._can_focus = False
+ if getattr(self, "_focused", False):
+ self._focused = False
+ else:
+ try:
+ self._can_focus = bool(getattr(self, "_saved_can_focus", True))
+ except Exception:
+ self._can_focus = True
+ except Exception:
+ pass
+
+ def _draw(self, window, y, x, width, height):
+ if self._visible is False:
+ return
+ try:
+ # Center the button label within available width, show underline for mnemonic
+ clean = getattr(self, "_clean_label", None) or self._label
+ button_text = f"[ {clean} ]"
+ # Determine drawing position and clip text if necessary
+ if width <= 0:
+ return
+ if len(button_text) <= width:
+ text_x = x + max(0, (width - len(button_text)) // 2)
+ draw_text = button_text
+ else:
+ # Not enough space: draw truncated centered/left-aligned text
+ draw_text = button_text[:max(1, width)]
+ text_x = x
+
+ if not self.isEnabled():
+ attr = curses.A_DIM
+ else:
+ attr = curses.A_REVERSE if self._focused else curses.A_NORMAL
+ if self._focused or self._is_default:
+ attr |= curses.A_BOLD
+
+ self._x = text_x
+ self._y = y
+
+ try:
+ window.addstr(y, text_x, draw_text, attr)
+ # underline mnemonic if visible
+ if self._mnemonic_index is not None:
+ underline_pos = 2 + self._mnemonic_index # within "[ ]"
+ if 0 <= underline_pos < len(draw_text):
+ try:
+ window.addstr(y, text_x + underline_pos, draw_text[underline_pos], attr | curses.A_UNDERLINE)
+ except curses.error:
+ pass
+ except curses.error:
+ # Best-effort: if even this fails, ignore
+ pass
+ except curses.error as e:
+ try:
+ self._logger.error("_draw curses.error: %s", e, exc_info=True)
+ except Exception:
+ _mod_logger.error("_draw curses.error: %s", e, exc_info=True)
+
+ def _handle_key(self, key):
+ if not self._focused or not self.isEnabled() or not self.visible():
+ return False
+
+ if key == ord('\n') or key == ord(' '):
+ # Button pressed -> post widget event to containing dialog
+ dlg = self.findDialog()
+ if dlg is not None:
+ try:
+ dlg._post_event(YWidgetEvent(self, YEventReason.Activated))
+ except Exception:
+ try:
+ self._logger.error("_handle_key post event error", exc_info=True)
+ except Exception:
+ _mod_logger.error("_handle_key post event error", exc_info=True)
+ return True
+ # mnemonic letter activates as well when focused
+ try:
+ if self._mnemonic:
+ if key == ord(self._mnemonic) or key == ord(self._mnemonic.upper()):
+ dlg = self.findDialog()
+ if dlg is not None:
+ try:
+ dlg._post_event(YWidgetEvent(self, YEventReason.Activated))
+ except Exception:
+ pass
+ return True
+ except Exception:
+ pass
+ return False
+
+ def setIcon(self, icon_name: str):
+ """Store icon name for curses backend (no graphical icon support)."""
+ try:
+ self._icon_name = icon_name
+ except Exception:
+ pass
+
+ def setVisible(self, visible=True):
+ super().setVisible(visible)
+ self._can_focus = bool(visible)
+
+ def _apply_default_state(self, state: bool, notify_dialog: bool):
+ """Internal helper to store default state and notify dialog if needed."""
+ desired = bool(state)
+ if getattr(self, "_is_default", False) == desired and not notify_dialog:
+ return
+ dlg = self.findDialog() if notify_dialog else None
+ if notify_dialog and dlg is not None:
+ try:
+ if desired:
+ dlg._register_default_button(self)
+ else:
+ dlg._unregister_default_button(self)
+ except Exception:
+ self._logger.exception("Failed to synchronize default button with dialog")
+ self._is_default = desired
+ self._sync_default_visual()
+
+ def _sync_default_visual(self):
+ """Best-effort visual cue for default buttons in curses."""
+ # curses drawing checks _is_default to add bold attribute; nothing else to do here
+ return
\ No newline at end of file
diff --git a/manatools/aui/backends/curses/radiobuttoncurses.py b/manatools/aui/backends/curses/radiobuttoncurses.py
new file mode 100644
index 0000000..47a9463
--- /dev/null
+++ b/manatools/aui/backends/curses/radiobuttoncurses.py
@@ -0,0 +1,200 @@
+#!/usr/bin/env python3
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.curses contains all curses backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.curses
+'''
+import curses
+import curses.ascii
+import sys
+import os
+import time
+import logging
+from ...yui_common import *
+
+# Module-level logger for radiobutton curses backend
+_mod_logger = logging.getLogger("manatools.aui.curses.radiobutton.module")
+if not logging.getLogger().handlers:
+ _h = logging.StreamHandler()
+ _h.setFormatter(logging.Formatter("%(asctime)s %(name)s %(levelname)s: %(message)s"))
+ _mod_logger.addHandler(_h)
+ _mod_logger.setLevel(logging.INFO)
+
+
+class YRadioButtonCurses(YWidget):
+ def __init__(self, parent=None, label="", is_checked=False):
+ super().__init__(parent)
+ self._label = label
+ self._is_checked = bool(is_checked)
+ self._focused = False
+ self._can_focus = True
+ self._height = 1
+ # per-instance logger
+ self._logger = logging.getLogger(f"manatools.aui.ncurses.{self.__class__.__name__}")
+ if not self._logger.handlers and not logging.getLogger().handlers:
+ for h in _mod_logger.handlers:
+ self._logger.addHandler(h)
+ self._logger.debug("%s.__init__ label=%s checked=%s", self.__class__.__name__, label, is_checked)
+
+ def widgetClass(self):
+ return "YRadioButton"
+
+ def value(self):
+ return self._is_checked
+
+ def setValue(self, checked):
+ # Programmatic set: enforce single-selection among siblings when setting True
+ try:
+ self._is_checked = bool(checked)
+ if self._is_checked:
+ # uncheck sibling radio buttons under same parent
+ try:
+ parent = getattr(self, '_parent', None)
+ if parent is not None:
+ for sib in list(getattr(parent, '_children', []) or []):
+ try:
+ if sib is not self and getattr(sib, 'widgetClass', None) and sib.widgetClass() == 'YRadioButton':
+ if getattr(sib, '_is_checked', False):
+ try:
+ sib._is_checked = False
+ except Exception:
+ pass
+ except Exception:
+ pass
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def label(self):
+ return self._label
+
+ def _create_backend_widget(self):
+ try:
+ # associate placeholder backend widget
+ self._backend_widget = self
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception as e:
+ try:
+ self._logger.error("_create_backend_widget error: %s", e, exc_info=True)
+ except Exception:
+ _mod_logger.error("_create_backend_widget error: %s", e, exc_info=True)
+
+ def _set_backend_enabled(self, enabled):
+ """Enable/disable radio button: update focusability and collapse focus if disabling."""
+ try:
+ if not hasattr(self, "_saved_can_focus"):
+ self._saved_can_focus = getattr(self, "_can_focus", True)
+ if not enabled:
+ try:
+ self._saved_can_focus = self._can_focus
+ except Exception:
+ self._saved_can_focus = True
+ self._can_focus = False
+ if getattr(self, "_focused", False):
+ self._focused = False
+ else:
+ try:
+ self._can_focus = bool(getattr(self, "_saved_can_focus", True))
+ except Exception:
+ self._can_focus = True
+ except Exception:
+ pass
+
+ def _draw(self, window, y, x, width, height):
+ if self._visible is False:
+ return
+ try:
+ # Use parentheses style for radio: '(*)' if checked else '( )'
+ radio_symbol = "(*)" if self._is_checked else "( )"
+ text = f"{radio_symbol} {self._label}"
+ if len(text) > width:
+ text = text[:max(0, width - 1)] + "…"
+
+ attr_on = False
+ if self._focused and self.isEnabled():
+ window.attron(curses.A_REVERSE)
+ attr_on = True
+ elif not self.isEnabled():
+ window.attron(curses.A_DIM)
+ attr_on = True
+
+ window.addstr(y, x, text)
+
+ if attr_on:
+ try:
+ if self._focused and self.isEnabled():
+ window.attroff(curses.A_REVERSE)
+ else:
+ window.attroff(curses.A_DIM)
+ except Exception:
+ pass
+ except curses.error as e:
+ try:
+ self._logger.error("_draw curses.error: %s", e, exc_info=True)
+ except Exception:
+ _mod_logger.error("_draw curses.error: %s", e, exc_info=True)
+
+ def _handle_key(self, key):
+ if not self._focused or not self.isEnabled() or not self.visible():
+ return False
+ # Space or Enter to select radio
+ if key in (ord(' '), ord('\n'), curses.KEY_ENTER):
+ self._select()
+ return True
+ return False
+
+ def _select(self):
+ """Select this radio and unselect siblings; post event."""
+ try:
+ if not getattr(self, '_is_checked', False):
+ # set self selected
+ self._is_checked = True
+ # uncheck siblings under same parent
+ try:
+ parent = getattr(self, '_parent', None)
+ if parent is not None:
+ for sib in list(getattr(parent, '_children', []) or []):
+ try:
+ if sib is not self and getattr(sib, 'widgetClass', None) and sib.widgetClass() == 'YRadioButton':
+ try:
+ sib._is_checked = False
+ except Exception:
+ pass
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ # Post notification event
+ if self.notify():
+ dlg = self.findDialog()
+ if dlg is not None:
+ try:
+ dlg._post_event(YWidgetEvent(self, YEventReason.ValueChanged))
+ except Exception as e:
+ try:
+ self._logger.error("_select post event error: %s", e, exc_info=True)
+ except Exception:
+ _mod_logger.error("_select post event error: %s", e, exc_info=True)
+ else:
+ try:
+ print(f"RadioButton selected (no dialog): {self._label}")
+ except Exception:
+ pass
+ except Exception:
+ try:
+ self._logger.error("_select error", exc_info=True)
+ except Exception:
+ _mod_logger.error("_select error", exc_info=True)
+
+ def setVisible(self, visible=True):
+ super().setVisible(visible)
+ # in curses backend visibility controls whether widget can receive focus
+ self._can_focus = bool(visible)
\ No newline at end of file
diff --git a/manatools/aui/backends/curses/replacepointcurses.py b/manatools/aui/backends/curses/replacepointcurses.py
new file mode 100644
index 0000000..995e888
--- /dev/null
+++ b/manatools/aui/backends/curses/replacepointcurses.py
@@ -0,0 +1,144 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.curses contains all curses backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.curses
+'''
+import logging
+from ...yui_common import *
+
+# Module-level logger for curses replace point backend
+_mod_logger = logging.getLogger("manatools.aui.curses.replacepoint.module")
+if not logging.getLogger().handlers:
+ _h = logging.StreamHandler()
+ _h.setFormatter(logging.Formatter("%(asctime)s %(name)s %(levelname)s: %(message)s"))
+ _mod_logger.addHandler(_h)
+ _mod_logger.setLevel(logging.INFO)
+
+class YReplacePointCurses(YSingleChildContainerWidget):
+ """
+ NCurses backend implementation of YReplacePoint.
+
+ A single-child placeholder; showChild() ensures the current child will
+ be drawn in the curses UI, and deleteChildren() clears the logical model
+ so a new child can be added later.
+ """
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self._backend_widget = self # curses backends often use self as the drawable
+ self._logger = logging.getLogger(f"manatools.aui.ncurses.{self.__class__.__name__}")
+ if not self._logger.handlers and not logging.getLogger().handlers:
+ for h in _mod_logger.handlers:
+ self._logger.addHandler(h)
+ try:
+ self._logger.debug("%s.__init__", self.__class__.__name__)
+ except Exception:
+ pass
+
+ def widgetClass(self):
+ return "YReplacePoint"
+
+ def _create_backend_widget(self):
+ # No separate widget for curses: keep self as the drawable entity
+ try:
+ self._backend_widget = self
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception as e:
+ try:
+ self._logger.error("_create_backend_widget error: %s", e, exc_info=True)
+ except Exception:
+ pass
+
+ def stretchable(self, dim: YUIDimension):
+ """Propagate stretchability from the single child to the container."""
+ try:
+ ch = self.child()
+ if ch is None:
+ return False
+ try:
+ if bool(ch.stretchable(dim)):
+ return True
+ except Exception:
+ pass
+ try:
+ if bool(ch.weight(dim)):
+ return True
+ except Exception:
+ pass
+ except Exception:
+ pass
+ return False
+
+ def _set_backend_enabled(self, enabled):
+ # Propagate enabled state to child if present
+ try:
+ ch = self.child()
+ if ch is not None and hasattr(ch, "setEnabled"):
+ ch.setEnabled(enabled)
+ except Exception as e:
+ try:
+ self._logger.error("_set_backend_enabled error: %s", e, exc_info=True)
+ except Exception:
+ pass
+
+ def addChild(self, child):
+ super().addChild(child)
+ try:
+ self.showChild()
+ except Exception:
+ pass
+
+ def showChild(self):
+ """
+ For curses, showing the child means ensuring the child will be drawn
+ when this container's _draw is invoked. No further action is needed
+ beyond the logical association, but this method exists for API parity
+ and debugging.
+ """
+ try:
+ self._logger.debug("showChild: child=%s", getattr(self.child(), "widgetClass", lambda: "?")())
+ # Force dialog redraw on next loop iteration (similar to recalcLayout)
+ try:
+ dlg = self.findDialog()
+ if dlg is not None:
+ try:
+ dlg._last_draw_time = 0
+ except Exception:
+ pass
+ # Update minimal height to reflect child's needs
+ try:
+ from .commoncurses import _curses_recursive_min_height
+ ch = self.child()
+ inner_min = _curses_recursive_min_height(ch) if ch is not None else 1
+ self._height = max(1, inner_min)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def deleteChildren(self):
+ try:
+ super().deleteChildren()
+ except Exception as e:
+ try:
+ self._logger.error("deleteChildren error: %s", e, exc_info=True)
+ except Exception:
+ pass
+
+ def _draw(self, window, y, x, width, height):
+ """Delegate drawing to the single child if present."""
+ try:
+ ch = self.child()
+ if ch is None:
+ return
+ if hasattr(ch, "_draw"):
+ ch._draw(window, y, x, width, height)
+ except Exception:
+ pass
diff --git a/manatools/aui/backends/curses/richtextcurses.py b/manatools/aui/backends/curses/richtextcurses.py
new file mode 100644
index 0000000..8381d55
--- /dev/null
+++ b/manatools/aui/backends/curses/richtextcurses.py
@@ -0,0 +1,711 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Curses backend RichText widget.
+- Displays text content in a scrollable area.
+- In plain text mode, shows the text as-is.
+- In rich mode, strips simple HTML tags for display; detects URLs.
+- Link activation: pressing Enter on a line with a URL posts a YMenuEvent with the URL.
+'''
+import curses
+import re
+from html.parser import HTMLParser
+import logging
+from ...yui_common import *
+
+_mod_logger = logging.getLogger("manatools.aui.curses.richtext.module")
+if not logging.getLogger().handlers:
+ _h = logging.StreamHandler()
+ _h.setFormatter(logging.Formatter("%(asctime)s %(name)s %(levelname)s: %(message)s"))
+ _mod_logger.addHandler(_h)
+ _mod_logger.setLevel(logging.INFO)
+
+
+class YRichTextCurses(YWidget):
+ def __init__(self, parent=None, text: str = "", plainTextMode: bool = False):
+ super().__init__(parent)
+ self._text = text or ""
+ self._plain = bool(plainTextMode)
+ self._auto_scroll = False
+ self._last_url = None
+ self._height = 6
+ self._can_focus = True
+ self._focused = False
+ self._scroll_offset = 0 # vertical offset
+ self._hscroll_offset = 0 # horizontal offset
+ self._hover_line = 0
+ self._anchors = [] # list of dicts: {sline, scol, eline, ecol, target}
+ self._armed_index = -1
+ self._heading_lines = set()
+ self._color_link = None
+ self._color_link_armed = None
+ self._parsed_lines = None
+ self._named_color_pairs = {}
+ self._next_color_pid = 20
+ self._preferred_rows = 6 #not used by now
+ #tooltip support
+ self._x = 0
+ self._y = 0
+ self.setStretchable(YUIDimension.YD_HORIZ, True)
+ self.setStretchable(YUIDimension.YD_VERT, True)
+ self._logger = logging.getLogger(f"manatools.aui.ncurses.{self.__class__.__name__}")
+ if not self._logger.handlers and not logging.getLogger().handlers:
+ for h in _mod_logger.handlers:
+ self._logger.addHandler(h)
+
+ def widgetClass(self):
+ return "YRichText"
+
+ def _create_backend_widget(self):
+ try:
+ self._backend_widget = self
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ # initial parse for rich mode
+ if not self._plain:
+ try:
+ self._anchors = self._parse_anchors(self._text)
+ self._anchors.sort(key=lambda a: (a['sline'], a['scol']))
+ except Exception:
+ self._anchors = []
+ except Exception as e:
+ try:
+ self._logger.error("_create_backend_widget error: %s", e, exc_info=True)
+ except Exception:
+ _mod_logger.error("_create_backend_widget error: %s", e, exc_info=True)
+
+ def setValue(self, newValue: str):
+ self._text = newValue or ""
+ # re-parse anchors when in rich mode
+ if not self._plain:
+ try:
+ self._anchors = self._parse_anchors(self._text)
+ self._anchors.sort(key=lambda a: (a['sline'], a['scol']))
+ except Exception:
+ self._anchors = []
+ # autoscroll: move hover to last line
+ if self._auto_scroll:
+ lines = self._lines()
+ self._hover_line = max(0, len(lines) - 1)
+ self._ensure_hover_visible()
+
+ def value(self) -> str:
+ return self._text
+
+ def plainTextMode(self) -> bool:
+ return bool(self._plain)
+
+ def setPlainTextMode(self, on: bool = True):
+ self._plain = bool(on)
+
+ def autoScrollDown(self) -> bool:
+ return bool(self._auto_scroll)
+
+ def setAutoScrollDown(self, on: bool = True):
+ self._auto_scroll = bool(on)
+ if self._auto_scroll:
+ lines = self._lines()
+ self._hover_line = max(0, len(lines) - 1)
+ self._ensure_hover_visible()
+
+ def lastActivatedUrl(self):
+ return self._last_url
+
+ def _strip_tags(self, s: str) -> str:
+ # Convert minimal HTML into text breaks and bullets, then strip remaining tags
+ try:
+ t = s
+ # breaks and paragraphs
+ t = re.sub(r"
", "\n", t, flags=re.IGNORECASE)
+ t = re.sub(r"
", "\n\n", t, flags=re.IGNORECASE)
+ t = re.sub(r"", "", t, flags=re.IGNORECASE)
+ # lists
+ t = re.sub(r"
", "\n", t, flags=re.IGNORECASE)
+ t = re.sub(r"
", "\n", t, flags=re.IGNORECASE)
+ t = re.sub(r"", "\n", t, flags=re.IGNORECASE)
+ t = re.sub(r"
", "\n", t, flags=re.IGNORECASE)
+ t = re.sub(r"", "• ", t, flags=re.IGNORECASE)
+ t = re.sub(r"", "\n", t, flags=re.IGNORECASE)
+ # headings -> uppercase with newline
+ for n in range(1,7):
+ t = re.sub(fr"", "", t, flags=re.IGNORECASE)
+ t = re.sub(fr"", "\n", t, flags=re.IGNORECASE)
+ # anchors: keep inner text only; URLs handled via anchor list
+ t = re.sub(r"]*href=\"([^\"]+)\"[^>]*>(.*?)", r"\2", t, flags=re.IGNORECASE|re.DOTALL)
+ t = re.sub(r"]*href='([^']+)'[^>]*>(.*?)", r"\2", t, flags=re.IGNORECASE|re.DOTALL)
+ return re.sub(r"<[^>]+>", "", t)
+ except Exception:
+ return s
+
+ def _lines(self):
+ content = self._text
+ if not self._plain:
+ content = self._strip_tags(content)
+ lines = content.splitlines() or [""]
+ return lines
+
+ def _parse_anchors(self, s: str):
+ # New parser-based implementation
+ anchors = []
+ try:
+ class Parser(HTMLParser):
+ def __init__(self):
+ super().__init__()
+ self.lines = [[]]
+ self.styles = []
+ self.anchor = None
+ self.buf = ""
+ self.heading_lines = set()
+
+ def _flush(self):
+ if not self.buf:
+ return
+ seg = {
+ 'text': self.buf,
+ 'bold': any(s == 'b' for s in self.styles),
+ 'italic': any(s == 'i' for s in self.styles),
+ 'underline': any(s == 'u' for s in self.styles) or (self.anchor is not None),
+ 'color': None,
+ 'anchor': self.anchor
+ }
+ for s in self.styles:
+ if isinstance(s, dict) and 'color' in s:
+ seg['color'] = s['color']
+ self.lines[-1].append(seg)
+ self.buf = ""
+
+ def handle_starttag(self, tag, attrs):
+ at = dict(attrs)
+ if tag == 'br':
+ self._flush()
+ self.lines.append([])
+ elif tag == 'p':
+ self._flush()
+ self.lines.append([])
+ elif tag in ('ul','ol'):
+ self._flush()
+ self.lines.append([])
+ elif tag == 'li':
+ self._flush()
+ self.buf += '• '
+ elif tag in ('h1','h2','h3','h4','h5','h6'):
+ # flush previous text, then mark heading as bold
+ self._flush()
+ self.styles.append('b')
+ elif tag == 'a':
+ href = at.get('href') or at.get('HREF')
+ self._flush()
+ self.anchor = href
+ elif tag == 'span':
+ # flush previous text, then push color/style
+ self._flush()
+ color = at.get('foreground') or None
+ if not color and 'style' in at:
+ m = re.search(r'color:\s*([^;]+)', at.get('style'))
+ if m:
+ color = m.group(1)
+ if color:
+ self.styles.append({'color': color})
+ else:
+ self.styles.append('span')
+ elif tag == 'b':
+ self._flush()
+ self.styles.append('b')
+ elif tag in ('i','em'):
+ self._flush()
+ self.styles.append('i')
+ elif tag == 'u':
+ self._flush()
+ self.styles.append('u')
+
+ def handle_endtag(self, tag):
+ if tag == 'br':
+ self._flush()
+ self.lines.append([])
+ elif tag == 'p':
+ self._flush()
+ self.lines.append([])
+ elif tag in ('ul','ol'):
+ self._flush()
+ self.lines.append([])
+ elif tag == 'li':
+ self._flush()
+ self.lines.append([])
+ elif tag in ('h1','h2','h3','h4','h5','h6'):
+ try:
+ self.styles.remove('b')
+ except ValueError:
+ pass
+ self._flush()
+ # mark current line as heading
+ try:
+ self.heading_lines.add(len(self.lines)-1)
+ except Exception:
+ pass
+ self.lines.append([])
+ elif tag == 'a':
+ self._flush()
+ self.anchor = None
+ elif tag == 'span':
+ # pop last color dict if present
+ for i in range(len(self.styles)-1, -1, -1):
+ if isinstance(self.styles[i], dict) and 'color' in self.styles[i]:
+ del self.styles[i]
+ break
+ else:
+ if self.styles:
+ self.styles.pop()
+ self._flush()
+ elif tag == 'b':
+ try:
+ self.styles.remove('b')
+ except ValueError:
+ pass
+ self._flush()
+ elif tag in ('i','em'):
+ try:
+ self.styles.remove('i')
+ except ValueError:
+ pass
+ self._flush()
+ elif tag == 'u':
+ try:
+ self.styles.remove('u')
+ except ValueError:
+ pass
+ self._flush()
+
+ def handle_data(self, data):
+ parts = data.split('\n')
+ for idx, part in enumerate(parts):
+ if idx > 0:
+ self._flush()
+ self.lines.append([])
+ self.buf += part
+
+ p = Parser()
+ p.feed(s)
+ p._flush()
+ lines = p.lines
+ # collect anchors and heading lines from parser
+ anchors = []
+ self._heading_lines = set(p.heading_lines)
+ for ln_idx, segs in enumerate(lines):
+ col = 0
+ for seg_idx, seg in enumerate(segs):
+ text = seg.get('text','')
+ length = len(text)
+ if seg.get('anchor'):
+ anchors.append({'sline': ln_idx, 'scol': col, 'eline': ln_idx, 'ecol': col + length, 'target': seg.get('anchor'), 'seg_idx': seg_idx})
+ col += length
+ self._parsed_lines = lines
+ except Exception:
+ self._parsed_lines = None
+ anchors = []
+ return anchors
+
+ def _visible_row_count(self):
+ return max(1, getattr(self, "_preferred_rows", 6))
+
+ def _ensure_hover_visible(self):
+ visible = self._visible_row_count()
+ if self._hover_line < self._scroll_offset:
+ self._scroll_offset = self._hover_line
+ elif self._hover_line >= self._scroll_offset + visible:
+ self._scroll_offset = self._hover_line - visible + 1
+
+ def _draw(self, window, y, x, width, height):
+ if self._visible is False:
+ return
+ try:
+ # draw border
+ try:
+ window.attrset(curses.A_NORMAL)
+ window.border()
+ except curses.error:
+ pass
+
+ inner_x = x
+ inner_y = y
+ self._x = inner_x
+ self._y = inner_y
+ inner_w = max(1, width)
+ inner_h = max(1, height)
+
+ # obtain lines (prefer parsed rich lines if available)
+ lines = self._parsed_lines if (not self._plain and getattr(self, '_parsed_lines', None)) else self._lines()
+ total_rows = len(lines)
+ max_row_len = max(len(row) for row in lines) if lines else 0
+
+ # reserve rightmost column for vertical scrollbar, bottom row for horizontal scrollbar
+ bar_w = 1 if inner_w > 2 and max_row_len > inner_w else 0
+ content_w = inner_w - bar_w
+ bar_h_row = 1 if inner_h > 2 and total_rows > inner_h else 0
+ content_h = inner_h - bar_h_row
+
+ visible = min(total_rows, max(1, content_h))
+ # remember for visibility calculations
+ self._last_content_w = content_w
+ self._last_width = width
+
+ # draw content with horizontal scrolling
+ segmented = bool(lines and isinstance(lines[0], list))
+ for i in range(visible):
+ idx = self._scroll_offset + i
+ if idx >= total_rows:
+ break
+ attr_default = curses.A_NORMAL
+ if not self.isEnabled():
+ attr_default |= curses.A_DIM
+ # apply heading style when applicable
+ is_heading = (not self._plain) and (idx in getattr(self, '_heading_lines', set()))
+ if is_heading:
+ attr_default |= curses.A_BOLD
+ # highlight hover line only in plain mode or without anchors
+ if (self._plain or not self._anchors) and self._focused and idx == self._hover_line and self.isEnabled():
+ attr_default |= curses.A_REVERSE
+
+ start_col = self._hscroll_offset
+ end_col = self._hscroll_offset + content_w
+
+ try:
+ line_x = inner_x
+ if not segmented:
+ txt = lines[idx]
+ segment = txt[start_col:end_col]
+ if is_heading:
+ # bold + red for headings
+ a = attr_default
+ red_pid = self._get_named_color_pair('red')
+ if red_pid is not None:
+ try:
+ a |= curses.color_pair(red_pid)
+ except Exception:
+ pass
+ window.addstr(inner_y + i, inner_x, segment.ljust(content_w), a)
+ else:
+ window.addstr(inner_y + i, inner_x, segment.ljust(content_w), attr_default)
+ else:
+ segs = lines[idx]
+ # iterate segments and draw visible portion
+ char_cursor = 0
+ for seg in segs:
+ text = seg.get('text', '')
+ if not text:
+ continue
+ seg_len = len(text)
+ seg_start = char_cursor
+ seg_end = char_cursor + seg_len
+ # no overlap with visible window?
+ if seg_end <= start_col:
+ char_cursor += seg_len
+ continue
+ if seg_start >= end_col:
+ break
+ # compute visible slice within this segment
+ vis_lo = max(start_col, seg_start) - seg_start
+ vis_hi = min(end_col, seg_end) - seg_start
+ piece = text[vis_lo:vis_hi]
+ if not piece:
+ char_cursor += seg_len
+ continue
+ # determine attributes for this piece
+ a = attr_default
+ if is_heading:
+ # headings: bold + red
+ red_pid = self._get_named_color_pair('red')
+ if red_pid is not None:
+ try:
+ a |= curses.color_pair(red_pid)
+ except Exception:
+ pass
+ else:
+ # unify bold/italic/underline and anchors to bold gray
+ if seg.get('bold') or seg.get('italic') or seg.get('underline') or seg.get('anchor'):
+ a |= curses.A_BOLD
+ gray_pid = self._get_named_color_pair('gray')
+ if gray_pid is not None:
+ try:
+ a |= curses.color_pair(gray_pid)
+ except Exception:
+ pass
+ else:
+ # optional explicit color spans for normal text
+ self._ensure_color_pairs()
+ color_id = None
+ if isinstance(seg.get('color'), int):
+ color_id = seg.get('color')
+ elif seg.get('color'):
+ color_id = self._get_named_color_pair(seg.get('color'))
+ if color_id is not None:
+ try:
+ a |= curses.color_pair(color_id)
+ except Exception:
+ pass
+ # armed anchor: add reverse for visibility
+ if seg.get('anchor') and self._armed_index != -1 and 0 <= self._armed_index < len(self._anchors):
+ a_active = self._anchors[self._armed_index]
+ if a_active and a_active['sline'] == idx and a_active['scol'] <= seg_start and a_active['ecol'] >= seg_end:
+ a |= curses.A_REVERSE
+
+ try:
+ window.addstr(inner_y + i, line_x, piece, a)
+ except curses.error:
+ pass
+ line_x += len(piece)
+ char_cursor += seg_len
+ # pad remainder
+ rem = content_w - (line_x - inner_x)
+ if rem > 0:
+ try:
+ window.addstr(inner_y + i, line_x, " " * rem, attr_default)
+ except curses.error:
+ pass
+ except curses.error:
+ pass
+
+ # vertical scrollbar on right
+ if bar_w == 1 and content_h > 0 and total_rows > visible:
+ try:
+ # draw track
+ for r in range(content_h):
+ window.addch(inner_y + r, inner_x + content_w, '|')
+ # draw slider position
+ pos = 0
+ if total_rows > visible:
+ pos = int((self._scroll_offset / max(1, total_rows - visible)) * (content_h - 1))
+ pos = max(0, min(content_h - 1, pos))
+ window.addch(inner_y + pos, inner_x + content_w, '#')
+ except curses.error:
+ pass
+
+ # horizontal scrollbar on bottom
+ if bar_h_row == 1:
+ try:
+ # basic track
+ for c in range(content_w):
+ window.addch(inner_y + content_h, inner_x + c, '-')
+ # slider position relative to max line length
+ maxlen = max((len(l) for l in lines), default=0)
+ if maxlen > content_w:
+ hpos = int((self._hscroll_offset / max(1, maxlen - content_w)) * (content_w - 1))
+ hpos = max(0, min(content_w - 1, hpos))
+ window.addch(inner_y + content_h, inner_x + hpos, '=')
+ except curses.error:
+ pass
+ except curses.error:
+ pass
+
+ def _handle_key(self, key):
+ if not self._focused or not self.isEnabled() or not self.visible():
+ return False
+ handled = True
+ lines = self._parsed_lines if (not self._plain and getattr(self, '_parsed_lines', None)) else self._lines()
+ if key == curses.KEY_UP:
+ if self._anchors:
+ if self._armed_index > 0:
+ self._armed_index -= 1
+ a = self._anchors[self._armed_index]
+ self._hover_line = a['sline']
+ self._ensure_anchor_visible(a)
+ else:
+ if self._hover_line > 0:
+ self._hover_line -= 1
+ self._ensure_hover_visible()
+ elif key == curses.KEY_DOWN:
+ if self._anchors:
+ if self._armed_index + 1 < len(self._anchors):
+ self._armed_index += 1
+ a = self._anchors[self._armed_index]
+ self._hover_line = a['sline']
+ self._ensure_anchor_visible(a)
+ else:
+ if self._hover_line < max(0, len(lines) - 1):
+ self._hover_line += 1
+ self._ensure_hover_visible()
+ elif key == curses.KEY_LEFT:
+ # horizontal scroll or move to previous anchor
+ if self._anchors:
+ if self._armed_index > 0:
+ self._armed_index -= 1
+ a = self._anchors[self._armed_index]
+ self._hover_line = a['sline']
+ self._ensure_anchor_visible(a)
+ else:
+ if self._hscroll_offset > 0:
+ self._hscroll_offset = max(0, self._hscroll_offset - max(1, (self._visible_row_count() // 2)))
+ elif key == curses.KEY_RIGHT:
+ if self._anchors:
+ if self._armed_index + 1 < len(self._anchors):
+ self._armed_index += 1
+ a = self._anchors[self._armed_index]
+ self._hover_line = a['sline']
+ self._ensure_anchor_visible(a)
+ else:
+ maxlen = max((len(l) for l in lines), default=0)
+ if maxlen > self._hscroll_offset:
+ self._hscroll_offset = min(maxlen, self._hscroll_offset + max(1, (self._visible_row_count() // 2)))
+ elif key == curses.KEY_PPAGE:
+ step = self._visible_row_count() or 1
+ self._hover_line = max(0, self._hover_line - step)
+ self._ensure_hover_visible()
+ elif key == curses.KEY_NPAGE:
+ step = self._visible_row_count() or 1
+ self._hover_line = min(max(0, len(lines) - 1), self._hover_line + step)
+ self._ensure_hover_visible()
+ elif key == curses.KEY_HOME:
+ self._hover_line = 0
+ self._ensure_hover_visible()
+ elif key == curses.KEY_END:
+ self._hover_line = max(0, len(lines) - 1)
+ self._ensure_hover_visible()
+ elif key in (ord('\n'),):
+ # Try to detect a URL in the current line and emit a menu event
+ try:
+ if self._anchors and self._armed_index != -1 and 0 <= self._armed_index < len(self._anchors):
+ url = self._anchors[self._armed_index]['target']
+ self._last_url = url
+ if self.notify():
+ dlg = self.findDialog()
+ if dlg is not None:
+ dlg._post_event(YMenuEvent(item=None, id=url))
+ else:
+ line = lines[self._hover_line] if 0 <= self._hover_line < len(lines) else ""
+ m = re.search(r"https?://\S+", line)
+ if m:
+ url = m.group(0)
+ self._last_url = url
+ if self.notify():
+ dlg = self.findDialog()
+ if dlg is not None:
+ dlg._post_event(YMenuEvent(item=None, id=url))
+ except Exception:
+ pass
+ else:
+ handled = False
+ return handled
+
+ def _set_backend_enabled(self, enabled):
+ try:
+ self._can_focus = bool(enabled)
+ if not enabled:
+ self._focused = False
+ except Exception:
+ pass
+
+ # Focus handling hooks (optional integration with parent)
+ def setFocus(self, on: bool = True):
+ self._focused = bool(on) and self._can_focus
+ # when focusing, if rich mode and anchors exist, arm first visible anchor
+ if self._focused and not self._plain and self._anchors:
+ # pick the first anchor within current page
+ visible_top = self._scroll_offset
+ visible_bottom = self._scroll_offset + (self._visible_row_count() or 1)
+ for i, a in enumerate(self._anchors):
+ if visible_top <= a['sline'] < visible_bottom:
+ self._armed_index = i
+ self._hover_line = a['sline']
+ break
+ else:
+ self._armed_index = 0
+ self._hover_line = self._anchors[0]['sline']
+ self._ensure_anchor_visible(self._anchors[0])
+
+ def _ensure_anchor_visible(self, a):
+ try:
+ # vertical
+ self._hover_line = a['sline']
+ self._ensure_hover_visible()
+ # horizontal: use last known content width
+ visible_w = max(1, getattr(self, '_last_content_w', 0))
+ if visible_w <= 0:
+ visible_w = 40
+ start_col = self._hscroll_offset
+ end_col = start_col + visible_w
+ if a['scol'] < start_col:
+ self._hscroll_offset = max(0, a['scol'])
+ elif a['ecol'] > end_col:
+ self._hscroll_offset = max(0, a['ecol'] - visible_w)
+ except Exception:
+ pass
+
+ def _ensure_color_pairs(self):
+ try:
+ if self._color_link is not None and self._color_link_armed is not None:
+ return
+ if curses.has_colors():
+ try:
+ curses.start_color()
+ # prefer default background where supported
+ if hasattr(curses, 'use_default_colors'):
+ try:
+ curses.use_default_colors()
+ except Exception:
+ pass
+ except Exception:
+ pass
+ pid_link = 10
+ pid_link_armed = 11
+ try:
+ curses.init_pair(pid_link, curses.COLOR_BLUE, -1)
+ self._color_link = pid_link
+ except Exception:
+ self._color_link = None
+ try:
+ curses.init_pair(pid_link_armed, curses.COLOR_CYAN, -1)
+ self._color_link_armed = pid_link_armed
+ except Exception:
+ self._color_link_armed = None
+ except Exception:
+ pass
+
+ def _get_named_color_pair(self, name: str):
+ try:
+ if not name:
+ return None
+ if not curses.has_colors():
+ self._logger.debug("_get_named_color_pair: terminal has no color support")
+ return None
+ # normalize name
+ nm = str(name).strip().lower()
+ # map common color names
+ cmap = {
+ 'black': curses.COLOR_BLACK,
+ 'red': curses.COLOR_RED,
+ 'green': curses.COLOR_GREEN,
+ 'yellow': curses.COLOR_YELLOW,
+ 'blue': curses.COLOR_BLUE,
+ 'magenta': curses.COLOR_MAGENTA,
+ 'purple': curses.COLOR_MAGENTA,
+ 'cyan': curses.COLOR_CYAN,
+ 'white': curses.COLOR_WHITE,
+ 'gray': curses.COLOR_WHITE,
+ 'grey': curses.COLOR_WHITE,
+ }
+ fg = cmap.get(nm)
+ if fg is None:
+ return None
+ # reuse if exists
+ if nm in self._named_color_pairs:
+ return self._named_color_pairs[nm]
+ # init colors if needed
+ try:
+ curses.start_color()
+ if hasattr(curses, 'use_default_colors'):
+ curses.use_default_colors()
+ except Exception:
+ pass
+ pid = self._next_color_pid
+ # advance, avoid collisions with link pairs
+ self._next_color_pid = pid + 1
+ try:
+ curses.init_pair(pid, fg, -1)
+ self._named_color_pairs[nm] = pid
+ return pid
+ except Exception:
+ return None
+ except Exception:
+ return None
+
+ def setVisible(self, visible=True):
+ super().setVisible(visible)
+ self._can_focus = visible
\ No newline at end of file
diff --git a/manatools/aui/backends/curses/selectionboxcurses.py b/manatools/aui/backends/curses/selectionboxcurses.py
new file mode 100644
index 0000000..e5148ad
--- /dev/null
+++ b/manatools/aui/backends/curses/selectionboxcurses.py
@@ -0,0 +1,375 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.curses contains all curses backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.curses
+'''
+import curses
+import curses.ascii
+import sys
+import os
+import time
+from typing import Optional
+import logging
+from ...yui_common import *
+
+# Module-level safe logging setup: add a StreamHandler by default only if
+# the root logger has no handlers so main can fully configure logging later.
+_mod_logger = logging.getLogger("manatools.aui.curses.selectionbox.module")
+if not logging.getLogger().handlers:
+ h = logging.StreamHandler()
+ h.setFormatter(logging.Formatter("%(asctime)s %(name)s %(levelname)s: %(message)s"))
+ _mod_logger.addHandler(h)
+ _mod_logger.setLevel(logging.INFO)
+
+
+class YSelectionBoxCurses(YSelectionWidget):
+ def __init__(self, parent=None, label="", multi_selection: Optional[bool] = False):
+ super().__init__(parent)
+ # per-instance logger named by package/backend/class
+ self._logger = logging.getLogger(f"manatools.aui.ncurses.{self.__class__.__name__}")
+ if not self._logger.handlers and not logging.getLogger().handlers:
+ # ensure instance logger at least inherits module handler if root not configured
+ for h in _mod_logger.handlers:
+ self._logger.addHandler(h)
+ self._logger.debug("YSelectionBoxCurses.__init__ label=%s", label)
+ self._label = label
+ self._value = ""
+ self._selected_items = []
+ self._multi_selection = multi_selection
+
+ # UI state for drawing/navigation
+ # actual minimal height for layout (keep small so parent can expand it)
+ self._height = 1
+ # preferred rows used for paging when no draw happened yet
+ self._preferred_rows = 6
+
+ self._scroll_offset = 0
+ self._hover_index = 0 # index into self._items (global)
+ self._can_focus = True
+ self._focused = False
+ self.setStretchable(YUIDimension.YD_HORIZ, True)
+ self.setStretchable(YUIDimension.YD_VERT, True)
+
+ # Track last computed visible rows during last _draw call so
+ # navigation/ensure logic uses actual available space.
+ self._current_visible_rows = None
+
+ def widgetClass(self):
+ return "YSelectionBox"
+
+ def label(self):
+ return self._label
+
+ def value(self):
+ return self._value
+
+ def selectedItems(self):
+ return list(self._selected_items)
+
+ def selectItem(self, item, selected=True):
+ """Programmatically select/deselect an item."""
+ # find index
+ idx = None
+ for i, it in enumerate(self._items):
+ if it is item or it.label() == item.label():
+ idx = i
+ break
+ if idx is None:
+ return
+
+ if selected:
+ if not self._multi_selection:
+ if item not in self._selected_items:
+ selected_item = self._selected_items[0] if self._selected_items else None
+ if selected_item is not None:
+ selected_item.setSelected(False)
+ self._selected_items = [item]
+ self._value = item.label()
+ item.setSelected(True)
+ else:
+ if item not in self._selected_items:
+ self._selected_items.append(item)
+ item.setSelected(True)
+ else:
+ if item in self._selected_items:
+ self._selected_items.remove(item)
+ item.setSelected(False)
+ self._value = self._selected_items[0].label() if self._selected_items else ""
+
+ # ensure hover and scroll reflect this item
+ self._hover_index = idx
+ self._ensure_hover_visible()
+
+ def setMultiSelection(self, enabled):
+ self._multi_selection = bool(enabled)
+ # if disabling multi-selection, reduce to first selected item
+ if not self._multi_selection and len(self._selected_items) > 1:
+ first = self._selected_items[0]
+ for it in list(self._selected_items)[1:]:
+ it.setSelected(False)
+ self._selected_items = [first]
+ self._value = first.label()
+
+ def multiSelection(self):
+ return bool(self._multi_selection)
+
+ def _ensure_hover_visible(self):
+ """Adjust scroll offset so that hover_index is visible in the box."""
+ # Prefer the visible row count computed during the last _draw call
+ # (which takes the actual available height into account). Fallback
+ # to the configured visible row count if no draw happened yet.
+ visible = self._current_visible_rows if self._current_visible_rows is not None else self._visible_row_count()
+ if visible <= 0:
+ return
+ if self._hover_index < self._scroll_offset:
+ self._scroll_offset = self._hover_index
+ elif self._hover_index >= self._scroll_offset + visible:
+ self._scroll_offset = self._hover_index - visible + 1
+
+ def _visible_row_count(self):
+ # Return preferred visible rows for navigation (PageUp/PageDown step).
+ # Use preferred_rows (default 6) rather than forcing the layout minimum.
+ return max(1, getattr(self, "_preferred_rows", 6))
+
+ def _create_backend_widget(self):
+ # No curses backend widget object; drawing handled in _draw.
+ # Keep minimal layout height small so parent can give more space.
+ self._height = len(self._items) + (1 if self._label else 0)
+ # reset scroll/hover if out of range
+ self._hover_index = 0
+ # reset the cached visible rows so future navigation uses the next draw's value
+ self._current_visible_rows = None
+ self._ensure_hover_visible()
+ # Reflect model YItem.selected flags into internal state so selection is visible
+ try:
+ sel = []
+ if self._multi_selection:
+ for it in self._items:
+ try:
+ if it.selected():
+ sel.append(it)
+ except Exception:
+ pass
+ else:
+ last = None
+ for it in self._items:
+ try:
+ if it.selected():
+ if last is not None:
+ last.setSelected(False)
+ last = it
+ except Exception:
+ pass
+ if last is not None:
+ sel = [last]
+ self._selected_items = sel
+ self._value = self._selected_items[0].label() if self._selected_items else ""
+ _mod_logger.debug("_create_backend_widget: <%s> selected_items=<%r> value=<%r>", self.debugLabel(), self._selected_items, self._value)
+ except Exception:
+ pass
+ self._backend_widget = self
+
+ def _set_backend_enabled(self, enabled):
+ """Enable/disable selection box: affect focusability and propagate to row items."""
+ try:
+ if not hasattr(self, "_saved_can_focus"):
+ self._saved_can_focus = getattr(self, "_can_focus", True)
+ if not enabled:
+ try:
+ self._saved_can_focus = self._can_focus
+ except Exception:
+ self._saved_can_focus = True
+ self._can_focus = False
+ if getattr(self, "_focused", False):
+ self._focused = False
+ else:
+ try:
+ self._can_focus = bool(getattr(self, "_saved_can_focus", True))
+ except Exception:
+ self._can_focus = True
+ # propagate logical enabled state to contained items (if they are YWidget)
+ try:
+ for it in list(getattr(self, "_items", []) or []):
+ if hasattr(it, "setEnabled"):
+ try:
+ it.setEnabled(enabled)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def _draw(self, window, y, x, width, height):
+ if self._visible is False:
+ return
+ try:
+ line = y
+ # draw label if present
+ if self._label:
+ lbl = self._label
+ lbl_attr = curses.A_BOLD
+ if not self.isEnabled():
+ lbl_attr |= curses.A_DIM
+ try:
+ window.addstr(line, x, lbl[:width], lbl_attr)
+ except curses.error:
+ pass
+ line += 1
+
+ visible = self._visible_row_count()
+ available_rows = max(0, height - (1 if self._label else 0))
+ if self.stretchable(YUIDimension.YD_VERT):
+ visible = min(len(self._items), available_rows)
+ else:
+ visible = min(len(self._items), self._visible_row_count(), available_rows)
+ self._current_visible_rows = visible
+
+ for i in range(visible):
+ item_idx = self._scroll_offset + i
+ if item_idx >= len(self._items):
+ break
+ item = self._items[item_idx]
+ text = item.label()
+ checkbox = "*" if item in self._selected_items else " "
+ display = f"[{checkbox}] {text}"
+ if len(display) > width:
+ display = display[:max(0, width - 1)] + "…"
+ attr = curses.A_NORMAL
+ if not self.isEnabled():
+ attr |= curses.A_DIM
+ if self._focused and item_idx == self._hover_index and self.isEnabled():
+ attr |= curses.A_REVERSE
+ try:
+ window.addstr(line + i, x, display.ljust(width), attr)
+ except curses.error:
+ pass
+
+ if self._focused and len(self._items) > visible and width > 0 and self.isEnabled():
+ try:
+ if self._scroll_offset > 0:
+ window.addch(y + (1 if self._label else 0), x + width - 1, '^')
+ if (self._scroll_offset + visible) < len(self._items):
+ window.addch(y + (1 if self._label else 0) + visible - 1, x + width - 1, 'v')
+ except curses.error:
+ pass
+ except curses.error:
+ pass
+
+ def _handle_key(self, key):
+ if not self._focused or not self.isEnabled() or not self.visible():
+ return False
+ self._logger.debug("_handle_key called key=%r focused=%s hover_index=%d", key, self._focused, self._hover_index)
+ handled = True
+ if key == curses.KEY_UP:
+ if self._hover_index > 0:
+ self._hover_index -= 1
+ self._ensure_hover_visible()
+ self._logger.debug("hover moved up -> %d", self._hover_index)
+ elif key == curses.KEY_DOWN:
+ if self._hover_index < max(0, len(self._items) - 1):
+ self._hover_index += 1
+ self._ensure_hover_visible()
+ self._logger.debug("hover moved down -> %d", self._hover_index)
+ elif key == curses.KEY_PPAGE: # PageUp
+ step = self._visible_row_count() or 1
+ self._hover_index = max(0, self._hover_index - step)
+ self._ensure_hover_visible()
+ elif key == curses.KEY_NPAGE: # PageDown
+ step = self._visible_row_count() or 1
+ self._hover_index = min(max(0, len(self._items) - 1), self._hover_index + step)
+ self._ensure_hover_visible()
+ elif key == curses.KEY_HOME:
+ self._hover_index = 0
+ self._ensure_hover_visible()
+ elif key == curses.KEY_END:
+ self._hover_index = max(0, len(self._items) - 1)
+ self._ensure_hover_visible()
+ elif key in (ord(' '), ord('\n')): # toggle/select
+ if 0 <= self._hover_index < len(self._items):
+ item = self._items[self._hover_index]
+ if self._multi_selection:
+ # toggle membership and update model flag
+ was_selected = item in self._selected_items
+ if was_selected:
+ self._selected_items.remove(item)
+ try:
+ item.setSelected(False)
+ except Exception:
+ pass
+ self._logger.info("item deselected: <%s>", item.label())
+ else:
+ self._selected_items.append(item)
+ try:
+ item.setSelected(True)
+ except Exception:
+ pass
+ self._logger.info("item selected: <%s>", item.label())
+ # update primary value to first selected or empty
+ self._value = self._selected_items[0].label() if self._selected_items else ""
+ else:
+ # single selection: set as sole selected and clear other model flags
+ it = self._selected_items[0] if self._selected_items else None
+ if it is not None:
+ try:
+ it.setSelected(False)
+ except Exception:
+ pass
+ self._selected_items = [item]
+ self._value = item.label()
+ try:
+ item.setSelected(True)
+ except Exception:
+ pass
+ self._logger.info("single selection set: %s", item.label())
+ # notify dialog of selection change
+ if self.notify():
+ dlg = self.findDialog()
+ if dlg is not None:
+ dlg._post_event(YWidgetEvent(self, YEventReason.SelectionChanged))
+ else:
+ handled = False
+
+ return handled
+
+
+ def addItem(self, item):
+ """Add item to model; if item has selected flag, update internal selection state.
+ Do not emit notification on add.
+ """
+ super().addItem(item)
+ try:
+ new_item = self._items[-1]
+ new_item.setIndex(len(self._items) - 1)
+ selected_flag = False
+ try:
+ selected_flag = bool(new_item.selected())
+ except Exception:
+ selected_flag = False
+ if selected_flag:
+ if not self._multi_selection:
+ selected = self._selected_items[0] if self._selected_items else None
+ if selected is not None:
+ try:
+ selected.setSelected(False)
+ except Exception:
+ pass
+ self._selected_items = [new_item]
+ self._value = new_item.label()
+ else:
+ self._selected_items.append(new_item)
+ self._value = self._selected_items[0].label() if self._selected_items else ""
+ self._logger.debug("addItem: label=<%s> selected=<%s> value=<%r>", new_item.label(), selected_flag, self._value)
+ except Exception:
+ pass
+
+ def setVisible(self, visible=True):
+ super().setVisible(visible)
+ # in curses backend visibility controls whether widget can receive focus
+ self._can_focus = bool(visible)
\ No newline at end of file
diff --git a/manatools/aui/backends/curses/slidercurses.py b/manatools/aui/backends/curses/slidercurses.py
new file mode 100644
index 0000000..b8b14f7
--- /dev/null
+++ b/manatools/aui/backends/curses/slidercurses.py
@@ -0,0 +1,267 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.curses contains all curses backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.curses
+'''
+"""
+NCurses backend Slider widget.
+- Draws a horizontal track with Unicode box-drawing characters.
+ Track: ╞═══…══╡ ; position tick: ╪ at current value.
+- Left/Right arrows to change, Home/End jump to min/max, PgUp/PgDn step.
+- Up/Down arrows or '+'/'-' for fine-grained +/-1 changes.
+- Numeric box on the right allows direct integer entry (like spinbox).
+- Default stretchable horizontally.
+"""
+
+import curses
+import curses.ascii
+import logging
+from ...yui_common import *
+
+_mod_logger = logging.getLogger("manatools.aui.curses.slider.module")
+if not logging.getLogger().handlers:
+ _h = logging.StreamHandler()
+ _h.setFormatter(logging.Formatter("%(asctime)s %(name)s %(levelname)s: %(message)s"))
+ _mod_logger.addHandler(_h)
+ _mod_logger.setLevel(logging.INFO)
+
+
+class YSliderCurses(YWidget):
+ def __init__(self, parent=None, label: str = "", minVal: int = 0, maxVal: int = 100, initialVal: int = 0):
+ super().__init__(parent)
+ self._logger = logging.getLogger(f"manatools.aui.ncurses.{self.__class__.__name__}")
+ if not self._logger.handlers and not logging.getLogger().handlers:
+ for h in _mod_logger.handlers:
+ self._logger.addHandler(h)
+
+ self._label_text = str(label) if label else ""
+ self._min = int(minVal)
+ self._max = int(maxVal)
+ if self._min > self._max:
+ self._min, self._max = self._max, self._min
+ self._value = max(self._min, min(self._max, int(initialVal)))
+
+ self._height = 2
+ self._can_focus = True
+ self._focused = False
+
+ # Inline numeric edit state
+ self._editing = False
+ self._edit_buf = ""
+
+ self.setStretchable(YUIDimension.YD_HORIZ, True)
+ self.setStretchable(YUIDimension.YD_VERT, False)
+
+ def widgetClass(self):
+ return "YSlider"
+
+ def value(self) -> int:
+ return int(self._value)
+
+ def setValue(self, v: int):
+ prev = self._value
+ self._value = max(self._min, min(self._max, int(v)))
+ if self._value != prev and self.notify():
+ dlg = self.findDialog()
+ if dlg is not None:
+ dlg._post_event(YWidgetEvent(self, YEventReason.ValueChanged))
+
+ def _create_backend_widget(self):
+ self._backend_widget = self
+ try:
+ self._logger.debug(
+ "_create_backend_widget: <%s> range=[%d,%d] value=%d",
+ self.debugLabel(), self._min, self._max, self._value
+ )
+ except Exception:
+ pass
+
+ def _set_backend_enabled(self, enabled):
+ pass
+
+ def _draw(self, window, y, x, width, height):
+ if self._visible is False:
+ return
+ try:
+ line = y
+ # label on top if present
+ if self._label_text:
+ try:
+ window.addstr(line, x, self._label_text[:width])
+ except curses.error:
+ pass
+ line += 1
+
+ track_y = line
+
+ # Determine width for value box on the right
+ digits = max(len(str(self._min)), len(str(self._max)))
+ box_w = digits + 2 # [123]
+ space_w = 1
+ min_track_w = 4
+ have_box = width >= (min_track_w + space_w + box_w)
+ track_w = width - (space_w + box_w) if have_box else width
+
+ if track_w < min_track_w:
+ return
+
+ left = "╞"
+ right = "╡"
+ seg = "═"
+
+ seg_count = max(1, track_w - 2)
+
+ # draw endpoints and track
+ try:
+ window.addstr(track_y, x, left)
+ except curses.error:
+ pass
+ try:
+ window.addstr(track_y, x + 1, seg * seg_count)
+ except curses.error:
+ pass
+ try:
+ window.addstr(track_y, x + 1 + seg_count, right)
+ except curses.error:
+ pass
+
+ # draw arrows first so the tick can overlay them at extremes
+ try:
+ if track_w >= 6:
+ window.addstr(track_y, x + 0, "◄")
+ window.addstr(track_y, x + 1 + seg_count, "►")
+ except curses.error:
+ pass
+
+ # tick position clamped to interior [x+1, x+seg_count]
+ rng = max(1, self._max - self._min)
+ pos_frac = (self._value - self._min) / rng
+ tick_x = x + 1 + int(pos_frac * max(0, seg_count - 1))
+ tick_x = max(x + 1, min(x + seg_count, tick_x))
+ try:
+ window.addstr(track_y, tick_x, "╪")
+ except curses.error:
+ pass
+
+ # Draw value box on the right if space allows
+ if have_box:
+ value_x = x + track_w + space_w
+ if self._editing:
+ text = self._edit_buf if self._edit_buf != "" else str(self._value)
+ else:
+ text = str(self._value)
+ text = text[:digits].rjust(digits)
+ disp = f"[{text}]"
+ try:
+ if self._editing:
+ window.addstr(track_y, value_x, disp, curses.A_REVERSE)
+ else:
+ window.addstr(track_y, value_x, disp)
+ except curses.error:
+ pass
+ except curses.error:
+ pass
+
+ def _handle_key(self, key):
+ if not self._focused or not self.isEnabled() or not self.visible():
+ return False
+
+ handled = True
+ step = max(1, (self._max - self._min) // 20) # ~5%
+
+ if self._editing:
+ # Editing mode: accept digits, backspace, ESC, Enter/Space
+ if key in (curses.KEY_BACKSPACE, 127, 8):
+ if self._edit_buf:
+ self._edit_buf = self._edit_buf[:-1]
+ elif curses.ascii.isdigit(key):
+ self._edit_buf += chr(key)
+ elif key in (ord('-'),):
+ # allow negative sign only at start and only if range allows negatives
+ if self._min < 0 and (self._edit_buf == "" or self._edit_buf == "-"):
+ self._edit_buf = "" if self._edit_buf == "-" else "-"
+ elif key in (ord('\n'), ord(' ')):
+ self._commit_edit()
+ if self.notify():
+ dlg = self.findDialog()
+ if dlg is not None:
+ dlg._post_event(YWidgetEvent(self, YEventReason.Activated))
+ elif key in (
+ curses.KEY_LEFT, curses.KEY_RIGHT, curses.KEY_UP, curses.KEY_DOWN,
+ curses.KEY_HOME, curses.KEY_END, curses.KEY_PPAGE, curses.KEY_NPAGE,
+ ord('+'), ord('-')
+ ):
+ # commit and re-handle as navigation
+ self._commit_edit()
+ handled = False
+ elif key == 9: # Tab
+ self._commit_edit()
+ return False
+ elif key == 27: # ESC
+ self._cancel_edit()
+ else:
+ handled = False
+
+ if handled is False:
+ handled = True
+
+ if not self._editing and handled:
+ if key in (curses.KEY_LEFT, ord('h')):
+ self.setValue(self._value - step)
+ elif key in (curses.KEY_RIGHT, ord('l')):
+ self.setValue(self._value + step)
+ elif key in (curses.KEY_UP, ord('+')):
+ self.setValue(self._value + 1)
+ elif key in (curses.KEY_DOWN, ord('-')):
+ self.setValue(self._value - 1)
+ elif key == curses.KEY_PPAGE:
+ self.setValue(self._value - max(1, (self._max - self._min) // 5))
+ elif key == curses.KEY_NPAGE:
+ self.setValue(self._value + max(1, (self._max - self._min) // 5))
+ elif key == curses.KEY_HOME:
+ self.setValue(self._min)
+ elif key == curses.KEY_END:
+ self.setValue(self._max)
+ elif key in (ord('\n'), ord(' ')):
+ if self.notify():
+ dlg = self.findDialog()
+ if dlg is not None:
+ dlg._post_event(YWidgetEvent(self, YEventReason.Activated))
+ elif curses.ascii.isdigit(key):
+ # start inline editing with first digit
+ self._begin_edit(chr(key))
+ else:
+ handled = False
+
+ return handled
+
+ def _begin_edit(self, initial_char=None):
+ self._editing = True
+ self._edit_buf = "" if initial_char is None else str(initial_char)
+
+ def _cancel_edit(self):
+ self._editing = False
+ self._edit_buf = ""
+
+ def _commit_edit(self):
+ if self._edit_buf in ("", "-"):
+ self._cancel_edit()
+ return
+ try:
+ v = int(self._edit_buf)
+ except ValueError:
+ self._cancel_edit()
+ return
+ self.setValue(v)
+ self._cancel_edit()
+
+ def setVisible(self, visible=True):
+ super().setVisible(visible)
+ # in curses backend visibility controls whether widget can receive focus
+ self._can_focus = bool(visible)
\ No newline at end of file
diff --git a/manatools/aui/backends/curses/spacingcurses.py b/manatools/aui/backends/curses/spacingcurses.py
new file mode 100644
index 0000000..c93cbf8
--- /dev/null
+++ b/manatools/aui/backends/curses/spacingcurses.py
@@ -0,0 +1,83 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Curses backend: YSpacing implementation as a non-drawing spacer.
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.curses
+'''
+import logging
+from ...yui_common import *
+from .commoncurses import pixels_to_chars
+
+
+class YSpacingCurses(YWidget):
+ """Spacing/Stretch widget for curses.
+
+ - `dim`: primary dimension where spacing applies
+ - `stretchable`: if True, the spacing expands in its primary dimension
+ - `size`: spacing size expressed in pixels; converted to character cells
+ using the libyui 800x600→80x25 mapping (10 px/col, 24 px/row).
+ """
+ def __init__(self, parent=None, dim: YUIDimension = YUIDimension.YD_HORIZ, stretchable: bool = False, size_px: int = 0):
+ super().__init__(parent)
+ self._dim = dim
+ self._stretchable = bool(stretchable)
+ try:
+ spx = int(size_px)
+ self._size_px = 0 if spx <= 0 else max(1, spx)
+ except Exception:
+ self._size_px = 0
+ self._logger = logging.getLogger(f"manatools.aui.ncurses.{self.__class__.__name__}")
+ # Sync base stretch flags so containers can query stretchable()
+ try:
+ self.setStretchable(self._dim, self._stretchable)
+ except Exception:
+ pass
+ try:
+ self._logger.debug("%s.__init__(dim=%s, stretchable=%s, size_px=%d)", self.__class__.__name__, self._dim, self._stretchable, self._size_px)
+ except Exception:
+ pass
+
+ def widgetClass(self):
+ return "YSpacing"
+
+ def dimension(self):
+ return self._dim
+
+ def size(self):
+ return self._size_px
+
+ def sizeDim(self, dim: YUIDimension):
+ return self._size_px if dim == self._dim else 0
+
+ def _create_backend_widget(self):
+ # curses uses logical widget; no separate backend structure required
+ self._backend_widget = self
+ try:
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception:
+ pass
+
+ def _desired_height_for_width(self, width):
+ try:
+ if self._dim == YUIDimension.YD_VERT:
+ return pixels_to_chars(self._size_px, YUIDimension.YD_VERT)
+ except Exception:
+ pass
+ return 0
+
+ def minWidth(self):
+ try:
+ if self._dim == YUIDimension.YD_HORIZ:
+ return pixels_to_chars(self._size_px, YUIDimension.YD_HORIZ)
+ except Exception:
+ pass
+ return 0
+
+ def _draw(self, window, y, x, width, height):
+ # spacing draws nothing; reserved area remains blank
+ return
diff --git a/manatools/aui/backends/curses/tablecurses.py b/manatools/aui/backends/curses/tablecurses.py
new file mode 100644
index 0000000..50a50ee
--- /dev/null
+++ b/manatools/aui/backends/curses/tablecurses.py
@@ -0,0 +1,480 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.curses contains all curses backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.curses
+'''
+import curses
+import curses.ascii
+import logging
+from ...yui_common import *
+
+# Module-level logger for table curses backend
+_mod_logger = logging.getLogger("manatools.aui.curses.table.module")
+if not logging.getLogger().handlers:
+ _h = logging.StreamHandler()
+ _h.setFormatter(logging.Formatter("%(asctime)s %(name)s %(levelname)s: %(message)s"))
+ _mod_logger.addHandler(_h)
+ _mod_logger.setLevel(logging.INFO)
+
+
+class YTableCurses(YSelectionWidget):
+ """
+ NCurses implementation of a table widget.
+ - Renders column headers and rows in a fixed-width grid.
+ - Honors `YTableHeader` titles and alignments.
+ - Displays checkbox columns declared via `YTableHeader.isCheckboxColumn()` as [x]/[ ].
+ - Selection driven by `YTableItem.selected()`; emits SelectionChanged on change.
+ - SPACE toggles the first checkbox column for the current row and emits ValueChanged.
+ - ENTER toggles row selection (multi or single as configured).
+ """
+ def __init__(self, parent=None, header: YTableHeader = None, multiSelection: bool = False):
+ super().__init__(parent)
+ if header is None:
+ raise ValueError("YTableCurses requires a YTableHeader")
+ self._header = header
+ self._multi = bool(multiSelection)
+ # force single-selection if any checkbox column present
+ try:
+ for c_idx in range(self._header.columns()):
+ if self._header.isCheckboxColumn(c_idx):
+ self._multi = False
+ break
+ except Exception:
+ pass
+ self._logger = logging.getLogger(f"manatools.aui.ncurses.{self.__class__.__name__}")
+ if not self._logger.handlers and not logging.getLogger().handlers:
+ for h in _mod_logger.handlers:
+ self._logger.addHandler(h)
+ # UI state
+ self._height = 3 # header + at least 2 rows
+ self._can_focus = True
+ self._focused = False
+ self._hover_row = 0
+ self._scroll_offset = 0
+ self._selected_items = []
+ self._changed_item = None
+ self._current_visible_rows = None
+ # widget position
+ self._x = 0
+ self._y = 0
+ self.setStretchable(YUIDimension.YD_HORIZ, True)
+ self.setStretchable(YUIDimension.YD_VERT, True)
+
+ def widgetClass(self):
+ return "YTable"
+
+ def _first_checkbox_col(self):
+ try:
+ for c in range(self._header.columns()):
+ if self._header.isCheckboxColumn(c):
+ return c
+ except Exception:
+ pass
+ return None
+
+ def _create_backend_widget(self):
+ # Associate backend with self, compute minimal height, and reflect model selection.
+ self._backend_widget = self
+ self._height = max(3, 1 + min(len(self._items), 6))
+ # ensure visibility affects focusability on creation
+ try:
+ self._can_focus = bool(self._visible)
+ except Exception:
+ pass
+ # Build internal selection list from item flags
+ sel = []
+ try:
+ if self._multi:
+ for it in list(getattr(self, '_items', []) or []):
+ try:
+ if it.selected():
+ sel.append(it)
+ except Exception:
+ pass
+ else:
+ chosen = None
+ for it in list(getattr(self, '_items', []) or []):
+ try:
+ if it.selected():
+ chosen = it
+ except Exception:
+ pass
+ if chosen is not None:
+ sel = [chosen]
+ except Exception:
+ pass
+ self._selected_items = sel
+ self._hover_row = 0
+ self._scroll_offset = 0
+ self._current_visible_rows = None
+ self._logger.debug("_create_backend_widget: items=%d selected=%d", len(self._items) if self._items else 0, len(self._selected_items))
+ # respect initial enabled state
+ try:
+ self._set_backend_enabled(self.isEnabled())
+ except Exception:
+ pass
+
+ def _set_backend_enabled(self, enabled):
+ try:
+ self._can_focus = bool(enabled)
+ if not enabled:
+ self._focused = False
+ # propagate logical enabled state to contained items
+ for it in list(getattr(self, '_items', []) or []):
+ if hasattr(it, 'setEnabled'):
+ try:
+ it.setEnabled(enabled)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def _visible_row_count(self):
+ # number of rows available excluding the header line
+ return max(1, getattr(self, "_preferred_rows", 6))
+
+ def _ensure_hover_visible(self):
+ visible = self._current_visible_rows if self._current_visible_rows is not None else self._visible_row_count()
+ if visible <= 0:
+ return
+ if self._hover_row < self._scroll_offset:
+ self._scroll_offset = self._hover_row
+ elif self._hover_row >= self._scroll_offset + visible:
+ self._scroll_offset = self._hover_row - visible + 1
+
+ def _col_widths(self, total_width):
+ try:
+ cols = max(1, int(self._header.columns()))
+ except Exception:
+ cols = 1
+ # Divide width equally across columns, leaving 1 char separator
+ sep = 1
+ usable = max(1, total_width - (cols - 1) * sep)
+ base = max(1, usable // cols)
+ widths = [base] * cols
+ remainder = usable - base * cols
+ for i in range(remainder):
+ widths[i] += 1
+ return widths, sep
+
+ def _align_text(self, text, width, align: YAlignmentType):
+ s = str(text)[:width]
+ if align == YAlignmentType.YAlignCenter:
+ pad = max(0, width - len(s))
+ left = pad // 2
+ right = pad - left
+ return (" " * left) + s + (" " * right)
+ elif align == YAlignmentType.YAlignEnd:
+ return s.rjust(width)
+ else:
+ return s.ljust(width)
+
+ def _draw(self, window, y, x, width, height):
+ if self._visible is False:
+ return
+ try:
+ line = y
+ # Header
+ widths, sep = self._col_widths(width)
+ headers = []
+ try:
+ for c in range(self._header.columns()):
+ lbl = self._header.header(c)
+ align = self._header.alignment(c)
+ headers.append(self._align_text(lbl, widths[c], align))
+ except Exception:
+ # fallback single empty header
+ headers = [" ".ljust(widths[0])]
+ header_line = (" " * sep).join(headers)
+ # If multi-selection without checkbox columns, reserve left space for selection marker
+ use_selection_marker = False
+ try:
+ use_selection_marker = self._multi and (self._first_checkbox_col() is None)
+ except Exception:
+ use_selection_marker = False
+ if use_selection_marker:
+ header_line = " " + header_line
+ self._x = x
+ self._y = line
+ try:
+ window.addstr(line, x, header_line[:width], curses.A_BOLD)
+ except curses.error:
+ pass
+ line += 1
+
+ # Rows
+ available_rows = max(0, height - 1)
+ visible = min(len(self._items), available_rows)
+ if self.stretchable(YUIDimension.YD_VERT):
+ visible = min(len(self._items), available_rows)
+ else:
+ visible = min(len(self._items), self._visible_row_count(), available_rows)
+ self._current_visible_rows = visible
+
+ for i in range(visible):
+ row_idx = self._scroll_offset + i
+ if row_idx >= len(self._items):
+ break
+ it = self._items[row_idx]
+ cells = []
+ for c in range(len(widths)):
+ try:
+ cell = it.cell(c)
+ except Exception:
+ cell = None
+ is_cb = False
+ try:
+ is_cb = self._header.isCheckboxColumn(c)
+ except Exception:
+ is_cb = False
+ align = YAlignmentType.YAlignBegin
+ try:
+ align = self._header.alignment(c)
+ except Exception:
+ pass
+ if is_cb:
+ val = False
+ try:
+ val = cell.checked() if cell is not None else False
+ except Exception:
+ val = False
+ txt = "[x]" if val else "[ ]"
+ else:
+ txt = ""
+ try:
+ txt = cell.label() if cell is not None else ""
+ except Exception:
+ txt = ""
+ cells.append(self._align_text(txt, widths[c], align))
+ row_text = (" " * sep).join(cells)
+ # selection marker for multi-selection without checkbox columns
+ if use_selection_marker:
+ try:
+ marker = "[x] " if (it in self._selected_items) else "[ ] "
+ except Exception:
+ marker = "[ ] "
+ row_text = marker + row_text
+ attr = curses.A_NORMAL
+ if not self.isEnabled():
+ attr |= curses.A_DIM
+ if self._focused and row_idx == self._hover_row and self.isEnabled():
+ attr |= curses.A_REVERSE
+ try:
+ window.addstr(line + i, x, row_text[:width].ljust(width), attr)
+ except curses.error:
+ pass
+
+ # simple scroll indicators
+ if self._focused and len(self._items) > visible and width > 0 and self.isEnabled():
+ try:
+ if self._scroll_offset > 0:
+ window.addch(y + 1, x + width - 1, '↑', curses.A_REVERSE)
+ if (self._scroll_offset + visible) < len(self._items):
+ window.addch(y + visible, x + width - 1, '↓', curses.A_REVERSE)
+ except curses.error:
+ pass
+ except curses.error:
+ pass
+
+ def _handle_key(self, key):
+ if not self._focused or not self.isEnabled() or not self.visible():
+ return False
+ handled = True
+ if key == curses.KEY_UP:
+ if self._hover_row > 0:
+ self._hover_row -= 1
+ self._ensure_hover_visible()
+ elif key == curses.KEY_DOWN:
+ if self._hover_row < max(0, len(self._items) - 1):
+ self._hover_row += 1
+ self._ensure_hover_visible()
+ elif key == curses.KEY_PPAGE:
+ step = self._visible_row_count() or 1
+ self._hover_row = max(0, self._hover_row - step)
+ self._ensure_hover_visible()
+ elif key == curses.KEY_NPAGE:
+ step = self._visible_row_count() or 1
+ self._hover_row = min(max(0, len(self._items) - 1), self._hover_row + step)
+ self._ensure_hover_visible()
+ elif key == curses.KEY_HOME:
+ self._hover_row = 0
+ self._ensure_hover_visible()
+ elif key == curses.KEY_END:
+ self._hover_row = max(0, len(self._items) - 1)
+ self._ensure_hover_visible()
+ elif key in (ord(' '),): # toggle checkbox or selection if no checkbox columns
+ col = self._first_checkbox_col()
+ if 0 <= self._hover_row < len(self._items):
+ it = self._items[self._hover_row]
+ if col is not None:
+ # Toggle checkbox value
+ cell = None
+ try:
+ cell = it.cell(col)
+ except Exception:
+ cell = None
+ if cell is not None:
+ try:
+ cell.setChecked(not bool(cell.checked()))
+ self._changed_item = it
+ except Exception:
+ pass
+ # notify value changed
+ if self.notify():
+ dlg = self.findDialog()
+ if dlg is not None:
+ dlg._post_event(YWidgetEvent(self, YEventReason.ValueChanged))
+ else:
+ # Use SPACE to toggle row selection in multi-selection mode
+ if self._multi:
+ was_selected = it in self._selected_items
+ if was_selected:
+ try:
+ it.setSelected(False)
+ except Exception:
+ pass
+ try:
+ self._selected_items.remove(it)
+ except Exception:
+ pass
+ else:
+ try:
+ it.setSelected(True)
+ except Exception:
+ pass
+ self._selected_items.append(it)
+ # notify selection change
+ if self.notify():
+ dlg = self.findDialog()
+ if dlg is not None:
+ dlg._post_event(YWidgetEvent(self, YEventReason.SelectionChanged))
+ elif key in (ord('\n'),): # toggle row selection
+ if 0 <= self._hover_row < len(self._items):
+ it = self._items[self._hover_row]
+ if self._multi:
+ was_selected = it in self._selected_items
+ if was_selected:
+ try:
+ it.setSelected(False)
+ except Exception:
+ pass
+ try:
+ self._selected_items.remove(it)
+ except Exception:
+ pass
+ else:
+ try:
+ it.setSelected(True)
+ except Exception:
+ pass
+ self._selected_items.append(it)
+ else:
+ # single selection: make it sole selected
+ prev = self._selected_items[0] if self._selected_items else None
+ if prev is not None:
+ try:
+ prev.setSelected(False)
+ except Exception:
+ pass
+ try:
+ it.setSelected(True)
+ except Exception:
+ pass
+ self._selected_items = [it]
+ # notify selection change
+ if self.notify():
+ dlg = self.findDialog()
+ if dlg is not None:
+ dlg._post_event(YWidgetEvent(self, YEventReason.SelectionChanged))
+ else:
+ handled = False
+ return handled
+
+ # API
+ def addItem(self, item):
+ if isinstance(item, str):
+ item = YTableItem(item)
+ if not isinstance(item, YTableItem):
+ raise TypeError("YTableCurses.addItem expects a YTableItem or string label")
+ super().addItem(item)
+ try:
+ item.setIndex(len(self._items) - 1)
+ except Exception:
+ pass
+ # reflect initial selected flag into internal list
+ try:
+ if item.selected():
+ if not self._multi:
+ prev = self._selected_items[0] if self._selected_items else None
+ if prev is not None:
+ try:
+ prev.setSelected(False)
+ except Exception:
+ pass
+ self._selected_items = [item]
+ else:
+ if item not in self._selected_items:
+ self._selected_items.append(item)
+ except Exception:
+ pass
+
+ def selectItem(self, item, selected=True):
+ try:
+ item.setSelected(bool(selected))
+ except Exception:
+ pass
+ if selected:
+ if not self._multi:
+ prev = self._selected_items[0] if self._selected_items else None
+ if prev is not None:
+ try:
+ prev.setSelected(False)
+ except Exception:
+ pass
+ self._selected_items = [item]
+ else:
+ if item not in self._selected_items:
+ self._selected_items.append(item)
+ else:
+ try:
+ if item in self._selected_items:
+ self._selected_items.remove(item)
+ except Exception:
+ pass
+ # move hover to this item if present
+ try:
+ for i, it in enumerate(list(getattr(self, '_items', []) or [])):
+ if it is item:
+ self._hover_row = i
+ self._ensure_hover_visible()
+ break
+ except Exception:
+ pass
+
+ def deleteAllItems(self):
+ try:
+ super().deleteAllItems()
+ except Exception:
+ self._items = []
+ try:
+ self._selected_items = []
+ self._hover_row = 0
+ self._scroll_offset = 0
+ self._current_visible_rows = None
+ self._changed_item = None
+ except Exception:
+ pass
+
+ def changedItem(self):
+ return getattr(self, "_changed_item", None)
+
+ def setVisible(self, visible: bool = True):
+ super().setVisible(visible)
+ # in curses backend visibility controls whether widget can receive focus
+ self._can_focus = bool(visible)
\ No newline at end of file
diff --git a/manatools/aui/backends/curses/timefieldcurses.py b/manatools/aui/backends/curses/timefieldcurses.py
new file mode 100644
index 0000000..2c130cf
--- /dev/null
+++ b/manatools/aui/backends/curses/timefieldcurses.py
@@ -0,0 +1,182 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.curses contains all curses backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.curses
+'''
+import curses
+import curses.ascii
+import logging
+from ...yui_common import *
+
+
+class YTimeFieldCurses(YWidget):
+ """NCurses backend YTimeField with three integer segments (H, M, S).
+ value()/setValue() use HH:MM:SS. No change events posted.
+ Navigation: Left/Right to change segment, Up/Down or +/- to change value, digits to type.
+ """
+ def __init__(self, parent=None, label: str = ""):
+ super().__init__(parent)
+ self._logger = logging.getLogger(f"manatools.aui.ncurses.{self.__class__.__name__}")
+ self._label = label or ""
+ self._h, self._m, self._s = 0, 0, 0
+ self._seg_index = 0 # 0..2
+ self._editing = False
+ self._edit_buf = ""
+ self._can_focus = True
+ self._focused = False
+ # Default: do not stretch horizontally or vertically; respects external overrides
+ try:
+ self.setStretchable(YUIDimension.YD_HORIZ, False)
+ self.setStretchable(YUIDimension.YD_VERT, False)
+ except Exception:
+ pass
+
+ def widgetClass(self):
+ return "YTimeField"
+
+ def value(self) -> str:
+ return f"{self._h:02d}:{self._m:02d}:{self._s:02d}"
+
+ def setValue(self, timestr: str):
+ try:
+ h, m, s = [int(p) for p in str(timestr).split(':')]
+ except Exception:
+ return
+ self._h = max(0, min(23, h))
+ self._m = max(0, min(59, m))
+ self._s = max(0, min(59, s))
+
+ def _create_backend_widget(self):
+ self._backend_widget = self
+ try:
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception:
+ pass
+
+ def _set_backend_enabled(self, enabled):
+ pass
+
+ def _draw(self, window, y, x, width, height):
+ if self._visible is False:
+ return
+ try:
+ line = y
+ label_to_show = self._label if self._label else (self.debugLabel() if hasattr(self, 'debugLabel') else "unknown")
+ try:
+ window.addstr(line, x, label_to_show[:width])
+ except curses.error:
+ pass
+ line += 1
+
+ parts = {'H': f"{self._h:02d}", 'M': f"{self._m:02d}", 'S': f"{self._s:02d}"}
+ disp = []
+ order = ['H', 'M', 'S']
+ for idx, p in enumerate(order):
+ seg_text = parts[p]
+ if self._focused and idx == self._seg_index:
+ if self._editing:
+ buf = self._edit_buf or ''
+ seg_w = 2
+ buf_disp = buf.rjust(seg_w)
+ text = f"[{buf_disp}]"
+ else:
+ text = f"[{seg_text}]"
+ else:
+ text = f" {seg_text} "
+ disp.append(text)
+ if idx < 2:
+ disp.append(":")
+ out = ''.join(disp)
+ try:
+ window.addstr(line, x, out[:max(0, width)])
+ except curses.error:
+ pass
+ except curses.error:
+ pass
+
+ def _handle_key(self, key):
+ if not self._focused or not self.isEnabled() or not self.visible():
+ return False
+
+ if self._editing:
+ if key in (curses.KEY_BACKSPACE, 127, 8):
+ if self._edit_buf:
+ self._edit_buf = self._edit_buf[:-1]
+ return True
+ if curses.ascii.isdigit(key):
+ self._edit_buf += chr(key)
+ return True
+ if key in (ord('\n'), ord(' ')):
+ self._commit_edit()
+ return True
+ if key == 27: # ESC
+ self._cancel_edit()
+ return True
+ if key in (curses.KEY_LEFT, curses.KEY_RIGHT, curses.KEY_UP, curses.KEY_DOWN, ord('+'), ord('-')):
+ self._commit_edit()
+ # Non-editing navigation
+ if key in (curses.KEY_LEFT,):
+ self._seg_index = (self._seg_index - 1) % 3
+ return True
+ if key in (curses.KEY_RIGHT,):
+ self._seg_index = (self._seg_index + 1) % 3
+ return True
+ if key in (curses.KEY_UP, ord('+')):
+ self._bump(+1)
+ return True
+ if key in (curses.KEY_DOWN, ord('-')):
+ self._bump(-1)
+ return True
+ if curses.ascii.isdigit(key):
+ self._begin_edit(chr(key))
+ return True
+ return False
+
+ def _seg_ref(self, idx):
+ seg = ['H', 'M', 'S'][idx]
+ if seg == 'H':
+ return '_h', 0, 23
+ if seg == 'M':
+ return '_m', 0, 59
+ return '_s', 0, 59
+
+ def _bump(self, delta):
+ name, lo, hi = self._seg_ref(self._seg_index)
+ val = getattr(self, name)
+ val += delta
+ if val < lo: val = lo
+ if val > hi: val = hi
+ setattr(self, name, val)
+
+ def _begin_edit(self, initial_char=None):
+ self._editing = True
+ self._edit_buf = '' if initial_char is None else str(initial_char)
+
+ def _cancel_edit(self):
+ self._editing = False
+ self._edit_buf = ''
+
+ def _commit_edit(self):
+ if self._edit_buf in ('', ':'):
+ self._cancel_edit()
+ return
+ try:
+ v = int(self._edit_buf)
+ except ValueError:
+ self._cancel_edit()
+ return
+ name, lo, hi = self._seg_ref(self._seg_index)
+ v = max(lo, min(hi, v))
+ setattr(self, name, v)
+ self._cancel_edit()
+
+ def setVisible(self, visible=True):
+ super().setVisible(visible)
+ # in curses backend visibility controls whether widget can receive focus
+ self._can_focus = bool(visible)
\ No newline at end of file
diff --git a/manatools/aui/backends/curses/treecurses.py b/manatools/aui/backends/curses/treecurses.py
new file mode 100644
index 0000000..f863c05
--- /dev/null
+++ b/manatools/aui/backends/curses/treecurses.py
@@ -0,0 +1,775 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.curses contains all curses backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.curses
+'''
+import curses
+import curses.ascii
+import sys
+import os
+import time
+import logging
+from ...yui_common import *
+
+# Module-level logger for tree curses backend
+_mod_logger = logging.getLogger("manatools.aui.curses.tree.module")
+if not logging.getLogger().handlers:
+ _h = logging.StreamHandler()
+ _h.setFormatter(logging.Formatter("%(asctime)s %(name)s %(levelname)s: %(message)s"))
+ _mod_logger.addHandler(_h)
+ _mod_logger.setLevel(logging.INFO)
+
+
+class YTreeCurses(YSelectionWidget):
+ """
+ NCurses implementation of a tree widget.
+ - Flattens visible nodes according to YTreeItem._is_open
+ - Supports single/multi selection and recursive selection propagation
+ - Preserves per-item selected() / setSelected() semantics and restores selections on rebuild
+ - Keyboard: Up/Down/PageUp/PageDown/Home/End, SPACE = expand/collapse, ENTER = select/deselect
+ """
+ def __init__(self, parent=None, label="", multiselection=False, recursiveselection=False):
+ super().__init__(parent)
+ self._label = label
+ self._multi = bool(multiselection)
+ self._recursive = bool(recursiveselection)
+ if self._recursive:
+ self._multi = True
+ self._immediate = self.notify()
+ # Minimal height (items area) requested by this widget
+ self._min_height = 6
+ # Preferred height exposed to layout should include label line if any
+ self._height = self._min_height + (1 if self._label else 0)
+ self._can_focus = True
+ self._focused = False
+ self._hover_index = 0
+ self._scroll_offset = 0
+ self._visible_items = []
+ self._selected_items = []
+ self._last_selected_ids = set()
+ self._suppress_selection_handler = False
+ self.setStretchable(YUIDimension.YD_HORIZ, True)
+ self.setStretchable(YUIDimension.YD_VERT, True)
+ # per-instance logger
+ self._logger = logging.getLogger(f"manatools.aui.ncurses.{self.__class__.__name__}")
+ if not self._logger.handlers and not logging.getLogger().handlers:
+ for h in _mod_logger.handlers:
+ self._logger.addHandler(h)
+ # debuglabel: class and initial label
+ try:
+ self._logger.debug("%s.__init__ label=%s multiselection=%s recursive=%s", self.__class__.__name__, label, multiselection, recursiveselection)
+ except Exception:
+ pass
+
+ def widgetClass(self):
+ return "YTree"
+
+ def hasMultiSelection(self):
+ """Return True if the tree allows selecting multiple items at once."""
+ return bool(self._multi)
+
+ def immediateMode(self):
+ return bool(self._immediate)
+
+ def setImmediateMode(self, on:bool=True):
+ self._immediate = on
+ self.setNotify(on)
+
+ def _create_backend_widget(self):
+ try:
+ # Keep preferred minimum for the layout (items + optional label)
+ self._height = max(self._height, self._min_height + (1 if self._label else 0))
+ # associate placeholder backend widget to avoid None
+ self._backend_widget = self
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ self._rebuildTree()
+ except Exception as e:
+ try:
+ self._logger.critical("_create_backend_widget critical error: %s", e, exc_info=True)
+ except Exception:
+ _mod_logger.critical("_create_backend_widget critical error: %s", e, exc_info=True)
+
+ def addItem(self, item):
+ """Ensure base storage gets the item and rebuild visible list immediately."""
+ try:
+ # prefer base implementation if present
+ try:
+ super().addItem(item)
+ except Exception:
+ # fallback: append to _items list used by this backend
+ if not hasattr(self, "_items") or self._items is None:
+ self._items = []
+ self._items.append(item)
+ finally:
+ try:
+ # mark rebuild so new items are visible without waiting for external trigger
+ self._rebuildTree()
+ except Exception:
+ pass
+
+ def addItems(self, items):
+ '''Add multiple items to the table. This is more efficient than calling addItem repeatedly.'''
+ try:
+ for item in items:
+ # prefer base implementation if present
+ try:
+ super().addItem(item)
+ except Exception:
+ # fallback: append to _items list used by this backend
+ if not hasattr(self, "_items") or self._items is None:
+ self._items = []
+ self._items.append(item)
+ finally:
+ try:
+ # mark rebuild so new items are visible without waiting for external trigger
+ self._rebuildTree()
+ except Exception:
+ pass
+
+ def removeItem(self, item):
+ """Remove item from internal list and rebuild."""
+ try:
+ try:
+ super().removeItem(item)
+ except Exception:
+ if hasattr(self, "_items") and item in self._items:
+ try:
+ self._items.remove(item)
+ except Exception:
+ pass
+ finally:
+ try:
+ self._rebuildTree()
+ except Exception:
+ pass
+
+ def deleteAllItems(self):
+ """Clear model and all internal state for this tree."""
+ self._suppress_selection_handler = True
+ try:
+ try:
+ super().deleteAllItems()
+ except Exception:
+ self._items = []
+ except Exception:
+ pass
+ # Reset selection and visibility state
+ try:
+ self._selected_items = []
+ self._last_selected_ids = set()
+ self._visible_items = []
+ self._hover_index = 0
+ self._scroll_offset = 0
+ except Exception:
+ pass
+ try:
+ # No items to show; nothing else to rebuild but ensure state consistent
+ self._flatten_visible()
+ except Exception:
+ pass
+ self._suppress_selection_handler = False
+
+ def _collect_all_descendants(self, item):
+ out = []
+ stack = []
+ try:
+ for c in getattr(item, "_children", []) or []:
+ stack.append(c)
+ except Exception:
+ pass
+ while stack:
+ cur = stack.pop()
+ out.append(cur)
+ try:
+ for ch in getattr(cur, "_children", []) or []:
+ stack.append(ch)
+ except Exception:
+ pass
+ return out
+
+ def _clear_all_selected(self):
+ """Clear the selected flag recursively across the entire tree."""
+ try:
+ roots = list(getattr(self, "_items", []) or [])
+ except Exception:
+ roots = []
+ def _clear(nodes):
+ for n in nodes:
+ try:
+ n.setSelected(False)
+ except Exception:
+ try:
+ setattr(n, "_selected", False)
+ except Exception:
+ pass
+ try:
+ chs = callable(getattr(n, "children", None)) and n.children() or getattr(n, "_children", []) or []
+ except Exception:
+ chs = getattr(n, "_children", []) or []
+ if chs:
+ _clear(chs)
+ _clear(roots)
+
+ def _flatten_visible(self):
+ """Produce self._visible_items = [(item, depth), ...] following _is_open flags."""
+ self._visible_items = []
+ def _visit(nodes, depth=0):
+ for n in nodes:
+ self._visible_items.append((n, depth))
+ try:
+ is_open = bool(getattr(n, "_is_open", False))
+ except Exception:
+ is_open = False
+ if is_open:
+ try:
+ childs = callable(getattr(n, "children", None)) and n.children() or getattr(n, "_children", []) or []
+ except Exception:
+ childs = getattr(n, "_children", []) or []
+ if childs:
+ _visit(childs, depth + 1)
+ roots = list(getattr(self, "_items", []) or [])
+ _visit(roots, 0)
+
+ def rebuildTree(self):
+ """RebuildTree to maintain compatibility."""
+ self._logger.warning("rebuildTree is deprecated and should not be needed anymore")
+ self._rebuildTree()
+
+ def _rebuildTree(self):
+ """Recompute visible items and restore selection from item.selected() or last_selected_ids.
+
+ Ensures ancestors of selected items are opened so selections are visible.
+ """
+ # preserve items selection if any
+ self._suppress_selection_handler = True
+ try:
+ # collect all nodes and desired selected ids (from last ids or model flags)
+ def _all_nodes(nodes):
+ out = []
+ for n in nodes:
+ out.append(n)
+ try:
+ chs = callable(getattr(n, "children", None)) and n.children() or getattr(n, "_children", []) or []
+ except Exception:
+ chs = getattr(n, "_children", []) or []
+ if chs:
+ out.extend(_all_nodes(chs))
+ return out
+ roots = list(getattr(self, "_items", []) or [])
+ all_nodes = _all_nodes(roots)
+
+ selected_ids = set(self._last_selected_ids) if self._last_selected_ids else set()
+ if not selected_ids:
+ for n in all_nodes:
+ try:
+ sel = False
+ if hasattr(n, "selected") and callable(getattr(n, "selected")):
+ sel = n.selected()
+ else:
+ sel = bool(getattr(n, "_selected", False))
+ if sel:
+ selected_ids.add(id(n))
+ except Exception:
+ pass
+
+ # open ancestors for any selected node so it becomes visible
+ if selected_ids:
+ for n in list(all_nodes):
+ try:
+ if id(n) in selected_ids:
+ parent = None
+ try:
+ parent = n.parentItem() if callable(getattr(n, 'parentItem', None)) else getattr(n, '_parent_item', None)
+ except Exception:
+ parent = getattr(n, '_parent_item', None)
+ while parent:
+ try:
+ try:
+ parent.setOpen(True)
+ except Exception:
+ setattr(parent, '_is_open', True)
+ except Exception:
+ pass
+ try:
+ parent = parent.parentItem() if callable(getattr(parent, 'parentItem', None)) else getattr(parent, '_parent_item', None)
+ except Exception:
+ break
+ except Exception:
+ pass
+
+ # now recompute visible list based on possibly opened parents
+ self._flatten_visible()
+
+ # In single-selection mode, clamp desired selection to a single item.
+ if selected_ids and not self._multi:
+ chosen_id = None
+ # Prefer a visible item
+ for itm, _d in self._visible_items:
+ try:
+ if id(itm) in selected_ids:
+ chosen_id = id(itm)
+ break
+ except Exception:
+ pass
+ if chosen_id is None:
+ try:
+ # fallback: arbitrary one
+ chosen_id = next(iter(selected_ids))
+ except Exception:
+ chosen_id = None
+ selected_ids = {chosen_id} if chosen_id is not None else set()
+ # collect from items' selected() property only (YTreeItem wins)
+ selected_ids = set()
+ try:
+ def _collect_selected(nodes):
+ out = []
+ for n in nodes:
+ try:
+ sel = False
+ if hasattr(n, "selected") and callable(getattr(n, "selected")):
+ sel = n.selected()
+ else:
+ sel = bool(getattr(n, "_selected", False))
+ if sel:
+ out.append(n)
+ except Exception:
+ pass
+ try:
+ chs = callable(getattr(n, "children", None)) and n.children() or getattr(n, "_children", []) or []
+ except Exception:
+ chs = getattr(n, "_children", []) or []
+ if chs:
+ out.extend(_collect_selected(chs))
+ return out
+ pre_selected = _collect_selected(list(getattr(self, "_items", []) or []))
+ for p in pre_selected:
+ selected_ids.add(id(p))
+ except Exception:
+ pass
+ # build logical selected list and last_selected_ids
+ sel_items = []
+ for itm, _d in self._visible_items:
+ try:
+ if id(itm) in selected_ids:
+ sel_items.append(itm)
+ except Exception:
+ pass
+ # also include non-visible selected nodes (descendants) when tracking logic
+ if selected_ids:
+ for n in all_nodes:
+ if id(n) in selected_ids and n not in sel_items:
+ sel_items.append(n)
+ # Ensure single-selection list contains only one item
+ if not self._multi and len(sel_items) > 1:
+ # Prefer the visible one if present
+ visible_ids = {id(itm) for itm, _d in self._visible_items}
+ chosen = None
+ for it in sel_items:
+ if id(it) in visible_ids:
+ chosen = it
+ break
+ if chosen is None:
+ chosen = sel_items[0]
+ sel_items = [chosen]
+ # apply selected flags to items consistently
+ try:
+ # clear all first
+ def _clear(nodes):
+ for n in nodes:
+ try:
+ n.setSelected(False)
+ except Exception:
+ pass
+ try:
+ chs = callable(getattr(n, "children", None)) and n.children() or getattr(n, "_children", []) or []
+ except Exception:
+ chs = getattr(n, "_children", []) or []
+ if chs:
+ _clear(chs)
+ _clear(list(getattr(self, "_items", []) or []))
+ except Exception:
+ pass
+ for it in sel_items:
+ try:
+ it.setSelected(True)
+ except Exception:
+ pass
+ self._selected_items = list(sel_items)
+ self._last_selected_ids = set(id(i) for i in self._selected_items)
+ # ensure hover_index valid
+ if self._hover_index >= len(self._visible_items):
+ self._hover_index = max(0, len(self._visible_items) - 1)
+ self._ensure_hover_visible()
+ except Exception:
+ pass
+ self._suppress_selection_handler = False
+
+ def _ensure_hover_visible(self, height=None):
+ """Adjust scroll offset so hover visible in given height area (if None use last draw height)."""
+ try:
+ # height param is number of rows available for items display (excluding label)
+ if height is None:
+ height = max(1, getattr(self, "_height", 1))
+ visible = max(1, height)
+ if self._hover_index < self._scroll_offset:
+ self._scroll_offset = self._hover_index
+ elif self._hover_index >= self._scroll_offset + visible:
+ self._scroll_offset = self._hover_index - visible + 1
+ except Exception:
+ pass
+
+ def _toggle_expand(self, item):
+ try:
+ self._suppress_selection_handler = True
+ except Exception:
+ pass
+ try:
+ try:
+ cur = item.isOpen()
+ item.setOpen(not cur)
+ except Exception:
+ try:
+ cur = bool(getattr(item, "_is_open", False))
+ item._is_open = not cur
+ except Exception:
+ pass
+ # preserve selected ids and rebuild
+ try:
+ self._last_selected_ids = set(id(i) for i in getattr(self, "_selected_items", []) or [])
+ except Exception:
+ self._last_selected_ids = set()
+ self._rebuildTree()
+ finally:
+ try:
+ self._suppress_selection_handler = False
+ except Exception:
+ pass
+
+ def _handle_selection_action(self, item):
+ """Toggle selection (ENTER) respecting multi/single & recursive semantics."""
+ if item is None:
+ return
+ try:
+ if self._multi:
+ # toggle membership
+ if item in self._selected_items:
+ # deselect item and (if recursive) descendants
+ if self._recursive:
+ to_remove = {item} | set(self._collect_all_descendants(item))
+ self._selected_items = [it for it in self._selected_items if it not in to_remove]
+ for it in to_remove:
+ try:
+ it.setSelected(False)
+ except Exception:
+ try:
+ setattr(it, "_selected", False)
+ except Exception:
+ pass
+ else:
+ try:
+ self._selected_items.remove(item)
+ except Exception:
+ pass
+ try:
+ item.setSelected(False)
+ except Exception:
+ try:
+ setattr(item, "_selected", False)
+ except Exception:
+ pass
+ else:
+ # select item and possibly descendants
+ if self._recursive:
+ to_add = [item] + self._collect_all_descendants(item)
+ for it in to_add:
+ if it not in self._selected_items:
+ self._selected_items.append(it)
+ try:
+ it.setSelected(True)
+ except Exception:
+ try:
+ setattr(it, "_selected", True)
+ except Exception:
+ pass
+ else:
+ self._selected_items.append(item)
+ try:
+ item.setSelected(True)
+ except Exception:
+ try:
+ setattr(item, "_selected", True)
+ except Exception:
+ pass
+ else:
+ # single selection: clear all flags recursively and set this one
+ try:
+ self._clear_all_selected()
+ except Exception:
+ pass
+ self._selected_items = [item]
+ try:
+ item.setSelected(True)
+ except Exception:
+ try:
+ setattr(item, "_selected", True)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ # update last_selected_ids and notify
+ try:
+ self._last_selected_ids = set(id(i) for i in self._selected_items)
+ except Exception:
+ self._last_selected_ids = set()
+ if self._immediate and self.notify():
+ dlg = self.findDialog()
+ if dlg:
+ dlg._post_event(YWidgetEvent(self, YEventReason.SelectionChanged))
+
+ def _draw(self, window, y, x, width, height):
+ """Draw tree in provided rectangle. Expects height rows available."""
+ if self._visible is False:
+ return
+ try:
+ # compute drawing area for items (first row may be label)
+ line = y
+ start_line = line
+ label_rows = 1 if self._label else 0
+
+ # Draw label
+ if self._label:
+ try:
+ window.addstr(line, x, self._label[:width], curses.A_BOLD)
+ except curses.error:
+ pass
+ line += 1
+
+ # Actual rows given by parent for items
+ available_rows = max(0, height - label_rows)
+ # Keep _height as the current viewport rows (items area), not the preferred minimum
+ self._height = max(1, available_rows)
+
+ # record last draw height for navigation/ensure logic
+ self._height = available_rows
+ # rebuild visible items (safe cheap operation)
+ self._flatten_visible()
+ total = len(self._visible_items)
+ if total == 0:
+ try:
+ if available_rows > 0:
+ window.addstr(line, x, "(empty)", curses.A_DIM)
+ except curses.error:
+ pass
+ return
+
+ # Clamp scroll/hover to the viewport
+ self._ensure_hover_visible(height=self._height)
+
+ # Draw only inside the allocated rectangle
+ draw_rows = min(available_rows, max(0, total - self._scroll_offset))
+ for i in range(draw_rows):
+ idx = self._scroll_offset + i
+ if idx >= total:
+ break
+ itm, depth = self._visible_items[idx]
+ is_selected = itm in self._selected_items
+ # expander, text, attrs...
+ try:
+ has_children = bool(getattr(itm, "_children", []) or (callable(getattr(itm, "children", None)) and (itm.children() or [])))
+ except Exception:
+ has_children = False
+ try:
+ is_open = bool(getattr(itm, "_is_open", False))
+ except Exception:
+ is_open = False
+ exp = "▾" if (has_children and is_open) else ("▸" if has_children else " ")
+ checkbox = "*" if is_selected else " "
+ indent = " " * (depth * 2)
+ text = f"{indent}{exp} [{checkbox}] {itm.label()}"
+ if len(text) > width:
+ text = text[:max(0, width - 1)] + "…"
+ attr = curses.A_REVERSE if (self._focused and idx == self._hover_index and self.isEnabled()) else curses.A_NORMAL
+ if not self.isEnabled():
+ attr |= curses.A_DIM
+ try:
+ window.addstr(line + i, x, text.ljust(width), attr)
+ except curses.error:
+ pass
+
+ # Scroll indicators based on actual viewport rows
+ try:
+ if self._scroll_offset > 0 and available_rows > 0:
+ window.addch(y + label_rows, x + max(0, width - 1), '↑', curses.A_REVERSE)
+ if (self._scroll_offset + available_rows) < total and available_rows > 0:
+ window.addch(y + label_rows + min(available_rows - 1, total - 1), x + max(0, width - 1), '↓', curses.A_REVERSE)
+ except curses.error:
+ pass
+ except Exception:
+ pass
+
+ def _handle_key(self, key):
+ """Keyboard handling: navigation, expand (SPACE), select (ENTER)."""
+ if not self._focused or not self.isEnabled() or not self.visible():
+ return False
+ handled = True
+ total = len(self._visible_items)
+ if key == curses.KEY_UP:
+ if self._hover_index > 0:
+ self._hover_index -= 1
+ self._ensure_hover_visible(self._height)
+ elif key == curses.KEY_DOWN:
+ if self._hover_index < max(0, total - 1):
+ self._hover_index += 1
+ self._ensure_hover_visible(self._height)
+ elif key == curses.KEY_PPAGE:
+ step = max(1, self._height)
+ self._hover_index = max(0, self._hover_index - step)
+ self._ensure_hover_visible(self._height)
+ elif key == curses.KEY_NPAGE:
+ step = max(1, self._height)
+ self._hover_index = min(max(0, total - 1), self._hover_index + step)
+ self._ensure_hover_visible(self._height)
+ elif key == curses.KEY_HOME:
+ self._hover_index = 0
+ self._ensure_hover_visible(self._height)
+ elif key == curses.KEY_END:
+ self._hover_index = max(0, total - 1)
+ self._ensure_hover_visible(self._height)
+ elif key in (ord(' '),): # SPACE toggles expansion per dialog footer convention
+ if 0 <= self._hover_index < total:
+ itm, _ = self._visible_items[self._hover_index]
+ # Toggle expand/collapse without changing selection
+ self._toggle_expand(itm)
+ elif key in (ord('\n'),): # ENTER toggles selection
+ if 0 <= self._hover_index < total:
+ itm, _ = self._visible_items[self._hover_index]
+ self._handle_selection_action(itm)
+ else:
+ handled = False
+ return handled
+
+ def currentItem(self):
+ try:
+ # Prefer explicit selected_items; if empty return hovered visible item (useful after selection)
+ if self._selected_items:
+ return self._selected_items[0]
+ # fallback: return hovered visible item if any
+ if 0 <= self._hover_index < len(getattr(self, "_visible_items", [])):
+ return self._visible_items[self._hover_index][0]
+ return None
+ except Exception:
+ return None
+
+ def getSelectedItems(self):
+ return list(self._selected_items)
+
+ def selectItem(self, item, selected=True):
+ """Programmatic select/deselect that respects recursive flag."""
+ if item is None:
+ return
+ try:
+ if selected:
+ if not self._multi:
+ # clear others recursively and select only this one
+ try:
+ self._clear_all_selected()
+ except Exception:
+ pass
+ try:
+ item.setSelected(True)
+ except Exception:
+ try:
+ setattr(item, "_selected", True)
+ except Exception:
+ pass
+ self._selected_items = [item]
+ else:
+ if item not in self._selected_items:
+ try:
+ item.setSelected(True)
+ except Exception:
+ try:
+ setattr(item, "_selected", True)
+ except Exception:
+ pass
+ self._selected_items.append(item)
+ if self._recursive:
+ for d in self._collect_all_descendants(item):
+ if d not in self._selected_items:
+ try:
+ d.setSelected(True)
+ except Exception:
+ try:
+ setattr(d, "_selected", True)
+ except Exception:
+ pass
+ self._selected_items.append(d)
+ # open parents so programmatically selected items are visible
+ try:
+ parent = item.parentItem() if callable(getattr(item, 'parentItem', None)) else getattr(item, '_parent_item', None)
+ except Exception:
+ parent = getattr(item, '_parent_item', None)
+ while parent:
+ try:
+ try:
+ parent.setOpen(True)
+ except Exception:
+ setattr(parent, '_is_open', True)
+ except Exception:
+ pass
+ try:
+ parent = parent.parentItem() if callable(getattr(parent, 'parentItem', None)) else getattr(parent, '_parent_item', None)
+ except Exception:
+ break
+ else:
+ # deselect
+ if item in self._selected_items:
+ try:
+ self._selected_items.remove(item)
+ except Exception:
+ pass
+ try:
+ item.setSelected(False)
+ except Exception:
+ try:
+ setattr(item, "_selected", False)
+ except Exception:
+ pass
+ if self._recursive:
+ for d in self._collect_all_descendants(item):
+ if d in self._selected_items:
+ try:
+ self._selected_items.remove(d)
+ except Exception:
+ pass
+ try:
+ d.setSelected(False)
+ except Exception:
+ try:
+ setattr(d, "_selected", False)
+ except Exception:
+ pass
+ # update last ids
+ try:
+ self._last_selected_ids = set(id(i) for i in self._selected_items)
+ except Exception:
+ self._last_selected_ids = set()
+ # after programmatic selection, rebuild visible list to reflect opened parents
+ try:
+ self._rebuildTree()
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def setVisible(self, visible=True):
+ super().setVisible(visible)
+ # in curses backend visibility controls whether widget can receive focus
+ self._can_focus = bool(visible)
\ No newline at end of file
diff --git a/manatools/aui/backends/curses/vboxcurses.py b/manatools/aui/backends/curses/vboxcurses.py
new file mode 100644
index 0000000..d341f24
--- /dev/null
+++ b/manatools/aui/backends/curses/vboxcurses.py
@@ -0,0 +1,251 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.curses contains all curses backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.curses
+'''
+import curses
+import curses.ascii
+import sys
+import os
+import time
+import logging
+from ...yui_common import *
+
+from .commoncurses import _curses_recursive_min_height
+
+# Module-level logger for vbox curses backend
+_mod_logger = logging.getLogger("manatools.aui.curses.vbox.module")
+if not logging.getLogger().handlers:
+ _h = logging.StreamHandler()
+ _h.setFormatter(logging.Formatter("%(asctime)s %(name)s %(levelname)s: %(message)s"))
+ _mod_logger.addHandler(_h)
+ _mod_logger.setLevel(logging.INFO)
+
+class YVBoxCurses(YWidget):
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ # per-instance logger
+ self._logger = logging.getLogger(f"manatools.aui.ncurses.{self.__class__.__name__}")
+ if not self._logger.handlers and not logging.getLogger().handlers:
+ for h in _mod_logger.handlers:
+ self._logger.addHandler(h)
+ self._logger.debug("%s.__init__", self.__class__.__name__)
+
+ def widgetClass(self):
+ return "YVBox"
+
+ # Returns the stretchability of the layout box:
+ # * The layout box is stretchable if one of the children is stretchable in
+ # * this dimension or if one of the child widgets has a layout weight in
+ # * this dimension.
+ def stretchable(self, dim):
+ for child in self._children:
+ widget = child.get_backend_widget()
+ expand = bool(child.stretchable(dim))
+ weight = bool(child.weight(dim))
+ if expand or weight:
+ return True
+ # No child is stretchable in this dimension
+ return False
+
+ def _create_backend_widget(self):
+ try:
+ self._backend_widget = self
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception:
+ _mod_logger.error("_create_backend_widget error: %s", e, exc_info=True)
+
+ def _set_backend_enabled(self, enabled):
+ """Enable/disable VBox and propagate to logical children."""
+ try:
+ for c in list(getattr(self, "_children", []) or []):
+ try:
+ c.setEnabled(enabled)
+ except Exception:
+ try:
+ self._logger.error("_set_backend_enabled child setEnabled error", exc_info=True)
+ except Exception:
+ _mod_logger.error("_set_backend_enabled child setEnabled error", exc_info=True)
+ except Exception:
+ try:
+ self._logger.error("_set_backend_enabled error", exc_info=True)
+ except Exception:
+ _mod_logger.error("_set_backend_enabled error", exc_info=True)
+
+ def _draw(self, window, y, x, width, height):
+ # Vertical layout with spacing; give stretchable children more than their minimum
+ num_children = len(self._children)
+ if num_children == 0 or height <= 0 or width <= 0:
+ return
+
+ spacing = max(0, num_children - 1)
+
+ # Compute both minimal heights and preferred (requested) heights
+ child_min_heights = []
+ child_pref_heights = []
+ stretchable_indices = []
+ stretchable_weights = []
+ fixed_height_total = 0
+
+ for i, child in enumerate(self._children):
+ if child.visible() is False:
+ child_min_heights.append(0)
+ child_pref_heights.append(0)
+ continue
+ # Minimal height (hard lower bound)
+ min_h = max(1, _curses_recursive_min_height(child))
+ # Preferred/requested height (may be larger than min_h)
+ pref_h = min_h
+ try:
+ # explicit _height attribute may express a preferred size
+ if hasattr(child, "_height"):
+ h = int(getattr(child, "_height", pref_h))
+ pref_h = max(pref_h, h)
+ except Exception:
+ pass
+ try:
+ if hasattr(child, "_desired_height_for_width"):
+ dh = int(child._desired_height_for_width(width))
+ pref_h = max(pref_h, dh)
+ except Exception:
+ pass
+ child_min_heights.append(min_h)
+ child_pref_heights.append(pref_h)
+
+ is_stretch = bool(child.stretchable(YUIDimension.YD_VERT))
+ if is_stretch:
+ stretchable_indices.append(i)
+ try:
+ w = child.weight(YUIDimension.YD_VERT)
+ # normalize weight to 0..100; default 0
+ w = int(w) if w is not None else 0
+ except Exception:
+ w = 0
+ if w < 0:
+ w = 0
+ stretchable_weights.append(w)
+ else:
+ fixed_height_total += min_h
+
+ # Determine spacing budget and allocate stretch space
+ # Compute minimal totals and decide allowed gaps
+ min_total = sum(child_min_heights)
+ # If even minimal sizes plus spacing don't fit, reduce spacing first
+ if min_total + spacing > height:
+ spacing_allowed = max(0, height - min_total)
+ else:
+ spacing_allowed = spacing
+
+ # Preferred total is sum of preferred heights
+ pref_total = sum(child_pref_heights)
+
+ # Start allocation from preferred heights clamped to available space
+ allocated = list(child_pref_heights)
+
+ # If preferred total plus spacing fits, we'll later distribute surplus
+ total_with_pref = pref_total + spacing_allowed
+ if total_with_pref <= height:
+ surplus = height - total_with_pref
+ else:
+ surplus = 0
+
+ # If there's surplus after preferred sizes and allowed spacing, distribute
+ # it among stretchable children according to their weights (0..100).
+ if stretchable_indices and surplus > 0:
+ weights = [stretchable_weights[i] for i in range(len(stretchable_indices))]
+ total_weight = sum(weights)
+ # If all weights are zero, distribute equally
+ if total_weight <= 0:
+ per = surplus // len(stretchable_indices)
+ extra_left = surplus % len(stretchable_indices)
+ for k, idx in enumerate(stretchable_indices):
+ allocated[idx] += per + (1 if k < extra_left else 0)
+ else:
+ # Distribute proportional to weights
+ assigned = [0] * len(stretchable_indices)
+ base = 0
+ for k, idx in enumerate(stretchable_indices):
+ add = (surplus * weights[k]) // total_weight
+ assigned[k] = add
+ base += add
+ leftover = surplus - base
+ for k in range(len(stretchable_indices)):
+ if leftover <= 0:
+ break
+ assigned[k] += 1
+ leftover -= 1
+ for k, idx in enumerate(stretchable_indices):
+ allocated[idx] += assigned[k]
+
+ # If preferred sizes + spacing do not fit, we must shrink from preferred
+ # down to minima. Reduce largest available slack first.
+ total_alloc = sum(allocated) + spacing_allowed
+ if total_alloc > height:
+ overflow = total_alloc - height
+ # compute how much each child can be reduced (allocated - min)
+ reducible = [max(0, allocated[i] - child_min_heights[i]) for i in range(num_children)]
+ # sort indices by reducible descending to preserve small widgets
+ order = sorted(range(num_children), key=lambda ii: reducible[ii], reverse=True)
+ for idx in order:
+ if overflow <= 0:
+ break
+ can = reducible[idx]
+ if can <= 0:
+ continue
+ take = min(can, overflow)
+ allocated[idx] -= take
+ overflow -= take
+ # if still overflow, clamp from any child down to 1
+ if overflow > 0:
+ for i in range(num_children):
+ if overflow <= 0:
+ break
+ can = max(0, allocated[i] - 1)
+ take = min(can, overflow)
+ allocated[i] -= take
+ overflow -= take
+ total_alloc = sum(allocated) + spacing_allowed
+
+ # If still room, give remainder to last stretchable/last child
+ total_alloc = sum(allocated) + spacing_allowed
+ if total_alloc < height:
+ extra = height - total_alloc
+ target = stretchable_indices[-1] if stretchable_indices else (num_children - 1)
+ allocated[target] += extra
+ elif total_alloc > height:
+ diff = total_alloc - height
+ target = stretchable_indices[-1] if stretchable_indices else (num_children - 1)
+ allocated[target] = max(1, allocated[target] - diff)
+
+ # Draw children with allocated heights, inserting at most spacing_allowed gaps
+ cy = y
+ gaps_allowed = spacing_allowed
+ for i, child in enumerate(self._children):
+ if child.visible() is False:
+ continue
+ ch = allocated[i]
+ if ch <= 0:
+ continue
+ if cy + ch > y + height:
+ ch = max(0, (y + height) - cy)
+ if ch <= 0:
+ break
+ try:
+ if hasattr(child, "_draw"):
+ child._draw(window, cy, x, width, ch)
+ except Exception:
+ try:
+ self._logger.error("_draw child error: %s", child, exc_info=True)
+ except Exception:
+ _mod_logger.error("_draw child error", exc_info=True)
+ cy += ch
+ # Insert at most one-line gap between children if budget allows
+ if i < num_children - 1 and gaps_allowed > 0 and cy < (y + height):
+ cy += 1
+ gaps_allowed -= 1
diff --git a/manatools/aui/backends/gtk/__init__.py b/manatools/aui/backends/gtk/__init__.py
new file mode 100644
index 0000000..680abd3
--- /dev/null
+++ b/manatools/aui/backends/gtk/__init__.py
@@ -0,0 +1,62 @@
+from .dialoggtk import YDialogGtk
+from .checkboxgtk import YCheckBoxGtk
+from .hboxgtk import YHBoxGtk
+from .vboxgtk import YVBoxGtk
+from .labelgtk import YLabelGtk
+from .pushbuttongtk import YPushButtonGtk
+from .treegtk import YTreeGtk
+from .alignmentgtk import YAlignmentGtk
+from .comboboxgtk import YComboBoxGtk
+from .framegtk import YFrameGtk
+from .inputfieldgtk import YInputFieldGtk
+from .selectionboxgtk import YSelectionBoxGtk
+from .checkboxframegtk import YCheckBoxFrameGtk
+from .progressbargtk import YProgressBarGtk
+from .radiobuttongtk import YRadioButtonGtk
+from .tablegtk import YTableGtk
+from .richtextgtk import YRichTextGtk
+from .menubargtk import YMenuBarGtk
+from .replacepointgtk import YReplacePointGtk
+from .intfieldgtk import YIntFieldGtk
+from .datefieldgtk import YDateFieldGtk
+from .multilineeditgtk import YMultiLineEditGtk
+from .spacinggtk import YSpacingGtk
+from .imagegtk import YImageGtk
+from .dumbtabgtk import YDumbTabGtk
+from .slidergtk import YSliderGtk
+from .logviewgtk import YLogViewGtk
+from .timefieldgtk import YTimeFieldGtk
+from .panedgtk import YPanedGtk
+
+__all__ = [
+ "YDialogGtk",
+ "YFrameGtk",
+ "YVBoxGtk",
+ "YHBoxGtk",
+ "YTreeGtk",
+ "YSelectionBoxGtk",
+ "YLabelGtk",
+ "YPushButtonGtk",
+ "YInputFieldGtk",
+ "YCheckBoxGtk",
+ "YComboBoxGtk",
+ "YAlignmentGtk",
+ "YCheckBoxFrameGtk",
+ "YProgressBarGtk",
+ "YRadioButtonGtk",
+ "YTableGtk",
+ "YRichTextGtk",
+ "YMenuBarGtk",
+ "YReplacePointGtk",
+ "YIntFieldGtk",
+ "YDateFieldGtk",
+ "YMultiLineEditGtk",
+ "YSpacingGtk",
+ "YImageGtk",
+ 'YDumbTabGtk',
+ "YSliderGtk",
+ "YLogViewGtk",
+ "YTimeFieldGtk",
+ "YPanedGtk",
+ # ...
+]
diff --git a/manatools/aui/backends/gtk/alignmentgtk.py b/manatools/aui/backends/gtk/alignmentgtk.py
new file mode 100644
index 0000000..e599652
--- /dev/null
+++ b/manatools/aui/backends/gtk/alignmentgtk.py
@@ -0,0 +1,453 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.gtk contains all GTK backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.gtk
+'''
+
+import gi
+gi.require_version('Gtk', '4.0')
+gi.require_version('Gdk', '4.0')
+from gi.repository import Gtk, Gdk, GObject, GdkPixbuf, GLib
+import cairo
+import threading
+import os
+import logging
+from ...yui_common import *
+
+
+class YAlignmentGtk(YSingleChildContainerWidget):
+ """
+ GTK4 implementation of YAlignment.
+
+ - Uses a Gtk.Grid as a lightweight container that expands to provide space
+ so child halign/valign can take effect. Gtk.Grid honors child halign/valign
+ within its allocation, which matches YAlignment.h semantics.
+ - Applies halign/valign hints to the child's backend widget.
+ - Defers attaching the child if its backend is not yet created (GLib.idle_add).
+ - Supports an optional repeating background pixbuf painted in the draw signal.
+ """
+ def __init__(self, parent=None, horAlign: YAlignmentType=YAlignmentType.YAlignUnchanged, vertAlign: YAlignmentType=YAlignmentType.YAlignUnchanged):
+ super().__init__(parent)
+ self._logger = logging.getLogger(f"manatools.aui.gtk.{self.__class__.__name__}")
+ self._halign_spec = horAlign
+ self._valign_spec = vertAlign
+ self._background_pixbuf = None
+ self._min_width_px = 0
+ self._min_height_px = 0
+ self._signal_id = None
+ self._backend_widget = None
+ # get a reference to the single row container
+ self._row = []
+ # pass-through mode for unchanged/fill alignment
+ self._fill_direct_mode = False
+ self._direct_child_widget = None
+ # schedule guard for deferred attach
+ self._attach_scheduled = False
+ # Track if we've already attached a child
+ self._child_attached = False
+
+ def widgetClass(self):
+ return "YAlignment"
+
+ def _to_gtk_halign(self):
+ """Convert Horizontal YAlignmentType to Gtk.Align or Gtk.Align.CENTER."""
+ if self._halign_spec:
+ if self._halign_spec == YAlignmentType.YAlignBegin:
+ return Gtk.Align.START
+ if self._halign_spec == YAlignmentType.YAlignCenter:
+ return Gtk.Align.CENTER
+ if self._halign_spec == YAlignmentType.YAlignEnd:
+ return Gtk.Align.END
+ # default for Unchanged: let child fill available space
+ return Gtk.Align.FILL
+
+ def _to_gtk_valign(self):
+ """Convert Vertical YAlignmentType to Gtk.Align or Gtk.Align.CENTER."""
+ if self._valign_spec:
+ if self._valign_spec == YAlignmentType.YAlignBegin:
+ return Gtk.Align.START
+ if self._valign_spec == YAlignmentType.YAlignCenter:
+ return Gtk.Align.CENTER
+ if self._valign_spec == YAlignmentType.YAlignEnd:
+ return Gtk.Align.END
+ # default for Unchanged: let child fill available space
+ return Gtk.Align.FILL
+
+ #def stretchable(self, dim):
+ # """Report whether this alignment should expand in given dimension.
+ #
+ # Parents (HBox/VBox) use this to distribute space.
+ # """
+ # try:
+ # if dim == YUIDimension.YD_HORIZ:
+ # align = self._to_gtk_halign()
+ # return align in (Gtk.Align.CENTER, Gtk.Align.END) #TODO: verify
+ # if dim == YUIDimension.YD_VERT:
+ # align = self._to_gtk_valign()
+ # return align == Gtk.Align.CENTER #TODO: verify
+ # except Exception:
+ # pass
+ # return False
+
+ def stretchable(self, dim: YUIDimension):
+ """Stretchability semantics consistent with YAlignment.h:
+ - In the aligned dimension (Begin/Center/End), the alignment container is stretchable
+ so there is space for alignment to take effect.
+ - In the unchanged dimension, reflect the child's stretchability or layout weight.
+ """
+ try:
+ if dim == YUIDimension.YD_HORIZ:
+ if self._halign_spec is not None and self._halign_spec != YAlignmentType.YAlignUnchanged:
+ return True
+ if dim == YUIDimension.YD_VERT:
+ if self._valign_spec is not None and self._valign_spec != YAlignmentType.YAlignUnchanged:
+ return True
+ except Exception:
+ pass
+
+ child = self.child()
+ if child:
+ try:
+ return bool(child.stretchable(dim) or child.weight(dim))
+ except Exception:
+ return False
+ return False
+
+ def setBackgroundPixmap(self, filename):
+ """Set a repeating background pixbuf and connect draw handler."""
+ # disconnect previous handler
+ if self._signal_id and self._backend_widget:
+ try:
+ self._backend_widget.disconnect(self._signal_id)
+ except Exception:
+ pass
+ self._signal_id = None
+
+ # release previous pixbuf if present
+ self._background_pixbuf = None
+
+ if filename:
+ try:
+ self._background_pixbuf = GdkPixbuf.Pixbuf.new_from_file(filename)
+ if self._backend_widget:
+ self._signal_id = self._backend_widget.connect("draw", self._on_draw)
+ self._backend_widget.queue_draw() # Trigger redraw
+ except Exception as e:
+ self._logger.error("Failed to load background image: %s", e, exc_info=True)
+ self._background_pixbuf = None
+
+ def _on_draw(self, widget, cr):
+ """Draw callback that tiles the background pixbuf."""
+ if not self._background_pixbuf:
+ return False
+ try:
+ # Get actual allocation
+ width = widget.get_allocated_width()
+ height = widget.get_allocated_height()
+
+ Gdk.cairo_set_source_pixbuf(cr, self._background_pixbuf, 0, 0)
+ # set repeat
+ pat = cr.get_source()
+ pat.set_extend(cairo.Extend.REPEAT)
+ cr.rectangle(0, 0, width, height)
+ cr.fill()
+ except Exception as e:
+ self._logger.error("Error drawing background: %s", e, exc_info=True)
+ return False
+
+ def addChild(self, child):
+ """Keep base behavior and ensure we attempt to attach child's backend."""
+ super().addChild(child)
+ self._child_attached = False
+ self._schedule_attach_child()
+
+ def _schedule_attach_child(self):
+ """Schedule a single idle callback to attach child backend later."""
+ if self._attach_scheduled or self._child_attached:
+ return
+ self._logger.debug("Scheduling child attach in idle")
+ self._attach_scheduled = True
+
+ def _idle_cb():
+ self._attach_scheduled = False
+ try:
+ self._ensure_child_attached()
+ except Exception as e:
+ self._logger.error("Error attaching child: %s", e, exc_info=True)
+ return False
+
+ try:
+ GLib.idle_add(_idle_cb)
+ except Exception:
+ # fallback: call synchronously if idle_add not available
+ _idle_cb()
+
+ def _ensure_child_attached(self):
+ """Attach child's backend to our container using a 3x3 grid built
+ from a vertical Gtk.Box with three Gtk.CenterBox rows. Position the
+ child in the appropriate row and slot according to halign/valign.
+ """
+ if self._backend_widget is None:
+ self._create_backend_widget()
+ return
+
+ # choose child reference
+ child = self.child()
+ if child is None:
+ return
+
+ # get child's backend widget
+ try:
+ cw = child.get_backend_widget()
+ except Exception:
+ cw = None
+
+ if cw is None:
+ # child backend not yet ready; schedule again
+ if not self._child_attached:
+ self._logger.debug("Child %s %s backend not ready; deferring attach", child.widgetClass(), child.debugLabel())
+ self._schedule_attach_child()
+ return
+
+ # convert specs -> Gtk.Align
+ hal = self._to_gtk_halign()
+ val = self._to_gtk_valign()
+
+ # Pass-through mode: when both alignments are unchanged/fill, this
+ # container should not center the child but let it fill all available
+ # space. CenterBox-based placement would visually center content.
+ if hal == Gtk.Align.FILL and val == Gtk.Align.FILL:
+ try:
+ # switch to direct-child mode by removing row containers once
+ if not self._fill_direct_mode:
+ for row in list(self._row):
+ try:
+ self._backend_widget.remove(row)
+ except Exception:
+ pass
+ self._fill_direct_mode = True
+
+ # replace previous direct child if present
+ old_direct = getattr(self, "_direct_child_widget", None)
+ if old_direct is not None and old_direct is not cw:
+ try:
+ self._backend_widget.remove(old_direct)
+ except Exception:
+ pass
+
+ try:
+ cw.set_halign(Gtk.Align.FILL)
+ cw.set_valign(Gtk.Align.FILL)
+ cw.set_hexpand(True)
+ cw.set_vexpand(True)
+ except Exception:
+ pass
+
+ # enforce minimum size on child if requested
+ try:
+ mw = getattr(self, '_min_width_px', 0)
+ mh = getattr(self, '_min_height_px', 0)
+ if (mw and mw > 0) or (mh and mh > 0):
+ w_req = int(mw) if mw and mw > 0 else -1
+ h_req = int(mh) if mh and mh > 0 else -1
+ try:
+ cw.set_size_request(w_req, h_req)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ if cw.get_parent() is not self._backend_widget:
+ try:
+ self._backend_widget.append(cw)
+ except Exception:
+ pass
+ self._direct_child_widget = cw
+ self._child_attached = True
+ self._logger.debug("Successfully attached child %s %s [fill-direct]", child.widgetClass(), child.debugLabel())
+ return
+ except Exception as e:
+ self._logger.error("Error in fill-direct alignment mode: %s", e, exc_info=True)
+
+ try:
+ # if previously in direct mode, restore row containers
+ if self._fill_direct_mode:
+ try:
+ old_direct = getattr(self, "_direct_child_widget", None)
+ if old_direct is not None:
+ try:
+ self._backend_widget.remove(old_direct)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ for row in list(self._row):
+ try:
+ if row.get_parent() is None:
+ self._backend_widget.append(row)
+ except Exception:
+ pass
+ self._fill_direct_mode = False
+ self._direct_child_widget = None
+
+
+ # Determine row index based on vertical alignment
+ row_index = 0 if val == Gtk.Align.START else 2 if val == Gtk.Align.END else 1 # center default
+ target_cb = self._row[row_index]
+
+ # Place child in start/center/end based on horizontal alignment
+ # Default to center if unspecified
+ try:
+ # Clear any existing widgets in the target centerbox slots
+ target_cb.set_start_widget(None)
+ target_cb.set_center_widget(None)
+ target_cb.set_end_widget(None)
+ except Exception:
+ pass
+ cw.set_halign(hal)
+ cw.set_valign(val)
+
+ # enforce minimum size on child if requested
+ try:
+ mw = getattr(self, '_min_width_px', 0)
+ mh = getattr(self, '_min_height_px', 0)
+ if (mw and mw > 0) or (mh and mh > 0):
+ # GTK uses size-request on widgets for minimum size
+ w_req = -1
+ h_req = -1
+ try:
+ if mw and mw > 0:
+ w_req = int(mw)
+ except Exception:
+ w_req = -1
+ try:
+ if mh and mh > 0:
+ h_req = int(mh)
+ except Exception:
+ h_req = -1
+ try:
+ cw.set_size_request(w_req if w_req > 0 else -1, h_req if h_req > 0 else -1)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ if hal == Gtk.Align.START:
+ target_cb.set_start_widget(cw)
+ elif hal == Gtk.Align.END:
+ target_cb.set_end_widget(cw)
+ else:
+ target_cb.set_center_widget(cw)
+
+ self._backend_widget.set_halign(hal)
+ self._backend_widget.set_valign(val)
+ #self._backend_widget.set_hexpand(True)
+ #self._backend_widget.set_vexpand(True)
+ self._child_attached = True
+
+ col_index = 0 if hal == Gtk.Align.START else 2 if hal == Gtk.Align.END else 1 # center default
+ self._logger.debug("Successfully attached child %s %s [%d,%d]", child.widgetClass(), child.debugLabel(), row_index, col_index)
+ except Exception as e:
+ self._logger.error("Error building CenterBox layout: %s", e, exc_info=True)
+
+ def _create_backend_widget(self):
+ """Create a container for the 3x3 alignment layout.
+
+ Use a simple Gtk.Box as root container; actual 3x3 is built on attach.
+ """
+ try:
+ root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
+ root.set_hexpand(True)
+ root.set_vexpand(True)
+ root.set_halign(Gtk.Align.FILL)
+ root.set_valign(Gtk.Align.FILL)
+
+ for _ in range(3):
+ cb = Gtk.CenterBox()
+ cb.set_hexpand(True)
+ cb.set_vexpand(True)
+ cb.set_halign(Gtk.Align.FILL)
+ cb.set_valign(Gtk.Align.FILL)
+ cb.set_margin_start(0)
+ cb.set_margin_end(0)
+ self._row.append(cb)
+ root.append(cb)
+
+ for cb in self._row:
+ cb.set_vexpand(True)
+
+
+ except Exception as e:
+ self._logger.error("Error creating backend widget: %s", e, exc_info=True)
+ root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
+
+ self._backend_widget = root
+ self._backend_widget.set_sensitive(self._enabled)
+
+ # Connect draw handler if we have a background pixbuf
+ if self._background_pixbuf and not self._signal_id:
+ try:
+ self._signal_id = self._backend_widget.connect("draw", self._on_draw)
+ except Exception as e:
+ self._logger.error("Error connecting draw signal: %s", e, exc_info=True)
+ self._signal_id = None
+
+ # Mark that backend is ready and attempt to attach child
+ self._ensure_child_attached()
+ try:
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception:
+ pass
+
+ def get_backend_widget(self):
+ """Return the backend GTK widget."""
+ if self._backend_widget is None:
+ self._create_backend_widget()
+ return self._backend_widget
+
+ def setSize(self, width, height):
+ """Set size of the alignment widget."""
+ if self._backend_widget:
+ if width > 0 and height > 0:
+ self._backend_widget.set_size_request(width, height)
+ else:
+ self._backend_widget.set_size_request(-1, -1)
+
+ def setEnabled(self, enabled):
+ """Set widget enabled state."""
+ if self._backend_widget:
+ self._backend_widget.set_sensitive(enabled)
+ super().setEnabled(enabled)
+
+ def setVisible(self, visible):
+ """Set widget visibility."""
+ if self._backend_widget:
+ try:
+ self._backend_widget.set_visible(visible)
+ except Exception:
+ pass
+ super().setVisible(visible)
+
+ def _set_backend_enabled(self, enabled):
+ """Enable/disable the alignment container and its child (if any)."""
+ try:
+ if self._backend_widget is not None:
+ try:
+ self._backend_widget.set_sensitive(enabled)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ # propagate to logical child so child's backend updates too
+ try:
+ child = self.child()
+ if child is not None:
+ child.setEnabled(enabled)
+ except Exception:
+ pass
diff --git a/manatools/aui/backends/gtk/checkboxframegtk.py b/manatools/aui/backends/gtk/checkboxframegtk.py
new file mode 100644
index 0000000..2b84d3a
--- /dev/null
+++ b/manatools/aui/backends/gtk/checkboxframegtk.py
@@ -0,0 +1,386 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.gtk contains all GTK backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.gtk
+'''
+import gi
+gi.require_version('Gtk', '4.0')
+gi.require_version('Gdk', '4.0')
+from gi.repository import Gtk, Gdk, GObject, GdkPixbuf, GLib
+import logging
+import cairo
+import threading
+import os
+from ...yui_common import *
+
+class YCheckBoxFrameGtk(YSingleChildContainerWidget):
+ """
+ GTK backend for YCheckBoxFrame: a frame with a checkbutton in the title area
+ that enables/disables its child. The implementation prefers to place the
+ CheckButton into the Frame's label widget (if supported) so the theme draws
+ the frame title and border consistently.
+ """
+ def __init__(self, parent=None, label: str = "", checked: bool = False):
+ super().__init__(parent)
+ self._label = label or ""
+ self._checked = bool(checked)
+ self._auto_enable = True
+ self._invert_auto = False
+
+ self._backend_widget = None
+ self._checkbox = None
+ self._content_box = None
+ self._label_widget = None
+ self._logger = logging.getLogger(f"manatools.aui.gtk.{self.__class__.__name__}")
+
+ def widgetClass(self):
+ return "YCheckBoxFrame"
+
+ def label(self):
+ return self._label
+
+ def setLabel(self, new_label: str):
+ try:
+ self._label = new_label or ""
+ if getattr(self, "_checkbox", None) is not None:
+ try:
+ # Gtk.CheckButton uses set_label via property or set_text
+ if hasattr(self._checkbox, "set_label"):
+ self._checkbox.set_label(self._label)
+ elif hasattr(self._checkbox, "set_text"):
+ self._checkbox.set_text(self._label)
+ else:
+ # fallback: recreate label if necessary (best-effort)
+ pass
+ except Exception:
+ pass
+ # if we used a separate label widget, update it too
+ try:
+ if getattr(self, "_label_widget", None) is not None:
+ self._label_widget.set_text(self._label)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def value(self):
+ try:
+ if getattr(self, "_checkbox", None) is not None:
+ return bool(self._checkbox.get_active())
+ except Exception:
+ pass
+ return bool(self._checked)
+
+ def setValue(self, isChecked: bool):
+ try:
+ self._checked = bool(isChecked)
+ if getattr(self, "_checkbox", None) is not None:
+ try:
+ self._checkbox.handler_block_by_func(self._on_toggled)
+ except Exception:
+ pass
+ try:
+ self._checkbox.set_active(self._checked)
+ except Exception:
+ try:
+ self._checkbox.set_active(self._checked)
+ except Exception:
+ pass
+ try:
+ self._checkbox.handler_unblock_by_func(self._on_toggled)
+ except Exception:
+ pass
+ # apply enable/disable to children
+ self._apply_children_enablement(self._checked)
+ except Exception:
+ pass
+
+ def autoEnable(self):
+ return bool(self._auto_enable)
+
+ def setAutoEnable(self, autoEnable: bool):
+ try:
+ self._auto_enable = bool(autoEnable)
+ self._apply_children_enablement(self.value())
+ except Exception:
+ pass
+
+ def invertAutoEnable(self):
+ return bool(self._invert_auto)
+
+ def setInvertAutoEnable(self, invert: bool):
+ try:
+ self._invert_auto = bool(invert)
+ self._apply_children_enablement(self.value())
+ except Exception:
+ pass
+
+ def _create_backend_widget(self):
+ """Create Gtk.Frame (or fallback container) and place a CheckButton in the title area."""
+ try:
+ # Try Gtk.Frame and place a checkbutton as label widget (theme-aware)
+ try:
+ frame = Gtk.Frame()
+ # create inner content box
+ content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
+ try:
+ content.set_hexpand(True)
+ content.set_vexpand(True)
+ except Exception:
+ pass
+
+ # Create checkbutton and try to set it as the frame's label widget
+ check = Gtk.CheckButton(label=self._label)
+ check.set_active(self._checked)
+ self._checkbox = check
+
+ # If Gtk.Frame supports set_label_widget use it for themed title
+ if hasattr(frame, "set_label_widget"):
+ try:
+ frame.set_label_widget(check)
+ except Exception:
+ # fallback: append check above content
+ pass
+
+ # Attach content inside frame
+ try:
+ if hasattr(frame, "set_child"):
+ frame.set_child(content)
+ else:
+ frame.add(content)
+ except Exception:
+ try:
+ frame.add(content)
+ except Exception:
+ pass
+
+ self._backend_widget = frame
+ self._content_box = content
+ self._label_widget = None
+ except Exception:
+ # Fallback: container with top CheckButton and then content box
+ container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
+ check = Gtk.CheckButton(label=self._label)
+ check.set_active(self._checked)
+ container.append(check)
+ content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
+ try:
+ content.set_hexpand(True)
+ content.set_vexpand(True)
+ except Exception:
+ pass
+ container.append(content)
+ self._backend_widget = container
+ self._checkbox = check
+ self._content_box = content
+ self._label_widget = None
+ self._backend_widget.set_sensitive(self._enabled)
+
+ # Ensure a little top margin between title and content
+ try:
+ if hasattr(self._content_box, "set_margin_top"):
+ self._content_box.set_margin_top(6)
+ else:
+ try:
+ self._content_box.set_spacing(6)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ # Connect toggled handler
+ try:
+ self._checkbox.connect("toggled", self._on_toggled)
+ except Exception:
+ try:
+ self._checkbox.connect("toggled", self._on_toggled)
+ except Exception:
+ pass
+
+ # attach existing child if any
+ try:
+ if self.hasChildren():
+ self._attach_child_backend()
+ except Exception:
+ pass
+ except Exception:
+ self._logger.critical("_create_backend_widget failed", exc_info=True)
+ self._backend_widget = None
+ self._checkbox = None
+ self._content_box = None
+ self._label_widget = None
+ return
+ try:
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception:
+ pass
+
+ def _attach_child_backend(self):
+ """Attach the logical child backend widget into the content box (clear previous)."""
+ if self._content_box is None or self._backend_widget is None:
+ return
+ try:
+ # clear existing children
+ try:
+ while True:
+ first = self._content_box.get_first_child()
+ if first is None:
+ break
+ try:
+ self._content_box.remove(first)
+ except Exception:
+ break
+ except Exception:
+ # fallback for Gtk.Box append/remove API
+ try:
+ for child in list(self._content_box.get_children()):
+ try:
+ self._content_box.remove(child)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ child = self.child()
+ if child is None:
+ return
+
+ try:
+ w = None
+ try:
+ w = child.get_backend_widget()
+ except Exception:
+ w = None
+ if w is None:
+ try:
+ if hasattr(child, "_create_backend_widget"):
+ child._create_backend_widget()
+ w = child.get_backend_widget()
+ except Exception:
+ w = None
+ if w is not None:
+ try:
+ self._content_box.append(w)
+ except Exception:
+ try:
+ self._content_box.add(w)
+ except Exception:
+ pass
+ # propagate expansion hints
+ try:
+ if child.stretchable(YUIDimension.YD_VERT):
+ if hasattr(w, "set_vexpand"):
+ w.set_vexpand(True)
+ if child.stretchable(YUIDimension.YD_HORIZ):
+ if hasattr(w, "set_hexpand"):
+ w.set_hexpand(True)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ # apply enablement state
+ if self.isEnabled():
+ self._apply_children_enablement(self.value())
+
+ def _on_toggled(self, widget):
+ try:
+ val = bool(self._checkbox.get_active())
+ self._checked = val
+ if self._auto_enable:
+ self._apply_children_enablement(val)
+ try:
+ if self.notify():
+ dlg = self.findDialog()
+ if dlg:
+ dlg._post_event(YWidgetEvent(self, YEventReason.ValueChanged))
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def _apply_children_enablement(self, isChecked: bool):
+ try:
+ if not self._auto_enable:
+ return
+ state = bool(isChecked)
+ if self._invert_auto:
+ state = not state
+ child = self.child()
+ if child is not None:
+ child.setEnabled(state)
+ #try:
+ # w = child.get_backend_widget()
+ # if w is not None:
+ # try:
+ # w.set_sensitive(state)
+ # except Exception:
+ # pass
+ #except Exception:
+ # pass
+ except Exception:
+ pass
+
+ def addChild(self, child):
+ super().addChild(child)
+ self._attach_child_backend()
+
+ def _set_backend_enabled(self, enabled: bool):
+ try:
+ if getattr(self, "_backend_widget", None) is not None:
+ try:
+ self._backend_widget.set_sensitive(bool(enabled))
+ except Exception:
+ pass
+ except Exception:
+ pass
+ # propagate to logical children
+ try:
+ child = self.child()
+ if child is not None:
+ child.setEnabled(enabled)
+ except Exception:
+ pass
+
+ def setProperty(self, propertyName, val):
+ try:
+ if propertyName == "label":
+ self.setLabel(str(val))
+ return True
+ if propertyName in ("value", "checked"):
+ self.setValue(bool(val))
+ return True
+ except Exception:
+ pass
+ return False
+
+ def getProperty(self, propertyName):
+ try:
+ if propertyName == "label":
+ return self.label()
+ if propertyName in ("value", "checked"):
+ return self.value()
+ except Exception:
+ pass
+ return None
+
+ def propertySet(self):
+ try:
+ props = YPropertySet()
+ try:
+ props.add(YProperty("label", YPropertyType.YStringProperty))
+ props.add(YProperty("value", YPropertyType.YBoolProperty))
+ except Exception:
+ pass
+ return props
+ except Exception:
+ return None
+
diff --git a/manatools/aui/backends/gtk/checkboxgtk.py b/manatools/aui/backends/gtk/checkboxgtk.py
new file mode 100644
index 0000000..aab1bdd
--- /dev/null
+++ b/manatools/aui/backends/gtk/checkboxgtk.py
@@ -0,0 +1,93 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.gtk contains all GTK backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.gtk
+'''
+
+import gi
+gi.require_version('Gtk', '4.0')
+gi.require_version('Gdk', '4.0')
+from gi.repository import Gtk, Gdk, GObject, GdkPixbuf, GLib
+import cairo
+import threading
+import os
+import logging
+from ...yui_common import *
+
+
+class YCheckBoxGtk(YWidget):
+ def __init__(self, parent=None, label="", is_checked=False):
+ super().__init__(parent)
+ self._label = label
+ self._is_checked = is_checked
+ self._logger = logging.getLogger(f"manatools.aui.gtk.{self.__class__.__name__}")
+
+ def widgetClass(self):
+ return "YCheckBox"
+
+ def value(self):
+ return self._is_checked
+
+ def setValue(self, checked):
+ self._is_checked = checked
+ if self._backend_widget:
+ try:
+ self._backend_widget.set_active(checked)
+ except Exception:
+ pass
+
+ def isChecked(self):
+ '''
+ Simplified access to value(): Return 'true' if the CheckBox is checked.
+ '''
+ return self.value()
+
+ def setChecked(self, checked: bool = True):
+ '''
+ Simplified access to setValue(): Set the CheckBox to 'checked' state if 'checked' is true.
+ '''
+ self.setValue(checked)
+
+ def label(self):
+ return self._label
+
+ def _create_backend_widget(self):
+ self._backend_widget = Gtk.CheckButton(label=self._label)
+ try:
+ self._backend_widget.set_active(self._is_checked)
+ self._backend_widget.connect("toggled", self._on_toggled)
+ except Exception:
+ self._logger.error("_create_backend_widget toggle setup failed", exc_info=True)
+ self._backend_widget.set_sensitive(self._enabled)
+ try:
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception:
+ pass
+
+ def _on_toggled(self, button):
+ try:
+ self._is_checked = button.get_active()
+ except Exception:
+ self._is_checked = bool(self._is_checked)
+
+ if self.notify():
+ dlg = self.findDialog()
+ if dlg is not None:
+ dlg._post_event(YWidgetEvent(self, YEventReason.ValueChanged))
+
+ def _set_backend_enabled(self, enabled):
+ """Enable/disable the check button backend."""
+ try:
+ if self._backend_widget is not None:
+ try:
+ self._backend_widget.set_sensitive(enabled)
+ except Exception:
+ pass
+ except Exception:
+ pass
diff --git a/manatools/aui/backends/gtk/comboboxgtk.py b/manatools/aui/backends/gtk/comboboxgtk.py
new file mode 100644
index 0000000..1093802
--- /dev/null
+++ b/manatools/aui/backends/gtk/comboboxgtk.py
@@ -0,0 +1,542 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.gtk contains all GTK backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.gtk
+'''
+
+import gi
+gi.require_version('Gtk', '4.0')
+gi.require_version('Gdk', '4.0')
+from gi.repository import Gtk, Gdk, GObject, GdkPixbuf, GLib
+import cairo
+import threading
+import os
+import logging
+from ...yui_common import *
+from .commongtk import _resolve_icon
+
+
+class YComboBoxGtk(YSelectionWidget):
+ """
+ GTK4 backend for a simple combo/select widget.
+ Provides editable and non-editable variants using Gtk.Entry, Gtk.DropDown or a simple cycling Gtk.Button.
+ Uses logging extensively to surface runtime issues and fallbacks.
+ """
+ def __init__(self, parent=None, label="", editable=False):
+ """
+ Initialize backend combobox.
+ - parent: parent widget
+ - label: optional label text
+ - editable: if True use an Entry, otherwise use DropDown or fallback
+ """
+ super().__init__(parent)
+ self._label = label
+ self._editable = editable
+ self._value = ""
+ self._selected_items = []
+ self._combo_widget = None
+ self._logger = logging.getLogger(f"manatools.aui.gtk.{self.__class__.__name__}")
+ # reference to the visible label widget (if any)
+ self._label_widget = None
+
+ def widgetClass(self):
+ return "YComboBox"
+
+ def value(self):
+ return self._value
+
+ def setValue(self, text):
+ """
+ Set the current displayed value (tries to update backing GTK widget).
+ Logs any exceptions encountered while updating the GTK widget.
+ """
+ self._value = text
+ if self._combo_widget:
+ try:
+ # try entry child for editable combos
+ child = None
+ if self._editable:
+ child = self._combo_widget.get_child()
+ if child and hasattr(child, "set_text"):
+ child.set_text(text)
+ else:
+ # attempt to set active by matching text if API available
+ if hasattr(self._combo_widget, "set_active_id"):
+ # Gtk.DropDown uses ids in models; we keep simple and try to match by text
+ # fallback: rebuild model and select programmatically below
+ pass
+ # update selected_items
+ self._selected_items = [it for it in self._items if it.label() == text][:1]
+ except Exception:
+ self._logger.exception("setValue: failed to update backend widget with text=%r", text)
+
+ def editable(self):
+ return self._editable
+
+ def _create_backend_widget(self):
+ # use vertical box so label is above the control
+ hbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5)
+
+ self._label_widget = Gtk.Label()
+ try:
+ hbox.append(self._label_widget)
+ except Exception:
+ self._logger.error("Failed to append label to box", exc_info=True)
+ hbox.add(self._label_widget)
+ if self._label:
+ self._label_widget.set_text(self._label)
+ try:
+ if hasattr(self._label_widget, "set_xalign"):
+ self._label_widget.set_xalign(0.0)
+ except Exception:
+ self._logger.error("Failed to set xalign on label", exc_info=True)
+ self._label_widget.set_visible(True)
+ else:
+ self._label_widget.set_visible(False)
+ # Determine expansion flags from logical widget before creating the control
+ try:
+ try:
+ vexpand_flag = bool(self.stretchable(YUIDimension.YD_VERT)) or bool(int(self.weight(YUIDimension.YD_VERT)))
+ except Exception:
+ vexpand_flag = bool(self.stretchable(YUIDimension.YD_VERT))
+ try:
+ hexpand_flag = bool(self.stretchable(YUIDimension.YD_HORIZ)) or bool(int(self.weight(YUIDimension.YD_HORIZ)))
+ except Exception:
+ hexpand_flag = bool(self.stretchable(YUIDimension.YD_HORIZ))
+ except Exception:
+ vexpand_flag = False
+ hexpand_flag = False
+
+ # For Gtk4 there is no ComboBoxText; try DropDown for non-editable,
+ # and Entry for editable combos (simple fallback).
+ if self._editable:
+ entry = Gtk.Entry()
+ try:
+ entry.set_text(self._value)
+ except Exception:
+ pass
+ # apply expansion policies
+ try:
+ entry.set_hexpand(hexpand_flag)
+ except Exception:
+ pass
+ try:
+ entry.set_vexpand(vexpand_flag)
+ except Exception:
+ pass
+ try:
+ entry.connect("changed", self._on_text_changed)
+ except Exception:
+ pass
+ self._combo_widget = entry
+ try:
+ hbox.append(entry)
+ except Exception:
+ hbox.add(entry)
+ else:
+ # Build a simple Gtk.DropDown backed by a Gtk.StringList (if available)
+ try:
+ if hasattr(Gtk, "StringList") and hasattr(Gtk, "DropDown"):
+ self._string_list_model = Gtk.StringList()
+ for it in self._items:
+ try:
+ self._string_list_model.append(it.label())
+ except Exception:
+ pass
+ dropdown = Gtk.DropDown.new(self._string_list_model, None)
+ # select initial value (prefer explicit selected() flag)
+ sel_idx = -1
+ for idx, it in enumerate(self._items):
+ try:
+ if it.selected():
+ sel_idx = idx
+ break
+ except Exception:
+ pass
+ if sel_idx >= 0:
+ try:
+ dropdown.set_selected(sel_idx)
+ self._value = self._items[sel_idx].label()
+ self._selected_items = [self._items[sel_idx]]
+ except Exception:
+ pass
+ else:
+ if self._value:
+ for idx, it in enumerate(self._items):
+ try:
+ if it.label() == self._value:
+ dropdown.set_selected(idx)
+ self._selected_items = [it]
+ break
+ except Exception:
+ pass
+ try:
+ dropdown.connect("notify::selected", lambda w, pspec: self._on_changed_dropdown(w))
+ except Exception:
+ pass
+ # apply expansion policies so the control grows according to widget settings
+ try:
+ dropdown.set_hexpand(hexpand_flag)
+ except Exception:
+ pass
+ try:
+ dropdown.set_vexpand(vexpand_flag)
+ except Exception:
+ pass
+ self._combo_widget = dropdown
+ try:
+ hbox.append(dropdown)
+ except Exception:
+ hbox.add(dropdown)
+ else:
+ # fallback: simple Gtk.Button that cycles items on click (very simple)
+ btn = Gtk.Button(label=self._value or (self._items[0].label() if self._items else ""))
+ try:
+ btn.connect("clicked", self._on_fallback_button_clicked)
+ except Exception:
+ pass
+ # apply expansion policies to button as best-effort
+ try:
+ btn.set_hexpand(hexpand_flag)
+ except Exception:
+ pass
+ try:
+ btn.set_vexpand(vexpand_flag)
+ except Exception:
+ pass
+ self._combo_widget = btn
+ try:
+ hbox.append(btn)
+ except Exception:
+ hbox.add(btn)
+ except Exception:
+ # final fallback: entry
+ entry = Gtk.Entry()
+ try:
+ entry.set_text(self._value or "")
+ except Exception:
+ pass
+ try:
+ entry.connect("changed", self._on_text_changed)
+ except Exception:
+ pass
+ try:
+ entry.set_hexpand(hexpand_flag)
+ except Exception:
+ pass
+ try:
+ entry.set_vexpand(vexpand_flag)
+ except Exception:
+ pass
+ self._combo_widget = entry
+ try:
+ hbox.append(entry)
+ except Exception:
+ hbox.add(entry)
+
+ self._backend_widget = hbox
+ if self._help_text:
+ try:
+ self._backend_widget.set_tooltip_text(self._help_text)
+ except Exception:
+ self._logger.error("Failed to set tooltip text on backend widget", exc_info=True)
+ try:
+ self._backend_widget.set_visible(self.visible())
+ except Exception:
+ self._logger.error("Failed to set backend widget visible", exc_info=True)
+ try:
+ self._backend_widget.set_sensitive(self._enabled)
+ except Exception:
+ self._logger.exception("Failed to set sensitivity on backend widget")
+
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+
+ def setLabel(self, new_label: str):
+ """Set logical label and update the visual Gtk.Label in the box."""
+ try:
+ super().setLabel(new_label)
+ if self._backend_widget is None:
+ return
+ if new_label:
+ self._label_widget.set_text(new_label)
+ self._label_widget.set_visible(True)
+ else:
+ self._label_widget.set_visible(False)
+ except Exception:
+ self._logger.exception("setLabel: error updating label=%r", new_label)
+
+ def _set_backend_enabled(self, enabled):
+ """
+ Enable/disable the combobox/backing widget and its entry/dropdown.
+ Logs problems when setting sensitivity fails.
+ """
+ try:
+ # prefer to enable the primary control if present
+ ctl = getattr(self, "_combo_widget", None)
+ if ctl is not None:
+ try:
+ ctl.set_sensitive(enabled)
+ except Exception:
+ self._logger.exception("_set_backend_enabled: failed to set_sensitive on primary control")
+ except Exception:
+ self._logger.exception("_set_backend_enabled: error accessing primary control")
+ try:
+ if self._backend_widget is not None:
+ try:
+ self._backend_widget.set_sensitive(enabled)
+ except Exception:
+ self._logger.exception("_set_backend_enabled: failed to set_sensitive on backend widget")
+ except Exception:
+ self._logger.exception("_set_backend_enabled: error accessing backend widget")
+
+ def _on_fallback_button_clicked(self, btn):
+ """
+ Cycle through items when using the fallback button control.
+ Logs unexpected issues.
+ """
+ if not self._items:
+ return
+ current = btn.get_label()
+ labels = [it.label() for it in self._items]
+ try:
+ idx = labels.index(current)
+ idx = (idx + 1) % len(labels)
+ except Exception:
+ idx = 0
+ new = labels[idx]
+ try:
+ btn.set_label(new)
+ self.setValue(new)
+ if self.notify():
+ dlg = self.findDialog()
+ if dlg:
+ dlg._post_event(YWidgetEvent(self, YEventReason.SelectionChanged))
+ except Exception:
+ self._logger.exception("_on_fallback_button_clicked: failed to cycle/set selection")
+
+ def _on_text_changed(self, entry):
+ """
+ Handler for editable text changes. Updates internal value and notifies dialog.
+ """
+ try:
+ text = entry.get_text()
+ except Exception:
+ text = ""
+ self._logger.exception("_on_text_changed: failed to read entry text")
+ self._value = text
+ try:
+ self._selected_items = [it for it in self._items if it.label() == self._value][:1]
+ except Exception:
+ self._logger.exception("_on_text_changed: error updating selected_items")
+ self._selected_items = []
+ if self.notify():
+ try:
+ dlg = self.findDialog()
+ if dlg is not None:
+ dlg._post_event(YWidgetEvent(self, YEventReason.SelectionChanged))
+ except Exception:
+ self._logger.exception("_on_text_changed: failed to notify dialog")
+
+ def _on_changed_dropdown(self, dropdown):
+ """
+ Handler for Gtk.DropDown selection changes. Attempts robust extraction of selected label.
+ """
+ # Prefer using the selected index to get a reliable label
+ idx = None
+ try:
+ idx = dropdown.get_selected()
+ except Exception:
+ idx = None
+ self._logger.exception("_on_changed_dropdown: failed to get_selected() from dropdown")
+
+ if isinstance(idx, int) and 0 <= idx < len(self._items):
+ try:
+ self._value = self._items[idx].label()
+ except Exception:
+ self._logger.exception("_on_changed_dropdown: failed to read label from items at index %d", idx)
+ self._value = ""
+ else:
+ # Fallback: try to extract text from the selected-item object
+ val = None
+ try:
+ val = dropdown.get_selected_item()
+ except Exception:
+ val = None
+ self._logger.exception("_on_changed_dropdown: failed to get_selected_item()")
+
+ self._value = ""
+ if isinstance(val, str):
+ self._value = val
+ elif val is not None:
+ # Try common accessor names that GTK objects may expose
+ for meth in ("get_string", "get_text", "get_value", "get_label", "get_name", "to_string"):
+ try:
+ fn = getattr(val, meth, None)
+ if callable(fn):
+ v = fn()
+ if isinstance(v, str) and v:
+ self._value = v
+ break
+ except Exception:
+ self._logger.debug("_on_changed_dropdown: accessor %s failed on selected item", meth)
+ continue
+ # Try properties if available
+ if not self._value:
+ try:
+ props = getattr(val, "props", None)
+ if props:
+ for attr in ("string", "value", "label", "name", "text"):
+ try:
+ pv = getattr(props, attr)
+ if isinstance(pv, str) and pv:
+ self._value = pv
+ break
+ except Exception:
+ self._logger.debug("_on_changed_dropdown: props accessor %s failed", attr)
+ except Exception:
+ self._logger.exception("_on_changed_dropdown: error inspecting props on selected item")
+ # final fallback to str()
+ if not self._value:
+ try:
+ self._value = str(val)
+ except Exception:
+ self._value = ""
+ self._logger.exception("_on_changed_dropdown: str(selected_item) failed")
+
+ # update selected_items and ensure only one item is selected in model
+ self._selected_items = []
+ for it in self._items:
+ try:
+ sel = (it.label() == self._value)
+ it.setSelected(sel)
+ if sel:
+ self._selected_items.append(it)
+ except Exception:
+ self._logger.exception("_on_changed_dropdown: failed updating selection for an item")
+
+ if self.notify():
+ try:
+ dlg = self.findDialog()
+ if dlg is not None:
+ dlg._post_event(YWidgetEvent(self, YEventReason.SelectionChanged))
+ except Exception:
+ self._logger.exception("_on_changed_dropdown: failed to notify dialog")
+ # Allow logger to raise if misconfigured so failures are visible during debugging
+ self._logger.debug("_on_changed_dropdown: value=%s selected_items=%s", self._value, [it.label() for it in self._selected_items])
+
+ # Runtime: add a single item (model + view)
+ def addItem(self, item):
+ """
+ Add an item at runtime. Updates internal state and GTK backing model when possible.
+ """
+ try:
+ super().addItem(item)
+ except Exception:
+ try:
+ if isinstance(item, str):
+ super().addItem(item)
+ else:
+ self._items.append(item)
+ self._logger.debug("addItem: fallback appended item %r", item)
+ except Exception:
+ self._logger.exception("addItem: failed to add item %r", item)
+ return
+ try:
+ new_item = self._items[-1]
+ new_item.setIndex(len(self._items) - 1)
+ except Exception:
+ self._logger.exception("addItem: failed to set index on new item")
+ return
+
+ # ensure only one selected in combo semantics
+ try:
+ if new_item.selected():
+ for it in self._items[:-1]:
+ try:
+ it.setSelected(False)
+ except Exception:
+ self._logger.exception("addItem: failed to clear selection on existing item")
+ new_item.setSelected(True)
+ self._value = new_item.label()
+ self._selected_items = [new_item]
+ except Exception:
+ self._logger.exception("addItem: failed while enforcing single-selection semantics")
+
+ # update GTK backing widget
+ if getattr(self, "_combo_widget", None):
+ try:
+ if isinstance(self._combo_widget, Gtk.Entry):
+ # nothing special: entries are freeform
+ pass
+ elif hasattr(self, "_string_list_model") and isinstance(self._string_list_model, Gtk.StringList):
+ try:
+ self._string_list_model.append(new_item.label())
+ # if the new item was selected, update dropdown selection
+ if new_item.selected():
+ self._combo_widget.set_selected(len(self._string_list_model) - 1)
+ except Exception:
+ self._logger.exception("addItem: failed to update string_list_model with new item")
+ else:
+ # fallback: update button label if single item and selected
+ try:
+ if getattr(self._combo_widget, "set_label", None) and new_item.selected():
+ self._combo_widget.set_label(new_item.label())
+ except Exception:
+ self._logger.exception("addItem: failed to update fallback combo widget label")
+ except Exception:
+ self._logger.exception("addItem: unexpected error while updating backend widget")
+
+ def deleteAllItems(self):
+ """
+ Remove all items and reset backend widgets. Logs any issues so runtime problems are visible.
+ """
+ try:
+ super().deleteAllItems()
+ except Exception:
+ self._logger.exception("deleteAllItems: super().deleteAllItems() failed")
+ self._value = ""
+ # update GTK widgets
+ if getattr(self, "_combo_widget", None):
+ try:
+ if hasattr(self, "_string_list_model") and isinstance(self._string_list_model, Gtk.StringList):
+ try:
+ # recreate model
+ self._string_list_model = Gtk.StringList()
+ self._combo_widget.set_model(self._string_list_model)
+ except Exception:
+ self._logger.exception("deleteAllItems: failed to recreate string_list_model")
+ elif isinstance(self._combo_widget, Gtk.Entry):
+ try:
+ self._combo_widget.set_text("")
+ except Exception:
+ self._logger.exception("deleteAllItems: failed to clear entry text")
+ else:
+ try:
+ if getattr(self._combo_widget, "set_label", None):
+ self._combo_widget.set_label("")
+ except Exception:
+ self._logger.exception("deleteAllItems: failed to clear fallback widget label")
+ except Exception:
+ self._logger.exception("deleteAllItems: unexpected error while updating backend widget")
+
+ def setVisible(self, visible=True):
+ super().setVisible(visible)
+ try:
+ if getattr(self, "_backend_widget", None) is not None:
+ self._backend_widget.set_visible(visible)
+ except Exception:
+ self._logger.exception("setVisible failed", exc_info=True)
+
+ def setHelpText(self, help_text: str):
+ super().setHelpText(help_text)
+ try:
+ if getattr(self, "_backend_widget", None) is not None:
+ self._backend_widget.set_tooltip_text(help_text)
+ except Exception:
+ self._logger.exception("setHelpText failed", exc_info=True)
diff --git a/manatools/aui/backends/gtk/commongtk.py b/manatools/aui/backends/gtk/commongtk.py
new file mode 100644
index 0000000..a22f5dd
--- /dev/null
+++ b/manatools/aui/backends/gtk/commongtk.py
@@ -0,0 +1,195 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.gtk contains all GTK backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.gtk
+'''
+
+import gi
+gi.require_version('Gtk', '4.0')
+gi.require_version('Gdk', '4.0')
+from gi.repository import Gtk, Gdk, GObject, GdkPixbuf, GLib, Gio
+import cairo
+import threading
+import os
+import logging
+from ...yui_common import *
+
+
+__all__ = ["_resolve_icon", "_resolve_gicon", "_convert_mnemonic_to_gtk"]
+
+
+def _resolve_icon(icon_name, size=16):
+ """Return a `Gtk.Image` for the given icon_name or None.
+
+ Resolution policy:
+ - If `icon_name` looks like a filesystem path (absolute or contains path
+ separator) or the file exists, load from file. If no extension, also
+ try the same path with `.png` appended.
+ - Otherwise, strip any extension and try to load from the system
+ icon theme. If that fails, try creating an image from the original
+ name (some engines accept full names).
+ """
+ if not icon_name:
+ return None
+
+ # Helper function to load from file
+ def load_from_file(filename):
+ if os.path.exists(filename):
+ try:
+ # Try as a file path
+ picture = Gtk.Picture.new_for_filename(filename)
+ if picture.get_paintable():
+ image = Gtk.Image.new_from_paintable(picture.get_paintable())
+ if image.get_paintable():
+ return image
+ except Exception:
+ pass
+
+ # Alternative: try GdkPixbuf
+ try:
+ pixbuf = GdkPixbuf.Pixbuf.new_from_file(filename)
+ if pixbuf:
+ return Gtk.Image.new_from_pixbuf(pixbuf)
+ except Exception:
+ pass
+ return None
+
+ # Step 1: Try as file path
+ try:
+ if (os.path.isabs(icon_name) or
+ os.path.sep in icon_name or
+ os.path.exists(icon_name)):
+
+ result = load_from_file(icon_name)
+ if result:
+ return result
+
+ # If no extension, try with .png
+ base, ext = os.path.splitext(icon_name)
+ if not ext:
+ result = load_from_file(icon_name + '.png')
+ if result:
+ return result
+ except Exception:
+ pass
+
+ # Step 2: Try icon theme
+ try:
+ # Strip extension for icon name
+ base_name = os.path.splitext(icon_name)[0] if '.' in icon_name else icon_name
+
+ # Get default display and theme
+ display = Gdk.Display.get_default()
+ if display:
+ theme = Gtk.IconTheme.get_for_display(display)
+
+ # Try to lookup the icon
+ paint = theme.lookup_icon(
+ base_name,
+ fallbacks=None,
+ size=size,
+ scale=1,
+ direction=Gtk.TextDirection.LTR,
+ flags=Gtk.IconLookupFlags.FORCE_REGULAR
+ )
+
+ if paint:
+ return Gtk.Image.new_from_paintable(paint)
+
+ # Fallback: simple icon name creation
+ image = Gtk.Image.new_from_icon_name(base_name)
+ if image.get_icon_name(): # Check if icon was actually set
+ return image
+
+ except Exception:
+ pass
+
+ # Step 3: Final attempt with original name
+ try:
+ image = Gtk.Image.new_from_icon_name(icon_name)
+ if image.get_icon_name():
+ return image
+ except Exception:
+ pass
+
+ return None
+
+
+def _resolve_gicon(icon_spec):
+ """Resolve icon specification to a Gio.Icon.
+
+ Supports theme icon names (Gio.ThemedIcon) and absolute file paths
+ (Gio.FileIcon). Returns None if it cannot be resolved.
+ """
+ if not icon_spec:
+ return None
+ try:
+ # absolute file path
+ if os.path.isabs(icon_spec) and os.path.exists(icon_spec):
+ try:
+ gfile = Gio.File.new_for_path(icon_spec)
+ return Gio.FileIcon.new(gfile)
+ except Exception:
+ pass
+ # theme icon
+ base_name = os.path.splitext(icon_spec)[0] if '.' in icon_spec else icon_spec
+ try:
+ return Gio.ThemedIcon.new(base_name)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ return None
+
+
+def _convert_mnemonic_to_gtk(label: str) -> str:
+ """Convert a Qt-style mnemonic in a label to GTK format.
+
+ - Qt: uses '&' before a character to mark the mnemonic (e.g., "&Quit").
+ A literal ampersand is written as "&&".
+ - GTK: uses '_' before the mnemonic character (e.g., "_Quit").
+
+ If no mnemonic marker ('&' not present), the string is returned unchanged.
+ Literal "&&" sequences are converted to a single '&'. Only the first
+ single '&' is converted to a mnemonic; subsequent single '&' are dropped.
+
+ This function does not attempt to escape underscores; GTK treats '_' as
+ mnemonic when present, but the input is expected to be Qt-style.
+ """
+ if label is None:
+ return label
+ s = str(label)
+ if '&' not in s:
+ return s
+
+ out = []
+ i = 0
+ mnemonic_done = False
+ n = len(s)
+ while i < n:
+ ch = s[i]
+ if ch == '&':
+ # Handle literal ampersand
+ if i + 1 < n and s[i + 1] == '&':
+ out.append('&')
+ i += 2
+ continue
+ # Single '&' indicates mnemonic; convert the first occurrence
+ if not mnemonic_done:
+ # Insert '_' before the next character (if any)
+ if i + 1 < n:
+ out.append('_')
+ mnemonic_done = True
+ # Skip this '&' (do not add to output)
+ i += 1
+ continue
+ else:
+ out.append(ch)
+ i += 1
+ return ''.join(out)
\ No newline at end of file
diff --git a/manatools/aui/backends/gtk/datefieldgtk.py b/manatools/aui/backends/gtk/datefieldgtk.py
new file mode 100644
index 0000000..e48ac03
--- /dev/null
+++ b/manatools/aui/backends/gtk/datefieldgtk.py
@@ -0,0 +1,318 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.gtk contains all gtk backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.gtk
+'''
+import logging
+import locale
+import datetime
+from gi.repository import Gtk, GLib
+from ...yui_common import *
+
+
+class YDateFieldGtk(YWidget):
+ """GTK backend YDateField implemented as a compact button with popup Gtk.Calendar.
+ value()/setValue() use ISO format YYYY-MM-DD. No change events posted.
+ """
+ def __init__(self, parent=None, label: str = ""):
+ super().__init__(parent)
+ self._label = label or ""
+ self._logger = logging.getLogger(f"manatools.aui.gtk.{self.__class__.__name__}")
+ # store current value as a date object (use only the date portion)
+ self._date = datetime.date(2000, 1, 1)
+ self._calendar = None
+ self._popover = None
+ self._locale_date_fmt = None
+
+ def widgetClass(self):
+ return "YDateField"
+
+ def value(self) -> str:
+ try:
+ d = getattr(self, '_date', None)
+ if d is None:
+ return ''
+ return d.isoformat()
+ except Exception:
+ return ""
+
+ def setValue(self, datestr: str):
+ try:
+ # accept ISO-like YYYY-MM-DD
+ parts = str(datestr).split('-')
+ if len(parts) != 3:
+ return
+ y, m, d = [int(p) for p in parts]
+ except Exception:
+ return
+ try:
+ y = max(1, min(9999, y))
+ m = max(1, min(12, m))
+ dmax = self._days_in_month(y, m)
+ d = max(1, min(dmax, d))
+ newdate = datetime.date(y, m, d)
+ except Exception:
+ return
+ self._set_date(newdate)
+
+ def _get_locale_date_fmt(self):
+ if getattr(self, '_locale_date_fmt', None):
+ return self._locale_date_fmt
+ try:
+ try:
+ locale.setlocale(locale.LC_TIME, '')
+ except Exception:
+ pass
+ fmt = locale.nl_langinfo(locale.D_FMT)
+ except Exception:
+ fmt = '%Y-%m-%d'
+ fmt = fmt or '%Y-%m-%d'
+ self._locale_date_fmt = fmt
+ return fmt
+
+ def _days_in_month(self, y, m):
+ if m in (1,3,5,7,8,10,12):
+ return 31
+ if m in (4,6,9,11):
+ return 30
+ # February
+ leap = (y % 4 == 0 and (y % 100 != 0 or y % 400 == 0))
+ return 29 if leap else 28
+
+ def _create_backend_widget(self):
+ box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
+ if self._label:
+ lbl = Gtk.Label.new(self._label)
+ lbl.set_xalign(0.0)
+ box.append(lbl)
+ self._label_widget = lbl
+
+ # Horizontal DateEdit: entry + calendar button
+ row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
+ self._date_entry = Gtk.Entry()
+ self._date_entry.set_hexpand(True)
+ # placeholder in system locale format
+ try:
+ fmt = self._get_locale_date_fmt()
+ sample = datetime.date(2000, 1, 15).strftime(fmt)
+ except Exception:
+ sample = "YYYY-MM-DD"
+ self._date_entry.set_placeholder_text(sample)
+ row.append(self._date_entry)
+
+ # MenuButton styled as dropdown
+ self._cal_button = Gtk.MenuButton()
+ row.append(self._cal_button)
+
+ # Popover with Calendar
+ self._popover = Gtk.Popover()
+ cal = Gtk.Calendar()
+
+ # GLib.DateTime uses 1-based months (January=1)
+ gdate = GLib.DateTime.new_utc(self._date.year, self._date.month, self._date.day, 0, 0, 0)
+ # Use set_date() for GTK 4.20+
+ try:
+ cal.set_date(gdate)
+ except Exception as e:
+ # Fall back to select_day() if set_date() fails unexpectedly
+ self._logger.debug("Failed to set_date() on calendar: %s", e)
+ try:
+ cal.select_day(gdate)
+ except Exception:
+ self._logger.exception("Failed to initialize calendar select_day()")
+
+ # Entry handlers: parse and commit to value
+ def _parse_and_set(datestr):
+ try:
+ parts = str(datestr).strip()
+ if not parts:
+ return False
+ y = m = d = None
+ if '-' in parts:
+ ymd = parts.split('-')
+ if len(ymd) == 3 and all(p.isdigit() for p in ymd):
+ y, m, d = [int(p) for p in ymd]
+ if y is None:
+ try:
+ fmt = self._get_locale_date_fmt()
+ dt = datetime.datetime.strptime(parts, fmt)
+ y, m, d = dt.year, dt.month, dt.day
+ except Exception:
+ return False
+ except Exception:
+ return False
+ y = max(1, min(9999, y))
+ m = max(1, min(12, m))
+ dmax = self._days_in_month(y, m)
+ d = max(1, min(dmax, d))
+ try:
+ newdate = datetime.date(y, m, d)
+ except Exception:
+ return False
+ self._date = newdate
+ # GLib.DateTime uses 1-based months (January=1)
+ gdate = GLib.DateTime.new_utc(self._date.year, self._date.month, self._date.day, 0, 0, 0)
+ # Use set_date() for GTK 4.20+
+ try:
+ self._calendar.set_date(gdate)
+ except Exception as e:
+ # Fall back to select_day() if set_date() fails unexpectedly
+ self._logger.debug("Failed to set_date() on calendar: %s", e)
+ try:
+ self._calendar.select_day(gdate)
+ except Exception:
+ self._logger.exception("Failed to initialize calendar select_day()")
+ self._logger.debug("entry commit: %04d-%02d-%02d", y, m, d)
+ return True
+
+ def _on_entry_activate(entry):
+ txt = entry.get_text()
+ _parse_and_set(txt)
+
+ try:
+ self._date_entry.connect('activate', _on_entry_activate)
+ self._date_entry.connect('changed', _on_entry_activate)
+ except Exception:
+ self._logger.exception("Failed to connect entry activate")
+
+ # Calendar handlers: pending selection, commit on popover close
+ def _refresh_from_calendar(calendar):
+ try:
+ try:
+ y = calendar.get_year()
+ m = calendar.get_month()
+ d = calendar.get_day()
+ except Exception:
+ self._logger.debug("calendar refresh: get_year/month/day failed (since gtk 4.14)")
+ try:
+ gdatetime = calendar.get_date()
+ y = gdatetime.get_year()
+ m = gdatetime.get_month()
+ d = gdatetime.get_day_of_month()
+ except Exception:
+ self._logger.exception("calendar refresh: get_date() failed")
+ return
+ # calendar.get_date() returns month as 0-based
+ y, m, d = int(y), int(m) + 1, int(d)
+ try:
+ pending = datetime.date(y, m, d)
+ except Exception:
+ pending = None
+ self._logger.debug("calendar refresh: invalid date %04d-%02d-%02d", y, m, d)
+ try:
+ if pending is not None:
+ self._logger.debug("calendar select pending=%04d-%02d-%02d", pending.year, pending.month, pending.day)
+ self._set_date(pending)
+ except Exception:
+ self._logger.exception("Failed to set date from calendar")
+ except Exception:
+ self._logger.exception("Failed in _refresh_from_calendar")
+
+ try:
+ cal.connect("day-selected", _refresh_from_calendar)
+ except Exception:
+ self._logger.exception("Failed to connect day-selected")
+ try:
+ cal.connect("next-month", _refresh_from_calendar)
+ except Exception:
+ self._logger.exception("Failed to connect next-month")
+ try:
+ cal.connect("prev-month", _refresh_from_calendar)
+ except Exception:
+ self._logger.exception("Failed to connect prev-month")
+ try:
+ cal.connect("next-year", _refresh_from_calendar)
+ except Exception:
+ self._logger.exception("Failed to connect next-year")
+ try:
+ cal.connect("prev-year", _refresh_from_calendar)
+ except Exception:
+ self._logger.exception("Failed to connect prev-year")
+
+ # Sync calendar to current value when popover opens
+ try:
+ def _on_button_clicked(b):
+ try:
+ self._logger.debug("popover open: sync %04d-%02d-%02d", self._date.year, self._date.month, self._date.day)
+ cal.set_year(self._date.year)
+ cal.set_month(self._date.month - 1)
+ cal.set_day(self._date.day)
+ except Exception:
+ self._logger.exception("Failed to sync calendar on popover open")
+ self._cal_button.connect('activate', _on_button_clicked)
+ except Exception:
+ self._logger.exception("Failed to connect button activate")
+
+ # Put calendar inside a box with margins inside the popover
+ popbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
+ try:
+ popbox.set_margin_top(6)
+ popbox.set_margin_bottom(6)
+ popbox.set_margin_start(6)
+ popbox.set_margin_end(6)
+ except Exception:
+ pass
+ popbox.append(cal)
+ self._popover.set_child(popbox)
+ try:
+ self._cal_button.set_popover(self._popover)
+ except Exception:
+ pass
+
+ # initial display update
+ self._calendar = cal
+ self._update_display()
+
+ box.append(row)
+
+ self._backend_widget = box
+ try:
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception:
+ pass
+
+ def _set_backend_enabled(self, enabled):
+ try:
+ for w in (getattr(self, '_date_entry', None), getattr(self, '_cal_button', None), getattr(self, '_label_widget', None)):
+ if w is not None:
+ w.set_sensitive(bool(enabled))
+ except Exception:
+ pass
+
+ def _update_display(self):
+ try:
+ if getattr(self, '_date_entry', None) is not None:
+ try:
+ fmt = self._get_locale_date_fmt()
+ d = getattr(self, '_date', None) or datetime.date(1, 1, 1)
+ txt = d.strftime(fmt)
+ except Exception:
+ d = getattr(self, '_date', None)
+ if d is None:
+ txt = ''
+ else:
+ txt = d.isoformat()
+ self._date_entry.set_text(txt)
+ except Exception:
+ pass
+
+ def _set_date(self, newdate: datetime.date):
+ '''
+ copies newdate into internal value and updates gtk.entry display
+
+ :param self: this instance
+ :param newdate: new date value
+ :type newdate: datetime.date
+ '''
+ try:
+ self._date = newdate
+ ## update entry display
+ self._update_display()
+ except Exception:
+ pass
diff --git a/manatools/aui/backends/gtk/dialoggtk.py b/manatools/aui/backends/gtk/dialoggtk.py
new file mode 100644
index 0000000..59d36f6
--- /dev/null
+++ b/manatools/aui/backends/gtk/dialoggtk.py
@@ -0,0 +1,528 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.gtk contains all GTK backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.gtk
+'''
+
+import gi
+gi.require_version('Gtk', '4.0')
+gi.require_version('Gdk', '4.0')
+from gi.repository import Gtk, Gdk, GObject, GdkPixbuf, GLib
+import cairo
+import threading
+import os
+import logging
+from ...yui_common import *
+from ... import yui as yui_mod
+
+
+class _YDialogMeasureWindow(Gtk.Window):
+ """Gtk.Window subclass delegating size measurement to YDialogGtk."""
+
+ def __init__(self, owner, title=""):
+ """Initialize the measuring window.
+
+ Args:
+ owner: Owning YDialogGtk instance.
+ title: Initial window title.
+ """
+ super().__init__(title=title)
+ self._owner = owner
+
+ def do_measure(self, orientation, for_size):
+ """GTK4 virtual method for size measurement.
+
+ Args:
+ orientation: Gtk.Orientation (HORIZONTAL or VERTICAL)
+ for_size: Size in the opposite orientation (-1 if not constrained)
+
+ Returns:
+ tuple: (minimum_size, natural_size, minimum_baseline, natural_baseline)
+ """
+ try:
+ return self._owner.do_measure(orientation, for_size)
+ except Exception:
+ self._owner._logger.exception("Dialog backend do_measure delegation failed", exc_info=True)
+ return (0, 0, -1, -1)
+
+
+class YDialogGtk(YSingleChildContainerWidget):
+ """Gtk4 dialog window with nested-loop event handling and default button support."""
+ _open_dialogs = []
+
+ def __init__(self, dialog_type=YDialogType.YMainDialog, color_mode=YDialogColorMode.YDialogNormalColor):
+ super().__init__()
+ self._dialog_type = dialog_type
+ self._color_mode = color_mode
+ self._is_open = False
+ self._window = None
+ self._event_result = None
+ self._glib_loop = None
+ self._default_button = None
+ self._default_key_controller = None
+ self._content_widget = None
+ YDialogGtk._open_dialogs.append(self)
+ self._logger = logging.getLogger(f"manatools.aui.gtk.{self.__class__.__name__}")
+
+ def widgetClass(self):
+ return "YDialog"
+
+ @staticmethod
+ def currentDialog(doThrow=True):
+ open_dialog = YDialogGtk._open_dialogs[-1] if YDialogGtk._open_dialogs else None
+ if not open_dialog and doThrow:
+ raise YUINoDialogException("No dialog is currently open")
+ return open_dialog
+
+ @staticmethod
+ def topmostDialog(doThrow=True):
+ ''' same as currentDialog '''
+ return YDialogGtk.currentDialog(doThrow=doThrow)
+
+ def isTopmostDialog(self):
+ '''Return whether this dialog is the topmost open dialog.'''
+ return YDialogGtk._open_dialogs[-1] == self if YDialogGtk._open_dialogs else False
+
+ def open(self):
+ # Finalize and show the dialog in a non-blocking way.
+ if not self._is_open:
+ if not self._window:
+ self._create_backend_widget()
+ # in Gtk4, show_all is not recommended; use present() or show
+ try:
+ self._window.present()
+ except Exception:
+ try:
+ self._window.show()
+ except Exception:
+ pass
+ self._is_open = True
+
+ def isOpen(self):
+ return self._is_open
+
+ def destroy(self, doThrow=True):
+ self._clear_default_button()
+ if self._window:
+ try:
+ self._window.destroy()
+ except Exception:
+ try:
+ self._window.close()
+ except Exception:
+ pass
+ self._window = None
+ self._is_open = False
+ if self in YDialogGtk._open_dialogs:
+ YDialogGtk._open_dialogs.remove(self)
+
+ # Stop GLib main loop if no dialogs left (nested loops only)
+ if not YDialogGtk._open_dialogs:
+ try:
+ if self._glib_loop and self._glib_loop.is_running():
+ self._glib_loop.quit()
+ except Exception:
+ pass
+ return True
+
+ def _post_event(self, event):
+ """Internal: post an event to this dialog and quit local GLib.MainLoop if running."""
+ self._event_result = event
+ if self._glib_loop is not None and self._glib_loop.is_running():
+ try:
+ self._glib_loop.quit()
+ except Exception:
+ pass
+
+ def waitForEvent(self, timeout_millisec=0):
+ """
+ Run a GLib.MainLoop until an event is posted or timeout occurs.
+ Returns a YEvent (YWidgetEvent, YTimeoutEvent, ...).
+ """
+ # Ensure dialog is finalized/open (finalize if caller didn't call open()).
+ if not self.isOpen():
+ self.open()
+
+ # Let GTK/GLib process pending events (show/layout) before entering nested loop.
+ # Gtk.events_pending()/Gtk.main_iteration() do not exist in GTK4; use MainContext iteration.
+ try:
+ ctx = GLib.MainContext.default()
+ while ctx.pending():
+ ctx.iteration(False)
+ except Exception:
+ # be defensive if API differs on some bindings
+ pass
+
+ self._event_result = None
+ self._glib_loop = GLib.MainLoop()
+
+ def on_timeout():
+ # post timeout event and quit loop
+ try:
+ self._event_result = YTimeoutEvent()
+ except Exception:
+ pass
+ # mark timeout id consumed so cleanup won't try to remove it again
+ try:
+ self._timeout_id = None
+ except Exception:
+ pass
+ try:
+ if self._glib_loop.is_running():
+ self._glib_loop.quit()
+ except Exception:
+ pass
+ return False # don't repeat
+
+ self._timeout_id = None
+ if timeout_millisec and timeout_millisec > 0:
+ self._timeout_id = GLib.timeout_add(timeout_millisec, on_timeout)
+
+ # Handle Ctrl-C gracefully: add a GLib SIGINT source to quit the loop and post CancelEvent
+ self._sigint_source_id = None
+ try:
+ def _on_sigint(*_args):
+ try:
+ self._post_event(YCancelEvent())
+ except Exception:
+ pass
+ try:
+ if self._glib_loop.is_running():
+ self._glib_loop.quit()
+ except Exception:
+ pass
+ return True
+ # GLib.unix_signal_add is available on Linux; use default priority
+ if hasattr(GLib, 'unix_signal_add'):
+ self._sigint_source_id = GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, 2, _on_sigint) # 2 = SIGINT
+ except Exception:
+ self._sigint_source_id = None
+
+ # run nested loop; suppress KeyboardInterrupt raised by GI fallback on exit
+ try:
+ self._glib_loop.run()
+ except KeyboardInterrupt:
+ # Convert to a cancel event and continue cleanup
+ try:
+ self._event_result = YCancelEvent()
+ except Exception:
+ pass
+
+ # cleanup
+ if self._timeout_id:
+ try:
+ GLib.source_remove(self._timeout_id)
+ except Exception:
+ pass
+ self._timeout_id = None
+ # remove SIGINT source if installed
+ try:
+ if self._sigint_source_id:
+ GLib.source_remove(self._sigint_source_id)
+ except Exception:
+ pass
+ self._sigint_source_id = None
+ self._glib_loop = None
+ return self._event_result if self._event_result is not None else YEvent()
+
+ @classmethod
+ def deleteTopmostDialog(cls, doThrow=True):
+ if cls._open_dialogs:
+ dialog = cls._open_dialogs[-1]
+ return dialog.destroy(doThrow)
+ return False
+
+ @classmethod
+ def deleteAllDialogs(cls, doThrow=True):
+ """Delete all open dialogs (best-effort)."""
+ ok = True
+ try:
+ while cls._open_dialogs:
+ try:
+ dlg = cls._open_dialogs[-1]
+ dlg.destroy(doThrow)
+ except Exception:
+ ok = False
+ try:
+ cls._open_dialogs.pop()
+ except Exception:
+ break
+ except Exception:
+ ok = False
+ return ok
+
+ @classmethod
+ def currentDialog(cls, doThrow=True):
+ if not cls._open_dialogs:
+ if doThrow:
+ raise YUINoDialogException("No dialog open")
+ return None
+ return cls._open_dialogs[-1]
+
+ def setDefaultButton(self, button):
+ """Set or clear the default push button for this dialog."""
+ if button is None:
+ self._clear_default_button()
+ return True
+ try:
+ if button.widgetClass() != "YPushButton":
+ raise ValueError("Default button must be a YPushButton")
+ except Exception:
+ self._logger.error("Invalid widget passed to setDefaultButton", exc_info=True)
+ return False
+ try:
+ dlg = button.findDialog() if hasattr(button, "findDialog") else None
+ except Exception:
+ dlg = None
+ if dlg not in (None, self):
+ self._logger.error("Refusing to set a default button owned by another dialog")
+ return False
+ try:
+ button.setDefault(True)
+ except Exception:
+ self._logger.exception("Failed to activate default button")
+ return False
+ return True
+
+ def do_measure(self, orientation, for_size):
+ """GTK4 virtual method for size measurement.
+
+ Args:
+ orientation: Gtk.Orientation (HORIZONTAL or VERTICAL)
+ for_size: Size in the opposite orientation (-1 if not constrained)
+
+ Returns:
+ tuple: (minimum_size, natural_size, minimum_baseline, natural_baseline)
+ """
+ container = getattr(self, "_content_widget", None)
+ if container is not None:
+ try:
+ cmin, cnat, _bmin, _bnat = container.measure(orientation, for_size)
+ margin = 20
+ minimum_size = int(cmin + margin)
+ natural_size = int(cnat + margin)
+ self._logger.debug(
+ "Dialog do_measure orientation=%s for_size=%s -> min=%s nat=%s",
+ orientation,
+ for_size,
+ minimum_size,
+ natural_size,
+ )
+ return (minimum_size, natural_size, -1, -1)
+ except Exception:
+ self._logger.exception("Dialog content measure failed", exc_info=True)
+
+ if orientation == Gtk.Orientation.HORIZONTAL:
+ return (420, 600, -1, -1)
+ return (280, 400, -1, -1)
+
+ def _create_backend_widget(self):
+ # Determine window title from YApplicationGtk instance stored on the YUI backend
+ title = "Manatools GTK Dialog"
+ try:
+ appobj = yui_mod.YUI.ui().application()
+ atitle = appobj.applicationTitle()
+ if atitle:
+ title = atitle
+ # try to obtain resolved pixbuf from application and store for window icon
+ _resolved_pixbuf = None
+ try:
+ _resolved_pixbuf = getattr(appobj, "_gtk_icon_pixbuf", None)
+ except Exception:
+ _resolved_pixbuf = None
+ except Exception:
+ try:
+ self._logger.warning("Could not determine application title for dialog", exc_info=True)
+ except Exception:
+ pass
+ pass
+
+ # Create Gtk4 Window
+ self._window = _YDialogMeasureWindow(self, title=title)
+ # set window icon if available
+ try:
+ if _resolved_pixbuf is not None:
+ try:
+ self._window.set_icon(_resolved_pixbuf)
+ except Exception:
+ try:
+ # fallback to name if pixbuf not accepted
+ icname = getattr(appobj, "applicationIcon", lambda : None)()
+ if icname:
+ self._window.set_icon_name(icname)
+ except Exception:
+ pass
+ else:
+ try:
+ # try setting icon name if application provided it
+ icname = getattr(appobj, "applicationIcon", lambda : None)()
+ if icname:
+ self._window.set_icon_name(icname)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ #try:
+ # self._window.set_default_size(600, 400)
+ #except Exception:
+ # pass
+
+ # Content container with margins (window.set_child used in Gtk4)
+ content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5)
+ content.set_margin_start(10)
+ content.set_margin_end(10)
+ content.set_margin_top(10)
+ content.set_margin_bottom(10)
+ self._content_widget = content
+
+ child = self.child()
+ if child:
+ child_widget = child.get_backend_widget()
+ # ensure child is shown properly
+ try:
+ content.append(child_widget)
+ except Exception:
+ try:
+ content.add(child_widget)
+ except Exception:
+ pass
+
+ try:
+ self._window.set_child(content)
+ except Exception:
+ # fallback for older bindings
+ try:
+ self._window.add(content)
+ except Exception:
+ pass
+
+ self._backend_widget = self._window
+ self._backend_widget.set_sensitive(self._enabled)
+ # Install key controller to trigger the default button with Enter/Return.
+ try:
+ controller = Gtk.EventControllerKey()
+ controller.connect("key-pressed", self._on_default_key_pressed)
+ self._window.add_controller(controller)
+ self._default_key_controller = controller
+ except Exception:
+ self._logger.exception("Failed to install default button key controller")
+ # Connect destroy/close handlers
+ try:
+ # Gtk4: use 'close-request' if available, otherwise 'destroy'
+ if hasattr(self._window, "connect"):
+ try:
+ self._window.connect("close-request", self._on_delete_event)
+ except Exception:
+ try:
+ self._window.connect("destroy", self._on_destroy)
+ except Exception:
+ pass
+ except Exception:
+ self._logger.error("Failed to connect window close/destroy handlers", exc_info=True)
+ try:
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception:
+ pass
+
+ def _on_destroy(self, widget):
+ try:
+ self.destroy()
+ except Exception:
+ pass
+
+ def _on_delete_event(self, *args):
+ # close-request handler in Gtk4: post cancel event and destroy
+ try:
+ self._post_event(YCancelEvent())
+ except Exception:
+ pass
+ try:
+ self.destroy()
+ except Exception:
+ pass
+ # returning False/True not used in this simplified handler
+ return False
+
+ def setVisible(self, visible):
+ """Set widget visibility."""
+ if self._backend_widget:
+ try:
+ self._backend_widget.set_visible(visible)
+ except Exception:
+ pass
+ super().setVisible(visible)
+
+ def _set_backend_enabled(self, enabled):
+ """Enable/disable the dialog window backend."""
+ try:
+ if self._window is not None:
+ try:
+ self._window.set_sensitive(enabled)
+ except Exception:
+ # fallback: propagate to child content
+ try:
+ child = getattr(self, "_window", None)
+ if child and hasattr(child, "set_sensitive"):
+ child.set_sensitive(enabled)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def _on_default_key_pressed(self, controller, keyval, keycode, state):
+ """Activate the default button when Return/Enter is pressed."""
+ try:
+ if keyval in (Gdk.KEY_Return, Gdk.KEY_ISO_Enter, Gdk.KEY_KP_Enter):
+ if self._activate_default_button():
+ return True
+ except Exception:
+ self._logger.exception("Default key handler failed")
+ return False
+
+ def _register_default_button(self, button):
+ """Ensure this dialog tracks a single default push button."""
+ if getattr(self, "_default_button", None) == button:
+ return
+ if getattr(self, "_default_button", None) is not None:
+ try:
+ self._default_button._apply_default_state(False, notify_dialog=False)
+ except Exception:
+ self._logger.exception("Failed to clear previous default button")
+ self._default_button = button
+
+ def _unregister_default_button(self, button):
+ """Drop dialog reference when button is no longer default."""
+ if getattr(self, "_default_button", None) == button:
+ self._default_button = None
+
+ def _clear_default_button(self):
+ """Clear any current default button."""
+ if getattr(self, "_default_button", None) is not None:
+ try:
+ self._default_button._apply_default_state(False, notify_dialog=False)
+ except Exception:
+ self._logger.exception("Failed to reset default button state")
+ self._default_button = None
+
+ def _activate_default_button(self):
+ """Invoke the current default button if enabled and visible."""
+ button = getattr(self, "_default_button", None)
+ if button is None:
+ return False
+ try:
+ if not button.isEnabled() or not button.visible():
+ return False
+ except Exception:
+ return False
+ try:
+ self._post_event(YWidgetEvent(button, YEventReason.Activated))
+ return True
+ except Exception:
+ self._logger.exception("Failed to activate default button")
+ return False
diff --git a/manatools/aui/backends/gtk/dumbtabgtk.py b/manatools/aui/backends/gtk/dumbtabgtk.py
new file mode 100644
index 0000000..7bbb4b7
--- /dev/null
+++ b/manatools/aui/backends/gtk/dumbtabgtk.py
@@ -0,0 +1,242 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.gtk contains all GTK backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.gtk
+'''
+'''
+GTK4 backend DumbTab (tab bar + single content area)
+
+Implements a simple tab bar using a row of toggle buttons (single selection)
+placed above a content area where applications typically attach a ReplacePoint.
+
+This is a YSelectionWidget: it manages items, single selection, and
+emits WidgetEvent(Activated) when the active tab changes.
+'''
+import gi
+gi.require_version('Gtk', '4.0')
+from gi.repository import Gtk, GLib
+import logging
+from ...yui_common import *
+
+class YDumbTabGtk(YSelectionWidget):
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self._logger = logging.getLogger(f"manatools.aui.gtk.{self.__class__.__name__}")
+ self._box = None
+ self._tabbar = None
+ self._content = None
+ self.setStretchable(YUIDimension.YD_HORIZ, True)
+ self.setStretchable(YUIDimension.YD_VERT, True)
+
+ def widgetClass(self):
+ return "YDumbTab"
+
+ def _create_backend_widget(self):
+ try:
+ self._box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5)
+ # Gtk4: Use Gtk.StackSwitcher + Gtk.Stack (Notebook-like)
+ self._stack = Gtk.Stack()
+ try:
+ self._stack.set_hexpand(True)
+ self._stack.set_vexpand(False)
+ # keep stack small; content is below
+ self._stack.set_size_request(1, 1)
+ except Exception:
+ pass
+ switcher = Gtk.StackSwitcher()
+ try:
+ switcher.set_stack(self._stack)
+ except Exception:
+ pass
+ self._tabbar = switcher
+ # separate content area below tabs
+ self._content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
+ try:
+ self._tabbar.set_hexpand(True)
+ self._content.set_hexpand(True)
+ self._content.set_vexpand(True)
+ except Exception:
+ pass
+ self._box.append(self._tabbar)
+ self._box.append(self._stack)
+ self._box.append(self._content)
+
+ # populate tabs/pages from items
+ active_idx = -1
+ for idx, it in enumerate(self._items):
+ # Create an empty page for each tab label
+ page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
+ name = f"tab-{idx}"
+ try:
+ self._stack.add_titled(page, name, it.label())
+ except Exception:
+ # Fallback: use add_child then set properties
+ try:
+ self._stack.add_child(page)
+ except Exception:
+ pass
+ if it.selected():
+ active_idx = idx
+ if active_idx < 0 and len(self._items) > 0:
+ active_idx = 0
+ try:
+ self._items[0].setSelected(True)
+ except Exception:
+ pass
+ # set active after building to avoid intermediate signals
+ if active_idx >= 0:
+ try:
+ self._stack.set_visible_child_name(f"tab-{active_idx}")
+ self._selected_items = [ self._items[active_idx] ]
+ except Exception:
+ pass
+
+ # attach pre-existing child content (e.g., ReplacePoint)
+ try:
+ if self.hasChildren():
+ ch = self.firstChild()
+ if ch is not None:
+ w = ch.get_backend_widget()
+ try:
+ self._content.append(w)
+ except Exception:
+ pass
+ try:
+ self._logger.debug("YDumbTabGtk._create_backend_widget: attached pre-existing child %s", ch.widgetClass())
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ self._box.set_sensitive(self._enabled)
+ self._backend_widget = self._box
+ try:
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception:
+ pass
+ # Notify on tab changes
+ try:
+ self._stack.connect('notify::visible-child-name', self._on_stack_changed)
+ except Exception:
+ pass
+ except Exception:
+ try:
+ self._logger.exception("YDumbTabGtk _create_backend_widget failed")
+ except Exception:
+ pass
+
+ def addChild(self, child):
+ # Accept only a single child; attach its backend into content area
+ if self.hasChildren():
+ raise YUIInvalidWidgetException("YDumbTab can only have one child")
+ super().addChild(child)
+ try:
+ if self._content is not None:
+ w = child.get_backend_widget()
+ self._content.append(w)
+ try:
+ self._logger.debug("YDumbTabGtk.addChild: attached %s into content", child.widgetClass())
+ except Exception:
+ pass
+ except Exception:
+ try:
+ self._logger.exception("YDumbTabGtk.addChild failed")
+ except Exception:
+ pass
+
+ def addItem(self, item):
+ super().addItem(item)
+ try:
+ if getattr(self, '_stack', None) is not None:
+ page = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
+ name = f"tab-{len(self._items) - 1}"
+ self._stack.add_titled(page, name, item.label())
+ if item.selected():
+ try:
+ self._stack.set_visible_child_name(name)
+ except Exception:
+ pass
+ self._sync_selection_from_stack()
+ except Exception:
+ try:
+ self._logger.exception("YDumbTabGtk.addItem failed")
+ except Exception:
+ pass
+
+ def selectItem(self, item, selected=True):
+ try:
+ if not selected:
+ return
+ target_idx = None
+ for i, it in enumerate(self._items):
+ if it is item:
+ target_idx = i
+ break
+ if target_idx is None:
+ return
+ try:
+ if getattr(self, '_stack', None) is not None:
+ self._stack.set_visible_child_name(f"tab-{target_idx}")
+ except Exception:
+ pass
+ for it in self._items:
+ it.setSelected(False)
+ item.setSelected(True)
+ self._selected_items = [item]
+ except Exception:
+ try:
+ self._logger.exception("YDumbTabGtk.selectItem failed")
+ except Exception:
+ pass
+
+ def _on_stack_changed(self, stack, pspec):
+ try:
+ name = None
+ try:
+ name = stack.get_visible_child_name()
+ except Exception:
+ pass
+ index = -1
+ if name and name.startswith("tab-"):
+ try:
+ index = int(name.split("-", 1)[1])
+ except Exception:
+ index = -1
+ # update model
+ if 0 <= index < len(self._items):
+ for i, it in enumerate(self._items):
+ try:
+ it.setSelected(i == index)
+ except Exception:
+ pass
+ self._selected_items = [ self._items[index] ]
+ else:
+ self._selected_items = []
+ # post event
+ if self.notify():
+ dlg = self.findDialog()
+ if dlg is not None:
+ dlg._post_event(YWidgetEvent(self, YEventReason.Activated))
+ except Exception:
+ try:
+ self._logger.exception("YDumbTabGtk._on_stack_changed failed")
+ except Exception:
+ pass
+
+ def _sync_selection_from_stack(self):
+ try:
+ name = self._stack.get_visible_child_name() if getattr(self, '_stack', None) is not None else None
+ if name and name.startswith("tab-"):
+ i = int(name.split("-", 1)[1])
+ if 0 <= i < len(self._items):
+ self._selected_items = [ self._items[i] ]
+ return
+ self._selected_items = []
+ except Exception:
+ self._selected_items = []
diff --git a/manatools/aui/backends/gtk/framegtk.py b/manatools/aui/backends/gtk/framegtk.py
new file mode 100644
index 0000000..6d81afd
--- /dev/null
+++ b/manatools/aui/backends/gtk/framegtk.py
@@ -0,0 +1,297 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.gtk contains all GTK backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.gtk
+'''
+
+import gi
+gi.require_version('Gtk', '4.0')
+gi.require_version('Gdk', '4.0')
+import logging
+from gi.repository import Gtk, Gdk, GObject, GdkPixbuf, GLib
+import cairo
+import threading
+import os
+from ...yui_common import *
+
+
+class YFrameGtk(YSingleChildContainerWidget):
+ """
+ GTK backend implementation of YFrame.
+
+ - Uses Gtk.Frame (when available) to present a labeled framed container.
+ - Internally places a Gtk.Box inside the frame to host the single child.
+ - Honors child's stretchability: the frame reports stretchable when its child is stretchable
+ so parent layouts can allocate extra space.
+ - Provides simple property support for 'label'.
+ """
+ def __init__(self, parent=None, label: str = ""):
+ super().__init__(parent)
+ self._label = label or ""
+ self._backend_widget = None
+ self._content_box = None
+ self._logger = logging.getLogger(f"manatools.aui.gtk.{self.__class__.__name__}")
+
+ def widgetClass(self):
+ return "YFrame"
+
+ def label(self):
+ return self._label
+
+ def setLabel(self, new_label: str):
+ """Set the frame label and update backend if created."""
+ try:
+ self._label = new_label or ""
+ if getattr(self, "_backend_widget", None) is not None:
+ try:
+ # Gtk.Frame in GTK4 supports set_label() in some bindings, else use a child label
+ if hasattr(self._backend_widget, "set_label"):
+ self._backend_widget.set_label(self._label)
+ else:
+ # fallback: if we created a dedicated label child, update it
+ if getattr(self, "_label_widget", None) is not None:
+ try:
+ self._label_widget.set_text(self._label)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def stretchable(self, dim: YUIDimension):
+ """
+ Report stretchability in a dimension.
+
+ The frame is stretchable when its child is stretchable or has a layout weight.
+ """
+ try:
+ child = self.child()
+ if child is None:
+ return False
+ try:
+ if bool(child.stretchable(dim)):
+ return True
+ except Exception:
+ pass
+ try:
+ if bool(child.weight(dim)):
+ return True
+ except Exception:
+ pass
+ except Exception:
+ pass
+ return False
+
+ def _attach_child_backend(self):
+ """Attach the child's backend widget into the frame's content box."""
+ try:
+ if self._backend_widget is None:
+ return
+ if self._content_box is None:
+ return
+ child = self.child()
+ if child is None:
+ return
+ try:
+ cw = child.get_backend_widget()
+ except Exception:
+ cw = None
+ if cw is None:
+ return
+
+ # Remove existing content children (defensive)
+ try:
+ while True:
+ first = self._content_box.get_first_child()
+ if first is None:
+ break
+ try:
+ self._content_box.remove(first)
+ except Exception:
+ break
+ except Exception:
+ pass
+
+ # Append child widget into content box
+ try:
+ self._content_box.append(cw)
+ except Exception:
+ try:
+ self._content_box.add(cw)
+ except Exception:
+ pass
+
+ # Ensure expansion hints propagate from child
+ try:
+ if child.stretchable(YUIDimension.YD_VERT):
+ if hasattr(cw, "set_vexpand"):
+ cw.set_vexpand(True)
+ if hasattr(cw, "set_valign"):
+ cw.set_valign(Gtk.Align.FILL)
+ else:
+ if hasattr(cw, "set_vexpand"):
+ cw.set_vexpand(False)
+ if hasattr(cw, "set_valign"):
+ cw.set_valign(Gtk.Align.START)
+ if child.stretchable(YUIDimension.YD_HORIZ):
+ if hasattr(cw, "set_hexpand"):
+ cw.set_hexpand(True)
+ if hasattr(cw, "set_halign"):
+ cw.set_halign(Gtk.Align.FILL)
+ else:
+ if hasattr(cw, "set_hexpand"):
+ cw.set_hexpand(False)
+ if hasattr(cw, "set_halign"):
+ cw.set_halign(Gtk.Align.START)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def addChild(self, child):
+ """Add logical child and attach backend if possible."""
+ super().addChild(child)
+ # best-effort fallback
+ self._attach_child_backend()
+
+ def _create_backend_widget(self):
+ """
+ Create a Gtk.Frame + inner box to host the single child.
+ Fall back to a bordered Gtk.Box when Gtk.Frame or set_label is not available.
+ """
+ try:
+ # Try to create a Gtk.Frame with a label if supported
+ try:
+ frame = Gtk.Frame()
+ # set label if API supports it
+ if hasattr(frame, "set_label"):
+ frame.set_label(self._label)
+ self._label_widget = None
+ else:
+ # create a label widget and set as label using set_label_widget if supported
+ lbl = Gtk.Label(label=self._label)
+ self._label_widget = lbl
+ if hasattr(frame, "set_label_widget"):
+ frame.set_label_widget(lbl)
+ # Create inner content box
+ content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
+ content.set_hexpand(True)
+ content.set_vexpand(True)
+ # Append content inside frame. In GTK4 a Frame can have a single child.
+ try:
+ frame.set_child(content)
+ except Exception:
+ try:
+ # fallback: some bindings use add()
+ frame.add(content)
+ except Exception:
+ pass
+ self._backend_widget = frame
+ self._content_box = content
+ self._backend_widget.set_sensitive(self._enabled)
+ # attach existing child if any
+ try:
+ if self.hasChildren():
+ self._attach_child_backend()
+ except Exception:
+ pass
+ try:
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception:
+ pass
+ return
+ except Exception:
+ # fallback to a boxed container with a visible border using CSS if Frame creation fails
+ pass
+
+ # Fallback container: vertical box with a top label and a framed-like border (best-effort)
+ container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
+ try:
+ lbl = Gtk.Label(label=self._label)
+ lbl.set_xalign(0.0)
+ container.append(lbl)
+ # content area
+ content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
+ content.set_hexpand(True)
+ content.set_vexpand(True)
+ container.append(content)
+ self._label_widget = lbl
+ self._backend_widget = container
+ self._content_box = content
+ if self.hasChildren():
+ try:
+ self._attach_child_backend()
+ except Exception:
+ pass
+ except Exception:
+ # ultimate fallback: empty widget reference
+ self._backend_widget = None
+ self._content_box = None
+ except Exception:
+ self._backend_widget = None
+ self._content_box = None
+ try:
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception:
+ pass
+
+ def _set_backend_enabled(self, enabled):
+ """Enable/disable the frame and propagate to child."""
+ try:
+ if self._backend_widget is not None:
+ try:
+ self._backend_widget.set_sensitive(enabled)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ # propagate to logical child
+ try:
+ child = self.child()
+ if child is not None:
+ try:
+ child.setEnabled(enabled)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def setProperty(self, propertyName, val):
+ """Handle simple properties; returns True if property handled here."""
+ try:
+ if propertyName == "label":
+ try:
+ self.setLabel(str(val))
+ except Exception:
+ pass
+ return True
+ except Exception:
+ pass
+ return False
+
+ def getProperty(self, propertyName):
+ try:
+ if propertyName == "label":
+ return self.label()
+ except Exception:
+ pass
+ return None
+
+ def propertySet(self):
+ """Return a minimal property set description for introspection."""
+ try:
+ props = YPropertySet()
+ try:
+ props.add(YProperty("label", YPropertyType.YStringProperty))
+ except Exception:
+ pass
+ return props
+ except Exception:
+ return None
diff --git a/manatools/aui/backends/gtk/hboxgtk.py b/manatools/aui/backends/gtk/hboxgtk.py
new file mode 100644
index 0000000..73af9d9
--- /dev/null
+++ b/manatools/aui/backends/gtk/hboxgtk.py
@@ -0,0 +1,256 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.gtk contains all GTK backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.gtk
+'''
+
+import gi
+gi.require_version('Gtk', '4.0')
+gi.require_version('Gdk', '4.0')
+from gi.repository import Gtk, Gdk, GObject, GdkPixbuf, GLib
+import cairo
+import threading
+import os
+import logging
+from ...yui_common import *
+
+
+class _YHBoxMeasureBox(Gtk.Box):
+ """Gtk.Box subclass delegating size requests to YHBoxGtk."""
+
+ def __init__(self, owner):
+ """Initialize the measuring box.
+
+ Args:
+ owner: Owning YHBoxGtk instance.
+ """
+ super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
+ self._owner = owner
+
+ def do_measure(self, orientation, for_size):
+ """GTK4 virtual method for size measurement.
+
+ Args:
+ orientation: Gtk.Orientation (HORIZONTAL or VERTICAL)
+ for_size: Size in the opposite orientation (-1 if not constrained)
+
+ Returns:
+ tuple: (minimum_size, natural_size, minimum_baseline, natural_baseline)
+ """
+ try:
+ return self._owner.do_measure(orientation, for_size)
+ except Exception:
+ self._owner._logger.exception("HBox backend do_measure delegation failed", exc_info=True)
+ return (0, 0, -1, -1)
+
+
+class YHBoxGtk(YWidget):
+ """Horizontal GTK4 container with weight-aware geometry management."""
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self._logger = logging.getLogger(f"manatools.aui.gtk.{self.__class__.__name__}")
+
+ def widgetClass(self):
+ return "YHBox"
+
+ def stretchable(self, dim):
+ for child in self._children:
+ expand = bool(child.stretchable(dim))
+ weight = bool(child.weight(dim))
+ if expand or weight:
+ return True
+ return False
+
+ def do_measure(self, orientation, for_size):
+ """GTK4 virtual method for size measurement.
+
+ Args:
+ orientation: Gtk.Orientation (HORIZONTAL or VERTICAL)
+ for_size: Size in the opposite orientation (-1 if not constrained)
+
+ Returns:
+ tuple: (minimum_size, natural_size, minimum_baseline, natural_baseline)
+ """
+ children = list(getattr(self, "_children", []) or [])
+ if not children:
+ return (0, 0, -1, -1)
+
+ spacing = 5
+ try:
+ if getattr(self, "_backend_widget", None) is not None and hasattr(self._backend_widget, "get_spacing"):
+ spacing = int(self._backend_widget.get_spacing())
+ except Exception:
+ self._logger.exception("Failed to read HBox spacing for measure", exc_info=True)
+ spacing = 5
+
+ minimum_size = 0
+ natural_size = 0
+ maxima_minimum = 0
+ maxima_natural = 0
+
+ for child in children:
+ try:
+ child_widget = child.get_backend_widget()
+ cmin, cnat, _cbase_min, _cbase_nat = child_widget.measure(orientation, for_size)
+ except Exception:
+ self._logger.exception("HBox measure failed for child %s", getattr(child, "debugLabel", lambda: "")(), exc_info=True)
+ cmin, cnat = 0, 0
+
+ if orientation == Gtk.Orientation.HORIZONTAL:
+ minimum_size += int(cmin)
+ natural_size += int(cnat)
+ else:
+ maxima_minimum = max(maxima_minimum, int(cmin))
+ maxima_natural = max(maxima_natural, int(cnat))
+
+ if orientation == Gtk.Orientation.HORIZONTAL:
+ gap_total = max(0, len(children) - 1) * spacing
+ minimum_size += gap_total
+ natural_size += gap_total
+ else:
+ minimum_size = maxima_minimum
+ natural_size = maxima_natural
+
+ self._logger.debug(
+ "HBox do_measure orientation=%s for_size=%s -> min=%s nat=%s",
+ orientation,
+ for_size,
+ minimum_size,
+ natural_size,
+ )
+ return (minimum_size, natural_size, -1, -1)
+
+ def _create_backend_widget(self):
+ """Create backend widget and configure weight/stretch behavior."""
+ self._backend_widget = _YHBoxMeasureBox(self)
+
+ # Collect children first so we can apply weight-based heuristics
+ children = list(self._children)
+
+ for child in children:
+ try:
+ self._logger.debug("HBox child: %s stretch(H)=%s weight(H)=%s stretch(V)=%s", child.widgetClass(), child.stretchable(YUIDimension.YD_HORIZ), child.weight(YUIDimension.YD_HORIZ), child.stretchable(YUIDimension.YD_VERT))
+ except Exception:
+ pass
+ widget = child.get_backend_widget()
+ try:
+ # Respect per-axis stretch flags
+ hexp = bool(child.stretchable(YUIDimension.YD_HORIZ) or child.weight(YUIDimension.YD_HORIZ))
+ vexp = bool(child.stretchable(YUIDimension.YD_VERT) or child.weight(YUIDimension.YD_VERT))
+ widget.set_hexpand(hexp)
+ widget.set_vexpand(vexp)
+ try:
+ widget.set_halign(Gtk.Align.FILL if hexp else Gtk.Align.CENTER)
+ except Exception:
+ pass
+ try:
+ widget.set_valign(Gtk.Align.FILL if vexp else Gtk.Align.CENTER)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ try:
+ self._backend_widget.append(widget)
+ except Exception:
+ try:
+ self._backend_widget.add(widget)
+ except Exception:
+ pass
+ self._backend_widget.set_sensitive(self._enabled)
+
+ # If there are exactly two children and both declare positive horizontal
+ # weights, attempt to enforce a proportional split according to weights.
+ # Gtk.Box does not have native per-child weight factors, so we set
+ # size requests on the children based on the container allocation.
+ try:
+ if len(children) == 2:
+ w0 = int(children[0].weight(YUIDimension.YD_HORIZ) or 0)
+ w1 = int(children[1].weight(YUIDimension.YD_HORIZ) or 0)
+ if w0 > 0 and w1 > 0:
+ left = children[0].get_backend_widget()
+ right = children[1].get_backend_widget()
+ total_weight = max(1, w0 + w1)
+
+ def _apply_weights(*args):
+ try:
+ alloc = self._backend_widget.get_width()
+ if not alloc or alloc <= 0:
+ return True
+ # subtract spacing and margins conservatively
+ spacing = getattr(self._backend_widget, 'get_spacing', lambda: 5)()
+ avail = max(0, alloc - spacing)
+ left_px = int(avail * w0 / total_weight)
+ right_px = max(0, avail - left_px)
+ try:
+ left.set_size_request(left_px, -1)
+ except Exception:
+ pass
+ try:
+ right.set_size_request(right_px, -1)
+ except Exception:
+ pass
+ except Exception:
+ self._logger.exception("_apply_weights: failed", exc_info=True)
+ # remove idle after first successful sizing; keep size-allocate
+ return False
+
+ try:
+ GLib.idle_add(_apply_weights)
+ except Exception:
+ try:
+ # fallback: call once
+ _apply_weights()
+ except Exception:
+ pass
+ # keep children proportional on subsequent resizes if possible
+ def _on_resize_update(*_args):
+ try:
+ _apply_weights()
+ except Exception:
+ self._logger.exception("_on_resize_update: failed", exc_info=True)
+
+ connected = False
+ for signal_name in ("size-allocate", "notify::width", "notify::width-request"):
+ try:
+ self._backend_widget.connect(signal_name, _on_resize_update)
+ connected = True
+ self._logger.debug("Connected HBox resize hook using signal '%s'", signal_name)
+ break
+ except Exception:
+ continue
+ if not connected:
+ self._logger.debug("No supported resize signal found for HBox; dynamic weight refresh disabled")
+ except Exception:
+ pass
+ try:
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception:
+ pass
+
+ def _set_backend_enabled(self, enabled):
+ """Enable/disable the HBox and propagate to children."""
+ try:
+ if self._backend_widget is not None:
+ try:
+ self._backend_widget.set_sensitive(enabled)
+ except Exception:
+ self._logger.exception("_set_backend_enabled: failed to set_sensitive", exc_info=True)
+ except Exception:
+ self._logger.exception("_set_backend_enabled: failed", exc_info=True)
+ try:
+ for c in list(getattr(self, "_children", []) or []):
+ try:
+ c.setEnabled(enabled)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
diff --git a/manatools/aui/backends/gtk/imagegtk.py b/manatools/aui/backends/gtk/imagegtk.py
new file mode 100644
index 0000000..79fb255
--- /dev/null
+++ b/manatools/aui/backends/gtk/imagegtk.py
@@ -0,0 +1,284 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+"""
+Python manatools.aui.backends.gtk contains GTK backend for YImage
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.gtk
+"""
+import gi
+gi.require_version('Gtk', '4.0')
+gi.require_version('GdkPixbuf', '2.0')
+from gi.repository import Gtk, GdkPixbuf, GObject
+import logging
+import os
+from ...yui_common import *
+from .commongtk import _resolve_icon, _resolve_gicon
+
+
+class _YImageMeasure(Gtk.Image):
+ """Gtk.Image subclass delegating size measurement to YImageGtk."""
+
+ def __init__(self, owner):
+ """Initialize the measuring image widget.
+
+ Args:
+ owner: Owning YImageGtk instance.
+ """
+ super().__init__()
+ self._owner = owner
+
+ def do_measure(self, orientation, for_size):
+ """GTK4 virtual method for size measurement.
+
+ Args:
+ orientation: Gtk.Orientation (HORIZONTAL or VERTICAL)
+ for_size: Size in the opposite orientation (-1 if not constrained)
+
+ Returns:
+ tuple: (minimum_size, natural_size, minimum_baseline, natural_baseline)
+ """
+ try:
+ return self._owner.do_measure(orientation, for_size)
+ except Exception:
+ self._owner._logger.exception("Image backend do_measure delegation failed", exc_info=True)
+ return (0, 0, -1, -1)
+
+
+class YImageGtk(YWidget):
+ """GTK4 image widget with optional autoscale and height-for-width measurement."""
+
+ def __init__(self, parent=None, imageFileName=""):
+ super().__init__(parent)
+ self._imageFileName = imageFileName
+ self._auto_scale = False
+ self._zero_size = {YUIDimension.YD_HORIZ: False, YUIDimension.YD_VERT: False}
+ self._pixbuf = None
+ self._logger = logging.getLogger(f"manatools.aui.gtk.{self.__class__.__name__}")
+ self._logger.debug("%s.__init__ file=%s", self.__class__.__name__, imageFileName)
+
+ def widgetClass(self):
+ return "YImage"
+
+ def imageFileName(self):
+ return self._imageFileName
+
+ def setImage(self, imageFileName):
+ try:
+ self._imageFileName = imageFileName
+ if getattr(self, '_backend_widget', None) is not None:
+ # Prefer direct filesystem loading for absolute/real paths so we keep
+ # an explicit pixbuf and reliable intrinsic geometry.
+ if imageFileName and os.path.exists(imageFileName):
+ try:
+ self._pixbuf = GdkPixbuf.Pixbuf.new_from_file(imageFileName)
+ self._apply_pixbuf()
+ return
+ except Exception:
+ self._logger.exception("failed to load pixbuf from file path")
+
+ # Try resolving via common GTK helper (may be theme icon or file)
+ try:
+ resolved = _resolve_icon(imageFileName, size=48)
+ if resolved is not None:
+ # If helper returned a Gtk.Image, attempt to set it on backend
+ try:
+ paintable = resolved.get_paintable() if hasattr(resolved, 'get_paintable') else None
+ if paintable:
+ self._backend_widget.set_from_paintable(paintable)
+ return
+ except Exception:
+ pass
+ try:
+ pix = resolved.get_pixbuf() if hasattr(resolved, 'get_pixbuf') else None
+ if pix:
+ self._pixbuf = pix
+ self._apply_pixbuf()
+ return
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ # Fallback: try to load from file directly
+ if os.path.exists(imageFileName):
+ try:
+ self._pixbuf = GdkPixbuf.Pixbuf.new_from_file(imageFileName)
+ self._apply_pixbuf()
+ except Exception:
+ self._logger.exception("failed to load pixbuf")
+ else:
+ self._logger.error("setImage: file not found: %s", imageFileName)
+ except Exception:
+ self._logger.exception("setImage failed")
+
+ def autoScale(self):
+ return bool(self._auto_scale)
+
+ def setAutoScale(self, on=True):
+ try:
+ self._auto_scale = bool(on)
+ self._apply_size_policy()
+ self._apply_pixbuf()
+ except Exception:
+ self._logger.exception("setAutoScale failed")
+
+ def hasZeroSize(self, dim):
+ return bool(self._zero_size.get(dim, False))
+
+ def setZeroSize(self, dim, zeroSize=True):
+ self._zero_size[dim] = bool(zeroSize)
+ try:
+ self._apply_size_policy()
+ except Exception:
+ pass
+
+ def _on_size_allocate(self, widget, allocation):
+ try:
+ if self._auto_scale and self._pixbuf is not None:
+ self._apply_pixbuf()
+ except Exception:
+ self._logger.exception("_on_size_allocate failed")
+
+ def do_measure(self, orientation, for_size):
+ """GTK4 virtual method for size measurement.
+
+ Args:
+ orientation: Gtk.Orientation (HORIZONTAL or VERTICAL)
+ for_size: Size in the opposite orientation (-1 if not constrained)
+
+ Returns:
+ tuple: (minimum_size, natural_size, minimum_baseline, natural_baseline)
+ """
+ pixbuf = getattr(self, "_pixbuf", None)
+ if pixbuf is not None:
+ try:
+ pix_w = max(1, int(pixbuf.get_width()))
+ pix_h = max(1, int(pixbuf.get_height()))
+ if orientation == Gtk.Orientation.HORIZONTAL:
+ if for_size is not None and int(for_size) > 0:
+ natural_size = max(1, int((int(for_size) * pix_w) / pix_h))
+ else:
+ natural_size = pix_w
+ minimum_size = 0 if self.hasZeroSize(YUIDimension.YD_HORIZ) else natural_size
+ else:
+ if for_size is not None and int(for_size) > 0:
+ natural_size = max(1, int((int(for_size) * pix_h) / pix_w))
+ else:
+ natural_size = pix_h
+ minimum_size = 0 if self.hasZeroSize(YUIDimension.YD_VERT) else natural_size
+ self._logger.debug(
+ "Image fallback do_measure orientation=%s for_size=%s -> min=%s nat=%s (pix=%sx%s)",
+ orientation,
+ for_size,
+ minimum_size,
+ natural_size,
+ pix_w,
+ pix_h,
+ )
+ return (minimum_size, natural_size, -1, -1)
+ except Exception:
+ self._logger.exception("Image fallback do_measure from pixbuf failed", exc_info=True)
+
+ widget = getattr(self, "_backend_widget", None)
+ if widget is not None:
+ try:
+ minimum_size, natural_size, _minimum_baseline, _natural_baseline = Gtk.Image.do_measure(widget, orientation, for_size)
+ measured = (minimum_size, natural_size, -1, -1)
+ self._logger.debug("Image base do_measure orientation=%s for_size=%s -> %s", orientation, for_size, measured)
+ return measured
+ except Exception:
+ self._logger.exception("Image base do_measure failed", exc_info=True)
+
+ if orientation == Gtk.Orientation.HORIZONTAL:
+ minimum_size = 0 if self.hasZeroSize(YUIDimension.YD_HORIZ) else 16
+ natural_size = max(minimum_size, 32)
+ else:
+ minimum_size = 0 if self.hasZeroSize(YUIDimension.YD_VERT) else 16
+ natural_size = max(minimum_size, 32)
+ self._logger.debug(
+ "Image generic fallback do_measure orientation=%s for_size=%s -> min=%s nat=%s",
+ orientation,
+ for_size,
+ minimum_size,
+ natural_size,
+ )
+ return (minimum_size, natural_size, -1, -1)
+
+ def _create_backend_widget(self):
+ try:
+ self._backend_widget = _YImageMeasure(self)
+ if self._imageFileName:
+ # Use setImage to allow theme icon resolution via commongtk._resolve_icon
+ try:
+ self.setImage(self._imageFileName)
+ except Exception:
+ # Fallback: try direct filesystem load
+ try:
+ if os.path.exists(self._imageFileName):
+ self._pixbuf = GdkPixbuf.Pixbuf.new_from_file(self._imageFileName)
+ except Exception:
+ self._logger.exception("failed to load pixbuf")
+ if getattr(self, '_pixbuf', None) is not None:
+ self._apply_pixbuf()
+ # hook allocation to rescale if autoscale is enabled
+ try:
+ self._backend_widget.connect('size-allocate', self._on_size_allocate)
+ except Exception:
+ pass
+ self._apply_size_policy()
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception:
+ self._logger.exception("_create_backend_widget failed")
+
+ def _apply_size_policy(self):
+ try:
+ if getattr(self, '_backend_widget', None) is None:
+ return
+ try:
+ if hasattr(self._backend_widget, 'set_hexpand'):
+ self._backend_widget.set_hexpand(self._auto_scale or self.stretchable(YUIDimension.YD_HORIZ))
+ except Exception:
+ pass
+ try:
+ if hasattr(self._backend_widget, 'set_vexpand'):
+ self._backend_widget.set_vexpand(self._auto_scale or self.stretchable(YUIDimension.YD_VERT))
+ except Exception:
+ pass
+ except Exception:
+ self._logger.exception("_apply_size_policy failed")
+
+ def _apply_pixbuf(self):
+ try:
+ if getattr(self, '_backend_widget', None) is None:
+ return
+ if not self._pixbuf:
+ self._backend_widget.clear()
+ return
+ if self._auto_scale and hasattr(self._backend_widget, 'get_allocated_width'):
+ try:
+ w = self._backend_widget.get_allocated_width()
+ h = self._backend_widget.get_allocated_height()
+ if w > 1 and h > 1:
+ scaled = self._pixbuf.scale_simple(w, h, GdkPixbuf.InterpType.BILINEAR)
+ self._backend_widget.set_from_pixbuf(scaled)
+ return
+ except Exception:
+ pass
+ # fallback: set original pixbuf
+ self._backend_widget.set_from_pixbuf(self._pixbuf)
+ except Exception:
+ self._logger.exception("_apply_pixbuf failed")
+
+ def setStretchable(self, dim, new_stretch):
+ try:
+ super().setStretchable(dim, new_stretch)
+ except Exception:
+ pass
+ try:
+ self._apply_size_policy()
+ except Exception:
+ pass
diff --git a/manatools/aui/backends/gtk/inputfieldgtk.py b/manatools/aui/backends/gtk/inputfieldgtk.py
new file mode 100644
index 0000000..b3847ea
--- /dev/null
+++ b/manatools/aui/backends/gtk/inputfieldgtk.py
@@ -0,0 +1,244 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.gtk contains all GTK backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.gtk
+'''
+
+import gi
+gi.require_version('Gtk', '4.0')
+gi.require_version('Gdk', '4.0')
+from gi.repository import Gtk, Gdk, GObject, GdkPixbuf, GLib
+import cairo
+import threading
+import os
+import logging
+from ...yui_common import *
+
+
+class YInputFieldGtk(YWidget):
+ def __init__(self, parent=None, label="", password_mode=False):
+ super().__init__(parent)
+ self._label = label
+ self._value = ""
+ self._password_mode = password_mode
+ self._logger = logging.getLogger(f"manatools.aui.gtk.{self.__class__.__name__}")
+
+ def widgetClass(self):
+ return "YInputField"
+
+ def value(self):
+ return self._value
+
+ def setValue(self, text):
+ self._value = text
+ if hasattr(self, '_entry_widget') and self._entry_widget:
+ try:
+ self._entry_widget.set_text(text)
+ except Exception:
+ pass
+
+ def label(self):
+ return self._label
+
+ def _create_backend_widget(self):
+ vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5)
+
+ self._lbl = Gtk.Label(label=self._label)
+ try:
+ if hasattr(self._lbl, "set_xalign"):
+ self._lbl.set_xalign(0.0)
+ except Exception:
+ pass
+ try:
+ vbox.append(self._lbl)
+ except Exception:
+ self._logger.error("Failed to append label to vbox, trying add()", exc_info=True)
+ vbox.add(self._lbl)
+
+ if not self._label:
+ self._lbl.set_visible(False)
+
+ entry = Gtk.Entry()
+ if self._password_mode:
+ try:
+ entry.set_visibility(False)
+ except Exception:
+ pass
+
+ try:
+ entry.set_text(self._value)
+ entry.connect("changed", self._on_changed)
+ except Exception:
+ pass
+
+ try:
+ vbox.append(entry)
+ except Exception:
+ self._logger.error("Failed to append entry to vbox, trying add()", exc_info=True)
+ vbox.add(entry)
+
+ self._backend_widget = vbox
+ self._entry_widget = entry
+ try:
+ if self._help_text:
+ self._backend_widget.set_tooltip_text(self._help_text)
+ except Exception:
+ self._logger.error("Failed to set tooltip text on backend widget", exc_info=True)
+ try:
+ self._backend_widget.set_visible(self.visible())
+ except Exception:
+ self._logger.error("Failed to set backend widget visible", exc_info=True)
+ self._backend_widget.set_sensitive(self._enabled)
+ try:
+ # initial stretch policy
+ self._apply_stretch_policy()
+ except Exception:
+ pass
+ try:
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception:
+ pass
+
+ def _on_changed(self, entry):
+ try:
+ self._value = entry.get_text()
+ except Exception:
+ self._value = ""
+ # Post ValueChanged
+ try:
+ dlg = self.findDialog()
+ if dlg is not None and self.notify():
+ dlg._post_event(YWidgetEvent(self, YEventReason.ValueChanged))
+ except Exception:
+ pass
+
+ def _set_backend_enabled(self, enabled):
+ """Enable/disable the input field (entry and container)."""
+ try:
+ if getattr(self, "_entry_widget", None) is not None:
+ try:
+ self._entry_widget.set_sensitive(enabled)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ try:
+ if self._backend_widget is not None:
+ try:
+ self._backend_widget.set_sensitive(enabled)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def setLabel(self, label):
+ self._label = label
+ if self._backend_widget is None:
+ return
+ try:
+ if hasattr(self, '_lbl') and self._lbl is not None:
+ self._lbl.set_text(str(label))
+ if not label:
+ self._lbl.set_visible(False)
+ else:
+ self._lbl.set_visible(True)
+ except Exception:
+ self._logger.error("setLabel failed", exc_info=True)
+
+ def setStretchable(self, dim, new_stretch):
+ try:
+ super().setStretchable(dim, new_stretch)
+ except Exception:
+ pass
+ try:
+ self._apply_stretch_policy()
+ except Exception:
+ pass
+
+ def _apply_stretch_policy(self):
+ """Apply independent hexpand/vexpand and size requests for single-line input."""
+ try:
+ if self._backend_widget is None:
+ return
+ horiz = bool(self.stretchable(YUIDimension.YD_HORIZ))
+ vert = bool(self.stretchable(YUIDimension.YD_VERT))
+ # Expansion flags
+ try:
+ self._backend_widget.set_hexpand(horiz)
+ self._backend_widget.set_vexpand(vert)
+ self._entry_widget.set_hexpand(horiz)
+ self._entry_widget.set_vexpand(vert)
+ except Exception:
+ pass
+
+ # Compute char/label sizes via Pango
+ try:
+ layout = self._entry_widget.create_pango_layout("M")
+ char_w, line_h = layout.get_pixel_size()
+ if not char_w:
+ char_w = 8
+ if not line_h:
+ line_h = 18
+ except Exception:
+ char_w, line_h = 8, 18
+ self._logger.exception("Failed to get entry char size", exc_info=True)
+ lbl_h = 0
+ try:
+ if self._lbl.get_visible():
+ lbl_layout = self._lbl.create_pango_layout("M")
+ _, lbl_h = lbl_layout.get_pixel_size()
+ if not lbl_h:
+ lbl_h = 20
+ except Exception:
+ self._logger.exception("Failed to get label height", exc_info=True)
+ desired_chars = 20
+ w_px = int(char_w * desired_chars) + 12
+ h_px = int(line_h) + lbl_h + 8
+
+ # Horizontal constraint
+ try:
+ if not horiz:
+ # Prefer width in characters when available
+ if hasattr(self._entry_widget, 'set_width_chars'):
+ self._entry_widget.set_width_chars(desired_chars)
+ else:
+ self._entry_widget.set_size_request(w_px, -1)
+ self._backend_widget.set_size_request(w_px, -1)
+ else:
+ self._backend_widget.set_size_request(-1, -1)
+ self._entry_widget.set_size_request(-1, -1)
+ except Exception:
+ pass
+
+ # Vertical constraint
+ try:
+ if not vert:
+ self._backend_widget.set_size_request(-1, h_px)
+ else:
+ self._backend_widget.set_size_request(-1, -1)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def setVisible(self, visible=True):
+ super().setVisible(visible)
+ try:
+ if getattr(self, "_backend_widget", None) is not None:
+ self._backend_widget.set_visible(visible)
+ except Exception:
+ self._logger.exception("setVisible failed", exc_info=True)
+
+ def setHelpText(self, help_text: str):
+ super().setHelpText(help_text)
+ try:
+ if getattr(self, "_backend_widget", None) is not None:
+ self._backend_widget.set_tooltip_text(help_text)
+ except Exception:
+ self._logger.exception("setHelpText failed", exc_info=True)
\ No newline at end of file
diff --git a/manatools/aui/backends/gtk/intfieldgtk.py b/manatools/aui/backends/gtk/intfieldgtk.py
new file mode 100644
index 0000000..68e6387
--- /dev/null
+++ b/manatools/aui/backends/gtk/intfieldgtk.py
@@ -0,0 +1,227 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.gtk contains all GTK backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.gtk
+'''
+import logging
+import gi
+gi.require_version('Gtk', '4.0')
+from gi.repository import Gtk, GLib
+from ...yui_common import *
+
+
+class YIntFieldGtk(YWidget):
+ def __init__(self, parent=None, label="", minValue=0, maxValue=100, initialValue=0):
+ super().__init__(parent)
+ self._label = label
+ self._min = int(minValue)
+ self._max = int(maxValue)
+ self._value = int(initialValue)
+ self._logger = logging.getLogger(f"manatools.aui.gtk.{self.__class__.__name__}")
+
+ def widgetClass(self):
+ return "YIntField"
+
+ def value(self):
+ return int(self._value)
+
+ def setValue(self, val):
+ try:
+ v = int(val)
+ except Exception:
+ try:
+ self._logger.debug("setValue: invalid int %r", val)
+ except Exception:
+ pass
+ return
+ if v < self._min:
+ v = self._min
+ if v > self._max:
+ v = self._max
+ self._value = v
+ try:
+ if getattr(self, '_spin', None) is not None:
+ try:
+ self._spin.set_value(self._value)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def minValue(self):
+ return int(self._min)
+
+ def maxValue(self):
+ return int(self._max)
+
+ def setMinValue(self, val):
+ try:
+ self._min = int(val)
+ except Exception:
+ return
+ try:
+ if getattr(self, '_adj', None) is not None:
+ try:
+ self._adj.set_lower(self._min)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def setMaxValue(self, val):
+ try:
+ self._max = int(val)
+ except Exception:
+ return
+ try:
+ if getattr(self, '_adj', None) is not None:
+ try:
+ self._adj.set_upper(self._max)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def label(self):
+ return self._label
+
+ def _create_backend_widget(self):
+ try:
+ # vertical layout: label above spin so they're attached
+ box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
+ if self._label:
+ lbl = Gtk.Label(label=self._label)
+ try:
+ lbl.set_halign(Gtk.Align.START)
+ except Exception:
+ try:
+ lbl.set_xalign(0.0)
+ except Exception:
+ pass
+ # keep label from expanding vertically
+ try:
+ lbl.set_hexpand(False)
+ lbl.set_vexpand(False)
+ except Exception:
+ pass
+ box.append(lbl)
+
+ # Create adjustment and spinbutton
+ adj = Gtk.Adjustment(value=self._value, lower=self._min, upper=self._max, step_increment=1, page_increment=10)
+ spin = Gtk.SpinButton(adjustment=adj)
+ try:
+ spin.set_value(self._value)
+ except Exception:
+ pass
+ try:
+ spin.connect('value-changed', self._on_value_changed)
+ except Exception:
+ pass
+ # Attach spin below the label and let _apply_size_policy control expansion
+ box.append(spin)
+
+ self._backend_widget = box
+ self._spin = spin
+ self._adj = adj
+ try:
+ self._label_widget = lbl
+ except Exception:
+ self._label_widget = None
+ # apply size policy according to stretchable hints (do not expand by default)
+ try:
+ self._apply_size_policy()
+ except Exception:
+ pass
+ try:
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception:
+ pass
+ except Exception as e:
+ try:
+ logging.getLogger('manatools.aui.gtk.intfield').exception("Error creating GTK IntField backend: %s", e)
+ except Exception:
+ pass
+
+ def _set_backend_enabled(self, enabled):
+ try:
+ if getattr(self, '_spin', None) is not None:
+ try:
+ self._spin.set_sensitive(bool(enabled))
+ except Exception:
+ pass
+ except Exception:
+ pass
+ try:
+ if getattr(self, '_label_widget', None) is not None:
+ try:
+ self._label_widget.set_sensitive(bool(enabled))
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def _on_value_changed(self, spin):
+ try:
+ v = int(spin.get_value())
+ self._value = v
+ if self.notify():
+ dlg = self.findDialog()
+ if dlg is not None:
+ dlg._post_event(YWidgetEvent(self, YEventReason.ValueChanged))
+ except Exception:
+ pass
+
+ def _apply_size_policy(self):
+ """Apply hexpand/vexpand flags according to `stretchable` hints.
+
+ Default: do not expand (respect user's setStretchable)."""
+ try:
+ horiz = bool(self.stretchable(YUIDimension.YD_HORIZ))
+ except Exception:
+ horiz = False
+ try:
+ vert = bool(self.stretchable(YUIDimension.YD_VERT))
+ except Exception:
+ vert = False
+
+ try:
+ if getattr(self, '_backend_widget', None) is not None:
+ try:
+ self._backend_widget.set_hexpand(horiz)
+ except Exception:
+ pass
+ try:
+ self._backend_widget.set_vexpand(vert)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ try:
+ if getattr(self, '_spin', None) is not None:
+ try:
+ self._spin.set_hexpand(horiz)
+ except Exception:
+ pass
+ try:
+ self._spin.set_vexpand(vert)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def setStretchable(self, dim, new_stretch):
+ try:
+ super().setStretchable(dim, new_stretch)
+ except Exception:
+ pass
+ try:
+ self._apply_size_policy()
+ except Exception:
+ pass
diff --git a/manatools/aui/backends/gtk/labelgtk.py b/manatools/aui/backends/gtk/labelgtk.py
new file mode 100644
index 0000000..92986da
--- /dev/null
+++ b/manatools/aui/backends/gtk/labelgtk.py
@@ -0,0 +1,264 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.gtk contains all GTK backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.gtk
+'''
+
+import gi
+gi.require_version('Gtk', '4.0')
+gi.require_version('Gdk', '4.0')
+from gi.repository import Gtk, Gdk, GObject, GdkPixbuf, GLib
+import cairo
+import threading
+import os
+import logging
+from ...yui_common import *
+
+
+class _YLabelMeasure(Gtk.Label):
+ """Gtk.Label subclass delegating size measurement to YLabelGtk."""
+
+ def __init__(self, owner, label=""):
+ """Initialize the measuring label.
+
+ Args:
+ owner: Owning YLabelGtk instance.
+ label: Initial label text.
+ """
+ super().__init__(label=label)
+ self._owner = owner
+
+ def do_measure(self, orientation, for_size):
+ """GTK4 virtual method for size measurement.
+
+ Args:
+ orientation: Gtk.Orientation (HORIZONTAL or VERTICAL)
+ for_size: Size in the opposite orientation (-1 if not constrained)
+
+ Returns:
+ tuple: (minimum_size, natural_size, minimum_baseline, natural_baseline)
+ """
+ try:
+ return self._owner.do_measure(orientation, for_size)
+ except Exception:
+ self._owner._logger.exception("Label backend do_measure delegation failed", exc_info=True)
+ return (0, 0, -1, -1)
+
+
+class YLabelGtk(YWidget):
+ """GTK4 label widget with heading/output-field options and wrapping."""
+
+ def __init__(self, parent=None, text="", isHeading=False, isOutputField=False):
+ super().__init__(parent)
+ self._text = text
+ self._is_heading = isHeading
+ self._is_output_field = isOutputField
+ self._auto_wrap = False
+ self._logger = logging.getLogger(f"manatools.aui.gtk.{self.__class__.__name__}")
+
+ def widgetClass(self):
+ return "YLabel"
+
+ def text(self):
+ return self._text
+
+ def isHeading(self) -> bool:
+ return bool(self._is_heading)
+
+ def isOutputField(self) -> bool:
+ return bool(self._is_output_field)
+
+ def label(self):
+ return self.text()
+
+ def value(self):
+ return self.text()
+
+ def setText(self, new_text):
+ self._text = new_text
+ if self._backend_widget:
+ try:
+ self._backend_widget.set_text(new_text)
+ except Exception:
+ pass
+
+ def setValue(self, newValue):
+ self.setText(newValue)
+
+ def setLabel(self, newLabel):
+ self.setText(newLabel)
+
+ def autoWrap(self) -> bool:
+ return bool(self._auto_wrap)
+
+ def setAutoWrap(self, on: bool = True):
+ self._auto_wrap = bool(on)
+ try:
+ if getattr(self, "_backend_widget", None) is not None:
+ if hasattr(self._backend_widget, "set_wrap"):
+ self._backend_widget.set_wrap(self._auto_wrap)
+ if hasattr(self._backend_widget, "set_wrap_mode"):
+ try:
+ self._backend_widget.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def do_measure(self, orientation, for_size):
+ """GTK4 virtual method for size measurement.
+
+ Args:
+ orientation: Gtk.Orientation (HORIZONTAL or VERTICAL)
+ for_size: Size in the opposite orientation (-1 if not constrained)
+
+ Returns:
+ tuple: (minimum_size, natural_size, minimum_baseline, natural_baseline)
+ """
+ widget = getattr(self, "_backend_widget", None)
+ if widget is not None:
+ try:
+ minimum_size, natural_size, minimum_baseline, natural_baseline = Gtk.Label.do_measure(widget, orientation, for_size)
+ if orientation == Gtk.Orientation.HORIZONTAL:
+ minimum_baseline = -1
+ natural_baseline = -1
+ measured = (minimum_size, natural_size, minimum_baseline, natural_baseline)
+ self._logger.debug("Label do_measure orientation=%s for_size=%s -> %s", orientation, for_size, measured)
+ return measured
+ except Exception:
+ self._logger.exception("Label base do_measure failed", exc_info=True)
+
+ text = str(getattr(self, "_text", "") or "")
+ line_count = max(1, text.count("\n") + 1)
+ longest_line = max((len(line) for line in text.splitlines()), default=len(text))
+ if orientation == Gtk.Orientation.HORIZONTAL:
+ minimum_size = min(80, max(8, longest_line * 5))
+ natural_size = max(minimum_size, longest_line * 8)
+ else:
+ minimum_size = max(18, line_count * 18)
+ natural_size = minimum_size
+ self._logger.debug(
+ "Label fallback do_measure orientation=%s for_size=%s -> min=%s nat=%s",
+ orientation,
+ for_size,
+ minimum_size,
+ natural_size,
+ )
+ return (minimum_size, natural_size, -1, -1)
+
+ def _create_backend_widget(self):
+ """Create backend label and apply visual and sizing policy."""
+ self._backend_widget = _YLabelMeasure(self, label=self._text)
+ try:
+ # alignment API in Gtk4 differs; fall back to setting xalign if available
+ if hasattr(self._backend_widget, "set_xalign"):
+ self._backend_widget.set_xalign(0.0)
+ except Exception:
+ pass
+ try:
+ if hasattr(self._backend_widget, "set_wrap"):
+ self._backend_widget.set_wrap(bool(self._auto_wrap))
+ if hasattr(self._backend_widget, "set_wrap_mode"):
+ self._backend_widget.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
+ except Exception:
+ pass
+ # Output field: allow text selection like a read-only input
+ try:
+ if hasattr(self._backend_widget, "set_selectable"):
+ self._backend_widget.set_selectable(bool(self._is_output_field))
+ except Exception:
+ pass
+
+ if self._is_heading:
+ try:
+ markup = f"{self._text}"
+ self._backend_widget.set_markup(markup)
+ except Exception:
+ pass
+ if self._help_text:
+ try:
+ self._backend_widget.set_tooltip_text(self._help_text)
+ except Exception:
+ self._logger.error("Failed to set tooltip text on backend widget", exc_info=True)
+ try:
+ self._backend_widget.set_visible(self.visible())
+ except Exception:
+ self._logger.error("Failed to set backend widget visible", exc_info=True)
+ try:
+ self._backend_widget.set_sensitive(self._enabled)
+ except Exception:
+ self._logger.exception("Failed to set sensitivity on backend widget")
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ try:
+ # apply initial size policy according to any stretch hints
+ self._apply_size_policy()
+ except Exception:
+ pass
+
+ def _set_backend_enabled(self, enabled):
+ """Enable/disable the label widget backend."""
+ try:
+ if self._backend_widget is not None:
+ try:
+ self._backend_widget.set_sensitive(enabled)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def _apply_size_policy(self):
+ """Apply `hexpand`/`vexpand` on the Gtk.Label according to model stretchable hints."""
+ try:
+ horiz = bool(self.stretchable(YUIDimension.YD_HORIZ))
+ except Exception:
+ horiz = False
+ try:
+ vert = bool(self.stretchable(YUIDimension.YD_VERT))
+ except Exception:
+ vert = False
+ try:
+ if getattr(self, '_backend_widget', None) is not None:
+ try:
+ if hasattr(self._backend_widget, 'set_hexpand'):
+ self._backend_widget.set_hexpand(horiz)
+ except Exception:
+ pass
+ try:
+ if hasattr(self._backend_widget, 'set_vexpand'):
+ self._backend_widget.set_vexpand(vert)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def setStretchable(self, dim, new_stretch):
+ try:
+ super().setStretchable(dim, new_stretch)
+ except Exception:
+ pass
+ try:
+ self._apply_size_policy()
+ except Exception:
+ pass
+
+ def setVisible(self, visible=True):
+ super().setVisible(visible)
+ try:
+ if getattr(self, "_backend_widget", None) is not None:
+ self._backend_widget.set_visible(visible)
+ except Exception:
+ self._logger.exception("setVisible failed", exc_info=True)
+
+ def setHelpText(self, help_text: str):
+ super().setHelpText(help_text)
+ try:
+ if getattr(self, "_backend_widget", None) is not None:
+ self._backend_widget.set_tooltip_text(help_text)
+ except Exception:
+ self._logger.exception("setHelpText failed", exc_info=True)
diff --git a/manatools/aui/backends/gtk/logviewgtk.py b/manatools/aui/backends/gtk/logviewgtk.py
new file mode 100644
index 0000000..4d34175
--- /dev/null
+++ b/manatools/aui/backends/gtk/logviewgtk.py
@@ -0,0 +1,171 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.gtk contains all gtk backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.gtk
+'''
+import logging
+from gi.repository import Gtk
+from ...yui_common import *
+
+
+class YLogViewGtk(YWidget):
+ """GTK backend for YLogView using Gtk.TextView inside Gtk.ScrolledWindow with optional caption label."""
+ def __init__(self, parent=None, label: str = "", visibleLines: int = 10, storedLines: int = 0):
+ super().__init__(parent)
+ self._logger = logging.getLogger(f"manatools.aui.gtk.{self.__class__.__name__}")
+ self._label = label or ""
+ self._visible = max(1, int(visibleLines or 10))
+ self._max_lines = max(0, int(storedLines or 0))
+ self._lines = []
+ try:
+ self.setStretchable(YUIDimension.YD_HORIZ, True)
+ self.setStretchable(YUIDimension.YD_VERT, True)
+ except Exception:
+ pass
+
+ def widgetClass(self):
+ return "YLogView"
+
+ def label(self):
+ return self._label
+
+ def setLabel(self, label: str):
+ self._label = label or ""
+ try:
+ if getattr(self, "_label_widget", None) is not None:
+ self._label_widget.set_text(self._label)
+ except Exception:
+ self._logger.exception("setLabel failed")
+
+ def visibleLines(self) -> int:
+ return int(self._visible)
+
+ def setVisibleLines(self, v: int):
+ self._visible = max(1, int(v or 1))
+ # Height tuning could be done via CSS or size requests; skip for now.
+
+ def maxLines(self) -> int:
+ return int(self._max_lines)
+
+ def setMaxLines(self, m: int):
+ self._max_lines = max(0, int(m or 0))
+ self._trim_if_needed()
+ self._update_display()
+
+ def logText(self) -> str:
+ return "\n".join(self._lines)
+
+ def setLogText(self, text: str):
+ try:
+ raw = [] if text is None else str(text).splitlines()
+ self._lines = raw
+ self._trim_if_needed()
+ self._update_display()
+ except Exception:
+ self._logger.exception("setLogText failed")
+
+ def lastLine(self) -> str:
+ return self._lines[-1] if self._lines else ""
+
+ def appendLines(self, text: str):
+ try:
+ if text is None:
+ return
+ for ln in str(text).splitlines():
+ self._lines.append(ln)
+ self._trim_if_needed()
+ self._update_display(scroll_end=True)
+ except Exception:
+ self._logger.exception("appendLines failed")
+
+ def clearText(self):
+ self._lines = []
+ self._update_display()
+
+ def lines(self) -> int:
+ return len(self._lines)
+
+ # internals
+ def _trim_if_needed(self):
+ try:
+ if self._max_lines > 0 and len(self._lines) > self._max_lines:
+ self._lines = self._lines[-self._max_lines:]
+ except Exception:
+ self._logger.exception("trim failed")
+
+ def _update_display(self, scroll_end: bool = False):
+ try:
+ if getattr(self, "_buffer", None) is not None:
+ self._buffer.set_text("\n".join(self._lines))
+ if scroll_end and getattr(self, "_view", None) is not None:
+ try:
+ iter_ = self._buffer.get_end_iter()
+ self._view.scroll_to_iter(iter_, 0.0, False, 0.0, 1.0)
+ except Exception:
+ pass
+ except Exception:
+ self._logger.exception("update_display failed")
+
+ def _create_backend_widget(self):
+ box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
+ if self._label:
+ lbl = Gtk.Label(label=self._label)
+ lbl.set_xalign(0.0)
+ self._label_widget = lbl
+ box.append(lbl)
+ sw = Gtk.ScrolledWindow()
+ # Respect base stretchable properties
+ try:
+ horiz = bool(self.stretchable(YUIDimension.YD_HORIZ))
+ vert = bool(self.stretchable(YUIDimension.YD_VERT))
+ sw.set_hexpand(horiz)
+ sw.set_vexpand(vert)
+ except Exception:
+ pass
+ tv = Gtk.TextView()
+ try:
+ tv.set_editable(False)
+ tv.set_wrap_mode(Gtk.WrapMode.NONE)
+ tv.set_monospace(True)
+ # Respect base stretchable properties
+ try:
+ horiz = bool(self.stretchable(YUIDimension.YD_HORIZ))
+ vert = bool(self.stretchable(YUIDimension.YD_VERT))
+ tv.set_hexpand(horiz)
+ tv.set_vexpand(vert)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ # approximate min height from visible lines so it appears with some space
+ try:
+ line_px = 18
+ sw.set_min_content_height(line_px * max(1, int(self._visible)))
+ except Exception:
+ pass
+ buf = tv.get_buffer()
+ self._buffer = buf
+ self._view = tv
+ sw.set_child(tv)
+ box.append(sw)
+ self._backend_widget = box
+ self._update_display()
+ try:
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception:
+ pass
+
+ def _set_backend_enabled(self, enabled):
+ try:
+ if getattr(self, "_view", None) is not None:
+ self._view.set_sensitive(bool(enabled))
+ if getattr(self, "_label_widget", None) is not None:
+ self._label_widget.set_sensitive(bool(enabled))
+ except Exception:
+ pass
diff --git a/manatools/aui/backends/gtk/menubargtk.py b/manatools/aui/backends/gtk/menubargtk.py
new file mode 100644
index 0000000..999d5b8
--- /dev/null
+++ b/manatools/aui/backends/gtk/menubargtk.py
@@ -0,0 +1,706 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+GTK backend: YMenuBar implementation using a horizontal box of MenuButtons with Popovers.
+GTK4 lacks traditional Gtk.MenuBar; this simulates a menubar.
+'''
+import gi
+gi.require_version('Gtk', '4.0')
+gi.require_version('Gio', '2.0')
+from gi.repository import Gtk, Gio, GLib
+import logging
+import os
+from ...yui_common import YWidget, YMenuEvent, YMenuItem, YUIDimension
+from .commongtk import _resolve_icon, _convert_mnemonic_to_gtk
+
+
+class YMenuBarGtk(YWidget):
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self._logger = logging.getLogger(f"manatools.aui.gtk.{self.__class__.__name__}")
+ self._menus = [] # top-level YMenuItem menus
+ self._menu_to_model = {}
+ # For MenuButton+Popover approach
+ self._menu_to_button = {}
+ self._item_to_row = {}
+ self._row_to_item = {}
+ self._row_to_popover = {}
+ self._item_to_button = {}
+ self._pending_rebuild = False
+ # Default stretch: horizontal True, vertical False
+ try:
+ self.setStretchable(YUIDimension.YD_HORIZ, True)
+ self.setStretchable(YUIDimension.YD_VERT, False)
+ except Exception:
+ pass
+
+ def widgetClass(self):
+ return "YMenuBar"
+
+ def addMenu(self, label: str="", icon_name: str = "", menu: YMenuItem = None) -> YMenuItem:
+ """Add a menu by label or by an existing YMenuItem (is_menu=True) to the menubar."""
+ m = None
+ if menu is not None:
+ if not menu.isMenu():
+ raise ValueError("Provided YMenuItem is not a menu (is_menu=True)")
+ m = menu
+ else:
+ if not label:
+ raise ValueError("Menu label must be provided when no YMenuItem is given")
+ m = YMenuItem(_convert_mnemonic_to_gtk(label), icon_name, enabled=True, is_menu=True)
+ self._menus.append(m)
+ if self._backend_widget:
+ self._ensure_menu_rendered(m)
+ self._rebuild_root_model()
+ return m
+
+ def addItem(self, menu: YMenuItem, label: str, icon_name: str = "", enabled: bool = True) -> YMenuItem:
+ item = menu.addItem(_convert_mnemonic_to_gtk(label), icon_name)
+ item.setEnabled(enabled)
+ if self._backend_widget:
+ # update model for this menu and rebuild root
+ self._ensure_menu_rendered(menu)
+ self._rebuild_root_model()
+ return item
+
+ def setItemEnabled(self, item: YMenuItem, on: bool = True):
+ item.setEnabled(on)
+ # Update rendered row or button sensitivity if present
+ try:
+ row = self._item_to_row.get(item)
+ if row is not None:
+ try:
+ row.set_sensitive(bool(on))
+ except Exception:
+ pass
+ try:
+ btnw = self._item_to_button.get(item)
+ if btnw is not None:
+ btnw.set_sensitive(bool(on))
+ except Exception:
+ pass
+ return
+ pair = self._menu_to_button.get(item)
+ if pair is not None:
+ try:
+ btn, _ = pair
+ btn.set_sensitive(bool(on))
+ except Exception:
+ pass
+ except Exception:
+ self._logger.exception("Error updating enabled state for menu item '%s'", getattr(item, 'label', lambda: 'unknown')())
+
+ def setItemVisible(self, item: YMenuItem, visible: bool = True):
+ item.setVisible(visible)
+ # Defer rebuild to idle to avoid clearing widgets during signal emission
+ self._queue_rebuild()
+
+ def _queue_rebuild(self):
+ try:
+ if self._pending_rebuild:
+ return
+ self._pending_rebuild = True
+ def do_rebuild():
+ try:
+ self.rebuildMenus()
+ except Exception:
+ self._logger.exception("Deferred rebuildMenus failed")
+ finally:
+ self._pending_rebuild = False
+ return False # remove idle source
+ try:
+ GLib.idle_add(do_rebuild, priority=GLib.PRIORITY_DEFAULT_IDLE)
+ except Exception:
+ # Fallback: rebuild synchronously if idle_add not available
+ try:
+ self.rebuildMenus()
+ finally:
+ self._pending_rebuild = False
+ except Exception:
+ self._logger.exception("_queue_rebuild failed")
+
+ def _path_for_item(self, item: YMenuItem) -> str:
+ labels = []
+ cur = item
+ while cur is not None:
+ labels.append(cur.label())
+ cur = getattr(cur, "_parent", None)
+ return "/".join(reversed(labels))
+
+ def _emit_activation(self, item: YMenuItem):
+ try:
+ dlg = self.findDialog()
+ if dlg and self.notify():
+ dlg._post_event(YMenuEvent(item=item, id=self._path_for_item(item)))
+ except Exception:
+ pass
+
+ def _ensure_menu_rendered(self, menu: YMenuItem):
+ # For backwards compatibility call-through to button renderer
+ try:
+ self._ensure_menu_rendered_button(menu)
+ except Exception:
+ self._logger.exception("Failed to ensure menu rendered for '%s'", menu.label())
+
+ def _action_name_for_item(self, item: YMenuItem) -> str:
+ # derive action id from path, sanitized
+ path = self._path_for_item(item)
+ return path.replace('/', '_').replace(' ', '_').lower()
+
+ def _populate_menu_model(self, model: Gio.Menu, menu: YMenuItem):
+ # This implementation builds per-menu Gtk.Popover/ListBox widgets instead of a Gio.Menu model.
+ # Population is handled by `_render_menu_children` when menus are rendered.
+ return
+
+ def _ensure_menu_rendered_button(self, menu: YMenuItem):
+ # Create a MenuButton with a Popover containing a ListBox for `menu` children.
+ if menu in self._menu_to_button:
+ return
+ hb = self._backend_widget
+ if hb is None:
+ return
+ btn = Gtk.MenuButton()
+ btn.set_use_underline(True)
+ try:
+ btn.set_label(_convert_mnemonic_to_gtk(menu.label()))
+ btn.set_has_frame(False)
+ except Exception:
+ pass
+ # Ensure mnemonic-activate opens the popover (Alt+Key)
+ try:
+ def _on_mnemonic(btn_widget, _cycle):
+ try:
+ pop = btn_widget.get_popover()
+ if pop is not None:
+ try:
+ pop.popup()
+ except Exception:
+ try:
+ pop.set_visible(True)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ return True
+ btn.connect('mnemonic-activate', _on_mnemonic)
+ except Exception:
+ pass
+
+ # optional icon on the button
+ if menu.iconName():
+ try:
+ img = _resolve_icon(menu.iconName())
+ if img is not None:
+ try:
+ btn.set_icon(img.get_paintable())
+ except Exception:
+ # Some Gtk versions may not support set_icon; try set_child with a box
+ try:
+ hb_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
+ hb_box.append(img)
+ # Add a mnemonic label so Alt+Key continues to work
+ try:
+ conv = menu.label()
+ lbl = Gtk.Label(label=conv)
+ try:
+ lbl.set_use_underline(True)
+ except Exception:
+ pass
+ try:
+ lbl.set_xalign(0.0)
+ except Exception:
+ pass
+ hb_box.append(lbl)
+ # route label mnemonic to the button
+ try:
+ lbl.set_mnemonic_widget(btn)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ btn.set_child(hb_box)
+ except Exception:
+ self._logger.exception("Failed to set icon on MenuButton for '%s'", menu.label())
+ except Exception:
+ self._logger.exception("Error resolving icon for MenuButton '%s'", menu.label())
+
+ pop = Gtk.Popover()
+ listbox = Gtk.ListBox()
+ listbox.set_selection_mode(Gtk.SelectionMode.NONE)
+ listbox.connect("row-activated", self._on_row_activated)
+ pop.set_child(listbox)
+ btn.set_popover(pop)
+ # ensure button doesn't expand vertically
+ try:
+ btn.set_vexpand(False)
+ btn.set_hexpand(False)
+ except Exception:
+ pass
+ try:
+ hb.append(btn)
+ except Exception:
+ try:
+ hb.add(btn)
+ except Exception:
+ pass
+
+ self._menu_to_button[menu] = (btn, listbox)
+ # populate listbox rows
+ self._render_menu_children(menu)
+ # reflect initial visibility
+ try:
+ btn.set_visible(bool(menu.visible()))
+ except Exception:
+ pass
+
+ def _render_menu_children(self, menu: YMenuItem):
+ pair = self._menu_to_button.get(menu)
+ if not pair:
+ return
+ btn, listbox = pair
+ # clear existing rows reliably on GTK4
+ try:
+ self.__clear_widget(listbox)
+ except Exception:
+ pass
+
+ for child in list(menu._children):
+ # skip invisible children
+ try:
+ if not child.visible():
+ continue
+ except Exception:
+ pass
+ if child.isMenu():
+ # submenu: create a nested MenuButton inside the row
+ sub_btn = Gtk.MenuButton()
+ sub_btn.set_use_underline(True)
+ try:
+ sub_btn.set_label(_convert_mnemonic_to_gtk(child.label()))
+ sub_btn.set_has_frame(False)
+ except Exception:
+ pass
+ try:
+ def _on_mnemonic_sub(bw, _cycle):
+ try:
+ sp = bw.get_popover()
+ if sp is not None:
+ try:
+ sp.popup()
+ except Exception:
+ try:
+ sp.set_visible(True)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ return True
+ sub_btn.connect('mnemonic-activate', _on_mnemonic_sub)
+ except Exception:
+ pass
+ sub_pop = Gtk.Popover()
+ sub_lb = Gtk.ListBox()
+ sub_lb.set_selection_mode(Gtk.SelectionMode.NONE)
+ sub_lb.connect("row-activated", self._on_row_activated)
+ sub_pop.set_child(sub_lb)
+ sub_btn.set_popover(sub_pop)
+ # optional icon
+ if child.iconName():
+ try:
+ img = _resolve_icon(child.iconName())
+ if img is not None:
+ try:
+ sub_btn.set_icon(img.get_paintable())
+ except Exception:
+ try:
+ box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
+ box.append(img)
+ # Add a mnemonic label for submenu button too
+ try:
+ conv = child.label()
+ slbl = Gtk.Label(label=conv)
+ try:
+ slbl.set_use_underline(True)
+ except Exception:
+ pass
+ try:
+ slbl.set_xalign(0.0)
+ except Exception:
+ pass
+ box.append(slbl)
+ try:
+ slbl.set_mnemonic_widget(sub_btn)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ sub_btn.set_child(box)
+ except Exception:
+ self._logger.exception("Failed to set icon on submenu button '%s'", child.label())
+ except Exception:
+ self._logger.exception("Error resolving icon for submenu '%s'", child.label())
+
+ row = Gtk.ListBoxRow()
+ row_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
+ row_box.append(sub_btn)
+ row.set_child(row_box)
+ row.set_sensitive(child.enabled())
+ listbox.append(row)
+ # store mapping and recurse
+ self._menu_to_button[child] = (sub_btn, sub_lb)
+ # map rows in submenu to its popover so we can close it on activation
+ try:
+ self._row_to_popover[row] = sub_btn.get_popover()
+ except Exception:
+ pass
+ self._render_menu_children(child)
+ else:
+ # skip invisible children
+ try:
+ if not child.visible():
+ continue
+ except Exception:
+ pass
+ if child.isSeparator():
+ sep = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
+ listbox.append(sep)
+ continue
+ row = Gtk.ListBoxRow()
+ row_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
+ # optional icon
+ if child.iconName():
+ try:
+ img = _resolve_icon(child.iconName())
+ if img is not None:
+ row_box.append(img)
+ except Exception:
+ self._logger.exception("Failed to resolve icon for menu item '%s'", child.label())
+ # Display item label (no button) for reliable visibility across GTK versions
+ lbl = Gtk.Label(label=child.label())
+ try:
+ # interpret '_' as mnemonic underline visually (no activation here)
+ lbl.set_use_underline(True)
+ except Exception:
+ pass
+ try:
+ lbl.set_xalign(0.0)
+ except Exception:
+ pass
+ row_box.append(lbl)
+ row.set_child(row_box)
+ row.set_sensitive(child.enabled())
+ listbox.append(row)
+ self._item_to_row[child] = row
+ self._row_to_item[row] = child
+ # remember the popover that contains this row (top-level menu)
+ try:
+ self._row_to_popover[row] = btn.get_popover()
+ except Exception:
+ pass
+
+ def _create_backend_widget(self):
+ hb = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
+ # Expansion based on current stretchable flags: default hexpand True, vexpand False
+ try:
+ try:
+ v_stretch = bool(self.stretchable(YUIDimension.YD_VERT))
+ except Exception:
+ v_stretch = False
+ try:
+ h_stretch = bool(self.stretchable(YUIDimension.YD_HORIZ))
+ except Exception:
+ h_stretch = True
+ hb.set_vexpand(v_stretch)
+ hb.set_hexpand(h_stretch)
+ except Exception:
+ pass
+ self._backend_widget = hb
+ # render any visible menus added before creation
+ for m in self._menus:
+ try:
+ if not m.visible():
+ continue
+ self._ensure_menu_rendered_button(m)
+ except Exception:
+ self._logger.exception("Failed to render menu '%s'", m.label())
+
+ def _rebuild_root_model(self):
+ # Re-render all menus and their popovers to reflect model changes
+ try:
+ for m in list(self._menus):
+ try:
+ try:
+ if not m.visible():
+ continue
+ except Exception:
+ pass
+ self._ensure_menu_rendered_button(m)
+ self._render_menu_children(m)
+ except Exception:
+ self._logger.exception("Failed rebuilding menu '%s'", m.label())
+ except Exception:
+ self._logger.exception("Unexpected error in _rebuild_root_model")
+
+ def __clear_widget(self, widget: Gtk.Widget):
+ """Remove all children from a GTK4 widget.
+
+ Recursively unparents/removes children to help rebuild container
+ widgets safely.
+ """
+ if not widget:
+ return
+
+ child = widget.get_first_child()
+ while child is not None:
+ next_child = child.get_next_sibling()
+ self.__clear_widget(child)
+
+ if hasattr(widget, 'remove'):
+ widget.remove(child)
+ #elif hasattr(child, 'unparent'):
+ # child.unparent()
+
+ child = next_child
+
+ def rebuildMenus(self):
+ """Rebuild all the menus.
+
+ Useful when the menu model changes at runtime.
+
+ This action must be performed to reflect any direct changes to
+ `YMenuItem` data (e.g., label, enabled, visible) without going
+ through higher-level menubar helpers.
+ """
+ try:
+ hb = self._backend_widget
+ # proactively disconnect signals and close popovers before clearing children
+ try:
+ for _m, pair in list(self._menu_to_button.items()):
+ try:
+ btn, listbox = pair
+ # disconnect row-activated if available
+ try:
+ if hasattr(listbox, 'disconnect_by_func'):
+ listbox.disconnect_by_func(self._on_row_activated)
+ except Exception:
+ pass
+ # close/detach popover
+ try:
+ pop = btn.get_popover()
+ if pop is not None:
+ try:
+ pop.popdown()
+ except Exception:
+ try:
+ pop.set_visible(False)
+ except Exception:
+ try:
+ pop.hide()
+ except Exception:
+ pass
+ try:
+ pop.set_child(None)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ except Exception:
+ pass
+ except Exception:
+ pass
+ self.__clear_widget(hb)
+
+ # clear mappings
+ try:
+ self._menu_to_button.clear()
+ except Exception:
+ self._menu_to_button = {}
+ try:
+ self._item_to_row.clear()
+ except Exception:
+ self._item_to_row = {}
+ try:
+ self._item_to_button.clear()
+ except Exception:
+ self._item_to_button = {}
+ try:
+ self._row_to_item.clear()
+ except Exception:
+ self._row_to_item = {}
+ try:
+ self._row_to_popover.clear()
+ except Exception:
+ self._row_to_popover = {}
+
+ # recreate top-level buttons like in _create_backend_widget
+ for m in self._menus:
+ try:
+ self._ensure_menu_rendered_button(m)
+ # set button visibility according to model
+ try:
+ pair = self._menu_to_button.get(m)
+ if pair is not None:
+ try:
+ btn, _ = pair
+ btn.set_visible(bool(m.visible()))
+ except Exception:
+ pass
+ except Exception:
+ pass
+ except Exception:
+ self._logger.exception("Failed to ensure menu rendered for '%s'", m.label())
+
+ except Exception:
+ self._logger.exception("rebuildMenus failed")
+
+ def deleteMenus(self):
+ """Remove all top-level menus and their backend widgets/mappings.
+
+ Clears the `self._menus` model list and removes any created
+ MenuButton widgets from the backend container, then invokes
+ `rebuildMenus()` so the UI is updated.
+ """
+ try:
+ # clear model
+ try:
+ self._menus.clear()
+ except Exception:
+ self._menus = []
+
+ # remove buttons from container, disconnect signals and clear listboxes
+ try:
+ hb = self._backend_widget
+ for m, pair in list(self._menu_to_button.items()):
+ btn, listbox = pair
+ # disconnect row-activated if available
+ try:
+ if hasattr(listbox, 'disconnect_by_func'):
+ listbox.disconnect_by_func(self._on_row_activated)
+ except Exception:
+ pass
+ # close/detach popover
+ try:
+ pop = btn.get_popover()
+ if pop is not None:
+ try:
+ pop.popdown()
+ except Exception:
+ try:
+ pop.set_visible(False)
+ except Exception:
+ try:
+ pop.hide()
+ except Exception:
+ pass
+ try:
+ pop.set_child(None)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ self.__clear_widget(hb)
+ except Exception:
+ pass
+
+ # clear mappings
+ try:
+ self._menu_to_button.clear()
+ except Exception:
+ self._menu_to_button = {}
+ try:
+ self._item_to_row.clear()
+ except Exception:
+ self._item_to_row = {}
+ try:
+ self._item_to_button.clear()
+ except Exception:
+ self._item_to_button = {}
+ try:
+ self._row_to_item.clear()
+ except Exception:
+ self._row_to_item = {}
+ try:
+ self._row_to_popover.clear()
+ except Exception:
+ self._row_to_popover = {}
+
+ # reflect empty state
+ try:
+ self.rebuildMenus()
+ except Exception:
+ pass
+ except Exception:
+ self._logger.exception("deleteMenus failed")
+
+
+
+ def _set_backend_enabled(self, enabled):
+ try:
+ if getattr(self, "_backend_widget", None) is not None:
+ self._backend_widget.set_sensitive(bool(enabled))
+ except Exception:
+ pass
+
+ # ListBox activation handled by `_on_row_activated`
+
+ def _on_row_activated(self, listbox: Gtk.ListBox, row: Gtk.ListBoxRow):
+ try:
+ item = self._row_to_item.get(row)
+ if item and item.enabled():
+ self._emit_activation(item)
+ # Close any popovers related to the menubar to mimic standard menu behavior
+ try:
+ for btn, _lb in list(self._menu_to_button.values()):
+ try:
+ pop = None
+ try:
+ pop = btn.get_popover()
+ except Exception:
+ pop = None
+ if pop is None:
+ continue
+ # Try popdown(), else hide/set_visible(False)
+ try:
+ pop.popdown()
+ except Exception:
+ try:
+ pop.set_visible(False)
+ except Exception:
+ try:
+ pop.hide()
+ except Exception:
+ pass
+ except Exception:
+ pass
+ except Exception:
+ self._logger.exception("Error closing popovers after activation")
+ except Exception:
+ self._logger.exception("Error handling row activation")
+
+ def _on_menu_button_clicked(self, item: YMenuItem):
+ try:
+ if item and item.enabled():
+ self._emit_activation(item)
+ # close all popovers
+ try:
+ for btn, _lb in list(self._menu_to_button.values()):
+ try:
+ pop = btn.get_popover()
+ if pop is None:
+ continue
+ try:
+ pop.popdown()
+ except Exception:
+ try:
+ pop.set_visible(False)
+ except Exception:
+ try:
+ pop.hide()
+ except Exception:
+ pass
+ except Exception:
+ pass
+ except Exception:
+ pass
+ except Exception:
+ pass
diff --git a/manatools/aui/backends/gtk/multilineeditgtk.py b/manatools/aui/backends/gtk/multilineeditgtk.py
new file mode 100644
index 0000000..4e28e66
--- /dev/null
+++ b/manatools/aui/backends/gtk/multilineeditgtk.py
@@ -0,0 +1,331 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.gtk contains GTK backend for YMultiLineEdit
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.gtk
+'''
+import logging
+import gi
+try:
+ gi.require_version('Gtk', '4.0')
+ from gi.repository import Gtk, GLib
+except Exception:
+ Gtk = None
+ GLib = None
+
+from ...yui_common import *
+
+_mod_logger = logging.getLogger("manatools.aui.gtk.multiline.module")
+if not logging.getLogger().handlers:
+ _h = logging.StreamHandler()
+ _h.setFormatter(logging.Formatter("%(asctime)s %(name)s %(levelname)s: %(message)s"))
+ _mod_logger.addHandler(_h)
+ _mod_logger.setLevel(logging.INFO)
+
+
+class YMultiLineEditGtk(YWidget):
+ def __init__(self, parent=None, label=""):
+ super().__init__(parent)
+ self._label = label
+ self._value = ""
+ # default visible content lines (consistent across backends)
+ self._default_visible_lines = 3
+ # -1 means no input length limit
+ self._input_max_length = -1
+ # reported minimal height: content lines + label row (if present)
+ self._height = self._default_visible_lines + (1 if bool(self._label) else 0)
+ self._logger = logging.getLogger(f"manatools.aui.gtk.{self.__class__.__name__}")
+ if not self._logger.handlers and _mod_logger.handlers:
+ for h in _mod_logger.handlers:
+ self._logger.addHandler(h)
+
+ self._widget = None
+
+ def widgetClass(self):
+ return "YMultiLineEdit"
+
+ def value(self):
+ return str(self._value)
+
+ def inputMaxLength(self):
+ return int(getattr(self, '_input_max_length', -1))
+
+ def setInputMaxLength(self, numberOfChars):
+ try:
+ self._input_max_length = int(numberOfChars)
+ except Exception:
+ self._input_max_length = -1
+ try:
+ if self._widget is not None and self._input_max_length >= 0:
+ buf = self._textview.get_buffer()
+ start = buf.get_start_iter()
+ end = buf.get_end_iter()
+ try:
+ text = buf.get_text(start, end, True)
+ except Exception:
+ text = ""
+ if len(text) > self._input_max_length:
+ try:
+ buf.set_text(text[:self._input_max_length])
+ self._value = text[:self._input_max_length]
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def setValue(self, text):
+ try:
+ s = str(text) if text is not None else ""
+ except Exception:
+ s = ""
+ self._value = s
+ try:
+ if self._widget is not None:
+ buf = self._textview.get_buffer()
+ try:
+ buf.set_text(self._value)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def label(self):
+ return self._label
+
+ def setLabel(self, label):
+ try:
+ self._label = label
+ if self._widget is not None:
+ try:
+ self._lbl.set_text(str(label))
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def defaultVisibleLines(self):
+ return int(getattr(self, '_default_visible_lines', 3))
+
+ def setDefaultVisibleLines(self, newVisibleLines):
+ try:
+ self._default_visible_lines = int(newVisibleLines)
+ self._height = self._default_visible_lines + (1 if bool(self._label) else 0)
+ try:
+ # Re-apply sizing based on new visible lines
+ self._apply_stretch_policy()
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def setStretchable(self, dim, new_stretch):
+ try:
+ super().setStretchable(dim, new_stretch)
+ except Exception:
+ pass
+ try:
+ # Re-apply full policy so axes are handled independently
+ self._apply_stretch_policy()
+ except Exception:
+ pass
+
+ def _create_backend_widget(self):
+ try:
+ if Gtk is None:
+ raise ImportError("GTK not available")
+ box = Gtk.Box.new(Gtk.Orientation.VERTICAL, 0)
+ self._lbl = Gtk.Label.new(str(self._label))
+ # Use scrolled window so when expanded beyond screen scrollbars appear
+ try:
+ self._scrolled = Gtk.ScrolledWindow.new()
+ self._textview = Gtk.TextView.new()
+ self._scrolled.set_child(self._textview)
+ except Exception:
+ self._scrolled = None
+ self._textview = Gtk.TextView.new()
+ try:
+ buf = self._textview.get_buffer()
+ buf.set_text(self._value)
+ try:
+ self._buf_handler_id = buf.connect('changed', self._on_buffer_changed)
+ except Exception:
+ self._buf_handler_id = None
+ except Exception:
+ pass
+ box.append(self._lbl)
+ if getattr(self, '_scrolled', None) is not None:
+ box.append(self._scrolled)
+ else:
+ box.append(self._textview)
+ self._widget = box
+ self._backend_widget = self._widget
+ try:
+ # Apply initial stretch/min size policy
+ self._apply_stretch_policy()
+ except Exception:
+ pass
+ except Exception as e:
+ try:
+ self._logger.exception("Error creating GTK MultiLineEdit backend: %s", e)
+ except Exception:
+ pass
+
+ def _set_backend_enabled(self, enabled):
+ try:
+ if self._widget is not None:
+ try:
+ self._textview.set_sensitive(enabled)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def _on_buffer_changed(self, buf):
+ try:
+ start = buf.get_start_iter()
+ end = buf.get_end_iter()
+ try:
+ text = buf.get_text(start, end, True)
+ except Exception:
+ text = ""
+ # enforce input max length
+ try:
+ if getattr(self, '_input_max_length', -1) >= 0 and len(text) > self._input_max_length:
+ try:
+ if getattr(self, '_buf_handler_id', None) is not None:
+ buf.handler_block(self._buf_handler_id)
+ except Exception:
+ pass
+ try:
+ buf.set_text(text[:self._input_max_length])
+ text = text[:self._input_max_length]
+ except Exception:
+ pass
+ try:
+ if getattr(self, '_buf_handler_id', None) is not None:
+ buf.handler_unblock(self._buf_handler_id)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ self._value = text
+ dlg = self.findDialog()
+ if dlg is not None:
+ try:
+ if self.notify():
+ dlg._post_event(YWidgetEvent(self, YEventReason.ValueChanged))
+ except Exception:
+ try:
+ self._logger.exception("Failed to post ValueChanged event")
+ except Exception:
+ pass
+ except Exception:
+ try:
+ self._logger.exception("_on_buffer_changed error")
+ except Exception:
+ pass
+
+ def _apply_stretch_policy(self):
+ """Apply horizontal/vertical stretch independently and set min pixel sizes.
+
+ - When an axis is not stretchable, set a minimum pixel size:
+ width ~= 20 chars; height ~= `defaultVisibleLines` lines + label.
+ - When stretchable, clear size requests and set expand flags.
+ """
+ try:
+ if self._widget is None:
+ return
+ target = self._scrolled if getattr(self, '_scrolled', None) is not None else self._textview
+
+ # Determine current stretch flags
+ horiz = bool(self.stretchable(YUIDimension.YD_HORIZ))
+ vert = bool(self.stretchable(YUIDimension.YD_VERT))
+
+ # Compute approximate character width and line height via Pango
+ try:
+ layout = self._textview.create_pango_layout("M") if self._textview is not None else None
+ if layout is not None:
+ char_w, line_h = layout.get_pixel_size()
+ else:
+ char_w, line_h = 8, 16
+ # Fallback if layout reports zeros
+ if not char_w:
+ char_w = 8
+ if not line_h:
+ line_h = 16
+ except Exception:
+ char_w, line_h = 8, 16
+
+ # Label height approximation
+ try:
+ lbl_layout = self._lbl.create_pango_layout("M") if self._lbl is not None else None
+ qlabel_w, qlabel_h = lbl_layout.get_pixel_size() if lbl_layout is not None else (0, 20)
+ if not qlabel_h:
+ qlabel_h = 20
+ except Exception:
+ qlabel_h = 20
+
+ desired_chars = 20
+ w_px = int(char_w * desired_chars) + 12
+ text_h_px = int(line_h * max(1, self._default_visible_lines))
+ box_h_px = text_h_px + qlabel_h + 8
+
+ # Expansion flags
+ try:
+ if target is not None:
+ target.set_hexpand(horiz)
+ target.set_vexpand(vert)
+ self._widget.set_hexpand(horiz)
+ self._widget.set_vexpand(vert)
+ except Exception:
+ pass
+
+ # Horizontal constraint
+ try:
+ if not horiz:
+ if target is not None and hasattr(target, 'set_min_content_width'):
+ target.set_min_content_width(w_px)
+ else:
+ # Fallback
+ if target is not None:
+ target.set_size_request(w_px, -1)
+ else:
+ self._widget.set_size_request(w_px, -1)
+ else:
+ if target is not None:
+ target.set_size_request(-1, -1)
+ except Exception:
+ pass
+
+ # Vertical constraint
+ try:
+ if not vert:
+ if target is not None and hasattr(target, 'set_min_content_height'):
+ target.set_min_content_height(text_h_px)
+ else:
+ if target is not None:
+ target.set_size_request(-1, text_h_px)
+ # Ensure overall box accounts for label rows
+ try:
+ self._widget.set_size_request(-1, box_h_px)
+ except Exception:
+ pass
+ else:
+ if target is not None:
+ target.set_size_request(-1, -1)
+ try:
+ self._widget.set_size_request(-1, -1)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ except Exception:
+ try:
+ self._logger.exception("_apply_stretch_policy failed")
+ except Exception:
+ pass
diff --git a/manatools/aui/backends/gtk/panedgtk.py b/manatools/aui/backends/gtk/panedgtk.py
new file mode 100644
index 0000000..ef8fb24
--- /dev/null
+++ b/manatools/aui/backends/gtk/panedgtk.py
@@ -0,0 +1,245 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.gtk contains all GTK backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.gtk
+'''
+"""
+YPanedGtk: GTK4 Paned widget wrapper.
+
+- Wraps Gtk.Paned with horizontal or vertical orientation.
+- Accepts up to two children; first child goes to "start", second to "end".
+- Behavior similar to HBox/VBox but using native Gtk.Paned.
+"""
+
+import logging
+from ...yui_common import YWidget, YUIDimension
+
+try:
+ import gi
+ gi.require_version("Gtk", "4.0")
+ from gi.repository import Gtk
+except Exception as e:
+ Gtk = None # Allow import in non-GTK environments
+ logging.getLogger("manatools.aui.gtk.paned").error("Failed to import GTK4: %s", e, exc_info=True)
+
+
+class YPanedGtk(YWidget):
+ """
+ GTK4 implementation of YPaned using Gtk.Paned.
+
+ - Paned is stretchable by default on both directions.
+ - Children are set to expand and fill, similar to HBox/VBox behavior.
+ """
+
+ def __init__(self, parent=None, dimension: YUIDimension = YUIDimension.YD_HORIZ):
+ super().__init__(parent)
+ self._logger = logging.getLogger("manatools.aui.gtk.YPanedGtk")
+ self._orientation = dimension
+ self._backend_widget = None
+ # Make paned stretchable by default so it fills available space
+ try:
+ self.setStretchable(YUIDimension.YD_HORIZ, True)
+ self.setStretchable(YUIDimension.YD_VERT, True)
+ except Exception:
+ self._logger.debug("Default stretchable setup failed", exc_info=True)
+
+ def widgetClass(self):
+ return "YPaned"
+
+ # --- size policy helpers ---
+ def setStretchable(self, dimension, stretchable):
+ """Override stretchable to re-apply size policy using base attributes."""
+ try:
+ super().setStretchable(dimension, stretchable)
+ except Exception:
+ self._logger.exception("setStretchable: base implementation failed")
+ self._apply_size_policy()
+
+ def _apply_size_policy(self):
+ """
+ Apply GTK4 expand/alignment to the paned and its children using YUI stretch/weights.
+ Ensures the paned and children fill the allocated space.
+ """
+ if self._backend_widget is None or Gtk is None:
+ return
+ try:
+ # Read stretch flags from base YWidget
+ h_stretch = bool(self.stretchable(YUIDimension.YD_HORIZ))
+ v_stretch = bool(self.stretchable(YUIDimension.YD_VERT))
+ # Read weights from base YWidget; default to 0.0 if not provided
+ try:
+ w_h = float(self.weight(YUIDimension.YD_HORIZ))
+ except Exception:
+ w_h = 0.0
+ try:
+ w_v = float(self.weight(YUIDimension.YD_VERT))
+ except Exception:
+ w_v = 0.0
+
+ eff_h = bool(h_stretch or (w_h > 0.0))
+ eff_v = bool(v_stretch or (w_v > 0.0))
+
+ # Paned should expand and fill
+ try:
+ self._backend_widget.set_hexpand(eff_h)
+ except Exception:
+ self._logger.debug("set_hexpand failed on paned", exc_info=True)
+ try:
+ self._backend_widget.set_vexpand(eff_v)
+ except Exception:
+ self._logger.debug("set_vexpand failed on paned", exc_info=True)
+ try:
+ self._backend_widget.set_halign(Gtk.Align.FILL if eff_h else Gtk.Align.START)
+ except Exception:
+ self._logger.debug("set_halign failed on paned", exc_info=True)
+ try:
+ self._backend_widget.set_valign(Gtk.Align.FILL if eff_v else Gtk.Align.START)
+ except Exception:
+ self._logger.debug("set_valign failed on paned", exc_info=True)
+
+ # Ensure children fill their panes (like HBox/VBox)
+ for ch in getattr(self, "_children", []):
+ try:
+ bw = ch.get_backend_widget() if hasattr(ch, "get_backend_widget") else getattr(ch, "_backend_widget", None)
+ if bw is None:
+ continue
+ bw.set_hexpand(True)
+ bw.set_vexpand(True)
+ bw.set_halign(Gtk.Align.FILL)
+ bw.set_valign(Gtk.Align.FILL)
+ except Exception:
+ self._logger.debug("Failed to apply child size policy for %s", getattr(ch, "debugLabel", lambda: repr(ch))(), exc_info=True)
+
+ self._logger.debug(
+ "_apply_size_policy: h_stretch=%s v_stretch=%s w_h=%s w_v=%s eff_h=%s eff_v=%s",
+ h_stretch, v_stretch, w_h, w_v, eff_h, eff_v
+ )
+ except Exception:
+ self._logger.exception("_apply_size_policy: unexpected failure")
+
+ def _apply_child_props(self, child_widget):
+ """
+ Ensure a child widget expands and fills its allocated pane area.
+ """
+ if child_widget is None or Gtk is None:
+ return
+ try:
+ child_widget.set_hexpand(True)
+ except Exception:
+ self._logger.debug("child.set_hexpand failed", exc_info=True)
+ try:
+ child_widget.set_vexpand(True)
+ except Exception:
+ self._logger.debug("child.set_vexpand failed", exc_info=True)
+ try:
+ child_widget.set_halign(Gtk.Align.FILL)
+ except Exception:
+ self._logger.debug("child.set_halign failed", exc_info=True)
+ try:
+ child_widget.set_valign(Gtk.Align.FILL)
+ except Exception:
+ self._logger.debug("child.set_valign failed", exc_info=True)
+
+ def _configure_paned_behavior(self):
+ """
+ Configure Gtk.Paned to behave like Qt's QSplitter:
+ - allow full collapse of either child (shrink = True on both sides)
+ - let both children participate in resize (resize = True on both sides)
+ """
+ if self._backend_widget is None or Gtk is None:
+ return
+ try:
+ if hasattr(self._backend_widget, "set_shrink_start_child"):
+ self._backend_widget.set_shrink_start_child(True)
+ if hasattr(self._backend_widget, "set_shrink_end_child"):
+ self._backend_widget.set_shrink_end_child(True)
+ if hasattr(self._backend_widget, "set_resize_start_child"):
+ self._backend_widget.set_resize_start_child(True)
+ if hasattr(self._backend_widget, "set_resize_end_child"):
+ self._backend_widget.set_resize_end_child(True)
+ self._logger.debug("Paned behavior configured: shrink(start/end)=True, resize(start/end)=True")
+ except Exception:
+ self._logger.error("Failed to configure paned behavior", exc_info=True)
+
+ def _create_backend_widget(self):
+ """
+ Create the underlying Gtk.Paned with the chosen orientation and attach existing children.
+ """
+ if Gtk is None:
+ raise RuntimeError("GTK4 is not available")
+ orient = Gtk.Orientation.HORIZONTAL if self._orientation == YUIDimension.YD_HORIZ else Gtk.Orientation.VERTICAL
+ self._backend_widget = Gtk.Paned.new(orient)
+ self._logger.debug("Created Gtk.Paned orientation=%s", "H" if orient == Gtk.Orientation.HORIZONTAL else "V")
+
+ # Paned should fill available space
+ try:
+ self._backend_widget.set_hexpand(True)
+ self._backend_widget.set_vexpand(True)
+ self._backend_widget.set_halign(Gtk.Align.FILL)
+ self._backend_widget.set_valign(Gtk.Align.FILL)
+ except Exception:
+ self._logger.debug("Initial paned size setup failed", exc_info=True)
+
+ # Ensure splitter can fully collapse either child (Qt-like behavior)
+ self._configure_paned_behavior()
+
+ # Attach already collected children (like HBox/VBox does)
+ for idx, child in enumerate(getattr(self, "_children", [])):
+ try:
+ widget = child.get_backend_widget() if hasattr(child, "get_backend_widget") else getattr(child, "_backend_widget", None)
+ if widget is None:
+ self._logger.debug("Child %s has no backend widget yet", getattr(child, "debugLabel", lambda: repr(child))())
+ continue
+ if idx == 0:
+ self._backend_widget.set_start_child(widget)
+ self._logger.debug("Set start child: %s", child.debugLabel())
+ elif idx == 1:
+ self._backend_widget.set_end_child(widget)
+ self._logger.debug("Set end child: %s", child.debugLabel())
+ else:
+ self._logger.warning("YPanedGtk can only manage two children; ignoring extra child")
+ # Ensure child fills
+ self._apply_child_props(widget)
+ except Exception as e:
+ self._logger.error("Attaching child[%d] failed: %s", idx, e, exc_info=True)
+
+ # Apply size policy once backend is ready
+ self._apply_size_policy()
+
+ def addChild(self, child: YWidget):
+ """
+ Add a child to the paned: first goes to 'start', second to 'end'.
+ Children are set to expand and fill.
+ """
+ if len(self._children) == 2:
+ self._logger.warning("YPanedGtk can only manage two children; ignoring extra child")
+ return
+ super().addChild(child)
+ if self._backend_widget is None:
+ return
+ try:
+ widget = child.get_backend_widget() if hasattr(child, "get_backend_widget") else getattr(child, "_backend_widget", None)
+ if child == self._children[0]:
+ if widget is not None:
+ self._backend_widget.set_start_child(widget)
+ self._apply_child_props(widget)
+ self._logger.debug("Set start child: %s", getattr(child, "debugLabel", lambda: repr(child))())
+ elif len(self._children) > 1 and child == self._children[1]:
+ if widget is not None:
+ self._backend_widget.set_end_child(widget)
+ self._apply_child_props(widget)
+ self._logger.debug("Set end child: %s", getattr(child, "debugLabel", lambda: repr(child))())
+ else:
+ self._logger.warning("YPanedGtk can only manage two children; ignoring extra child")
+ except Exception as e:
+ self._logger.error("addChild error: %s", e, exc_info=True)
+ # Keep paned behavior consistent after dynamic changes
+ self._configure_paned_behavior()
+ # Re-apply overall size policy
+ self._apply_size_policy()
diff --git a/manatools/aui/backends/gtk/progressbargtk.py b/manatools/aui/backends/gtk/progressbargtk.py
new file mode 100644
index 0000000..40f82bd
--- /dev/null
+++ b/manatools/aui/backends/gtk/progressbargtk.py
@@ -0,0 +1,220 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.gtk contains all GTK backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.gtk
+'''
+
+import gi
+gi.require_version('Gtk', '4.0')
+gi.require_version('Gdk', '4.0')
+from gi.repository import Gtk, Gdk, GObject, GdkPixbuf, GLib
+import cairo
+import threading
+import os
+import logging
+from ...yui_common import *
+
+class YProgressBarGtk(YWidget):
+ def __init__(self, parent=None, label="", maxValue=100):
+ super().__init__(parent)
+ self._label = label
+ self._max_value = int(maxValue) if maxValue is not None else 100
+ self._value = 0
+ self._backend_widget = None
+ self._label_widget = None
+ self._progress_widget = None
+ #default stretchable in horizontal direction
+ self.setStretchable(YUIDimension.YD_HORIZ, True)
+ self._logger = logging.getLogger(f"manatools.aui.gtk.{self.__class__.__name__}")
+
+ def widgetClass(self):
+ return "YProgressBar"
+
+ # --- size policy helpers ---
+ def setStretchable(self, dimension, stretchable):
+ """Override stretchable to re-apply size policy on change using YUI base attributes."""
+ try:
+ super().setStretchable(dimension, stretchable)
+ except Exception:
+ self._logger.exception("setStretchable: base implementation failed")
+ # Do not cache locally; read from base each time
+ self._apply_size_policy()
+
+ def _apply_size_policy(self):
+ """Apply GTK4 expand/alignment to container, label and progress bar using YUI stretch/weight."""
+ if self._backend_widget is None:
+ return
+ try:
+ # Read stretch flags from base YWidget
+ h_stretch = bool(self.stretchable(YUIDimension.YD_HORIZ))
+ v_stretch = bool(self.stretchable(YUIDimension.YD_VERT))
+
+ # Read weights from base YWidget; default to 0.0 if not provided
+ try:
+ w_h = float(self.weight(YUIDimension.YD_HORIZ))
+ except Exception:
+ w_h = 0.0
+ try:
+ w_v = float(self.weight(YUIDimension.YD_VERT))
+ except Exception:
+ w_v = 0.0
+
+ eff_h = bool(h_stretch or (w_h > 0.0))
+ eff_v = bool(v_stretch or (w_v > 0.0))
+
+ targets = []
+ if getattr(self, "_backend_widget", None) is not None:
+ targets.append(self._backend_widget)
+ if getattr(self, "_label_widget", None) is not None:
+ targets.append(self._label_widget)
+ if getattr(self, "_progress_widget", None) is not None:
+ targets.append(self._progress_widget)
+
+ for w in targets:
+ try:
+ w.set_hexpand(eff_h)
+ except Exception:
+ self._logger.debug("set_hexpand failed on %s", type(w), exc_info=True)
+ try:
+ w.set_halign(Gtk.Align.FILL if eff_h else Gtk.Align.START)
+ except Exception:
+ self._logger.debug("set_halign failed on %s", type(w), exc_info=True)
+ try:
+ w.set_vexpand(eff_v)
+ except Exception:
+ self._logger.debug("set_vexpand failed on %s", type(w), exc_info=True)
+ try:
+ w.set_valign(Gtk.Align.FILL if eff_v else Gtk.Align.START)
+ except Exception:
+ self._logger.debug("set_valign failed on %s", type(w), exc_info=True)
+
+ self._logger.debug(
+ "_apply_size_policy: h_stretch=%s v_stretch=%s w_h=%s w_v=%s eff_h=%s eff_v=%s",
+ h_stretch, v_stretch, w_h, w_v, eff_h, eff_v
+ )
+ except Exception:
+ self._logger.exception("_apply_size_policy: unexpected failure")
+
+ def label(self):
+ return self._label
+
+ def setLabel(self, newLabel):
+ try:
+ self._label = str(newLabel) if isinstance(newLabel, str) else newLabel
+ if getattr(self, "_label_widget", None) is not None:
+ try:
+ is_valid = isinstance(self._label, str) and bool(self._label.strip())
+ if is_valid:
+ self._label_widget.set_text(self._label)
+ self._label_widget.set_visible(True)
+ else:
+ self._label_widget.set_visible(False)
+ except Exception:
+ self._logger.exception("setLabel: failed to update Gtk.Label")
+ except Exception:
+ self._logger.exception("setLabel: unexpected error")
+
+ def maxValue(self):
+ return int(self._max_value)
+
+ def value(self):
+ return int(self._value)
+
+ def setValue(self, newValue):
+ try:
+ v = int(newValue)
+ if v < 0:
+ v = 0
+ if v > self._max_value:
+ v = self._max_value
+ self._value = v
+ if getattr(self, "_progress_widget", None) is not None:
+ try:
+ self._progress_widget.set_fraction(float(self._value) / float(self._max_value) if self._max_value > 0 else 0.0)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def _create_backend_widget(self):
+ container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
+ # Start with sane defaults; final behavior comes from _apply_size_policy
+ container.set_hexpand(True)
+ container.set_halign(Gtk.Align.FILL)
+ container.set_valign(Gtk.Align.FILL)
+ # container.set_homogeneous(False) # default; keep for clarity
+
+ # Label
+ self._label_widget = Gtk.Label()
+ self._label_widget.set_halign(Gtk.Align.START)
+ container.append(self._label_widget)
+ try:
+ is_valid = isinstance(self._label, str) and bool(self._label.strip())
+ if is_valid:
+ self._label_widget.set_text(self._label)
+ self._label_widget.set_visible(True)
+ else:
+ self._label_widget.set_visible(False)
+ except Exception:
+ # be safe: hide if anything goes wrong
+ self._label_widget.set_visible(False)
+
+ # Progress Bar
+ self._progress_widget = Gtk.ProgressBar()
+ self._progress_widget.set_fraction(float(self._value) / float(self._max_value) if self._max_value > 0 else 0.0)
+ self._progress_widget.set_hexpand(True)
+ self._progress_widget.set_halign(Gtk.Align.FILL)
+ self._progress_widget.set_show_text(True)
+ container.append(self._progress_widget)
+
+ self._backend_widget = container
+
+ # Apply consistent size policy to avoid centered layout
+ self._apply_size_policy()
+
+ try:
+ self._backend_widget.set_sensitive(self._enabled)
+ except Exception:
+ pass
+ if self._help_text:
+ try:
+ self._backend_widget.set_tooltip_text(self._help_text)
+ except Exception:
+ self._logger.error("Failed to set tooltip text on backend widget", exc_info=True)
+ try:
+ self._backend_widget.set_visible(self.visible())
+ except Exception:
+ self._logger.error("Failed to set backend widget visible", exc_info=True)
+ try:
+ self._backend_widget.set_sensitive(self._enabled)
+ except Exception:
+ self._logger.exception("Failed to set sensitivity on backend widget")
+
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+
+ def setVisible(self, visible: bool = True):
+ """Set widget visibility and propagate it to the GTK backend widget."""
+ super().setVisible(visible)
+ try:
+ if getattr(self, "_backend_widget", None) is not None:
+ self._backend_widget.set_visible(bool(visible))
+ except Exception:
+ self._logger.exception("setVisible failed")
+
+ def setHelpText(self, help_text: str):
+ """Set help text (tooltip) and propagate it to the GTK backend widget."""
+ super().setHelpText(help_text)
+ try:
+ if getattr(self, "_backend_widget", None) is not None:
+ try:
+ self._backend_widget.set_tooltip_text(help_text)
+ except Exception:
+ self._logger.exception("Failed to apply tooltip to backend widget")
+ except Exception:
+ self._logger.exception("setHelpText failed")
\ No newline at end of file
diff --git a/manatools/aui/backends/gtk/pushbuttongtk.py b/manatools/aui/backends/gtk/pushbuttongtk.py
new file mode 100644
index 0000000..c7a93ea
--- /dev/null
+++ b/manatools/aui/backends/gtk/pushbuttongtk.py
@@ -0,0 +1,336 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.gtk contains all GTK backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.gtk
+'''
+
+import gi
+gi.require_version('Gtk', '4.0')
+gi.require_version('Gdk', '4.0')
+from gi.repository import Gtk, Gdk, GObject, GdkPixbuf, GLib
+import cairo
+import threading
+import os
+import logging
+from typing import Optional
+from ...yui_common import *
+from .commongtk import _resolve_icon, _convert_mnemonic_to_gtk
+
+
+class _YPushButtonMeasure(Gtk.Button):
+ """Gtk.Button subclass delegating size measurement to YPushButtonGtk."""
+
+ def __init__(self, owner, label=None):
+ """Initialize the measuring button.
+
+ Args:
+ owner: Owning YPushButtonGtk instance.
+ label: Optional initial label.
+ """
+ if label is None:
+ super().__init__()
+ else:
+ super().__init__(label=label)
+ self._owner = owner
+
+ def do_measure(self, orientation, for_size):
+ """GTK4 virtual method for size measurement.
+
+ Args:
+ orientation: Gtk.Orientation (HORIZONTAL or VERTICAL)
+ for_size: Size in the opposite orientation (-1 if not constrained)
+
+ Returns:
+ tuple: (minimum_size, natural_size, minimum_baseline, natural_baseline)
+ """
+ try:
+ return self._owner.do_measure(orientation, for_size)
+ except Exception:
+ self._owner._logger.exception("PushButton backend do_measure delegation failed", exc_info=True)
+ return (0, 0, -1, -1)
+
+
+class YPushButtonGtk(YWidget):
+ """Gtk4 push button wrapper supporting icons, mnemonics, and default state."""
+ def __init__(self, parent=None, label: str="", icon_name: Optional[str]=None, icon_only: Optional[bool]=False):
+ super().__init__(parent)
+ self._label = _convert_mnemonic_to_gtk(label)
+ self._icon_name = icon_name
+ self._icon_only = bool(icon_only)
+ self._is_default = False
+ self._logger = logging.getLogger(f"manatools.aui.gtk.{self.__class__.__name__}")
+
+ def widgetClass(self):
+ return "YPushButton"
+
+ def label(self):
+ return self._label
+
+ def setLabel(self, label):
+ self._label = _convert_mnemonic_to_gtk(label)
+ if self._backend_widget:
+ try:
+ self._backend_widget.set_label(self._label)
+ except Exception:
+ pass
+
+ def setDefault(self, default: bool):
+ """Mark this push button as the dialog default."""
+ self._apply_default_state(bool(default), notify_dialog=True)
+
+ def default(self) -> bool:
+ """Return True if this button is currently the default."""
+ return bool(getattr(self, "_is_default", False))
+
+ def do_measure(self, orientation, for_size):
+ """GTK4 virtual method for size measurement.
+
+ Args:
+ orientation: Gtk.Orientation (HORIZONTAL or VERTICAL)
+ for_size: Size in the opposite orientation (-1 if not constrained)
+
+ Returns:
+ tuple: (minimum_size, natural_size, minimum_baseline, natural_baseline)
+ """
+ widget = getattr(self, "_backend_widget", None)
+ if widget is not None:
+ try:
+ measured = Gtk.Button.do_measure(widget, orientation, for_size)
+ self._logger.debug("PushButton do_measure orientation=%s for_size=%s -> %s", orientation, for_size, measured)
+ return measured
+ except Exception:
+ self._logger.exception("PushButton base do_measure failed", exc_info=True)
+
+ text = str(getattr(self, "_label", "") or "")
+ text_len = max(1, len(text.replace("_", "")))
+ if orientation == Gtk.Orientation.HORIZONTAL:
+ minimum_size = max(48, text_len * 6 + 18)
+ natural_size = max(minimum_size, text_len * 9 + 28)
+ else:
+ minimum_size = 30
+ natural_size = 34
+ self._logger.debug(
+ "PushButton fallback do_measure orientation=%s for_size=%s -> min=%s nat=%s",
+ orientation,
+ for_size,
+ minimum_size,
+ natural_size,
+ )
+ return (minimum_size, natural_size, -1, -1)
+
+ def _create_backend_widget(self):
+ if self._icon_only:
+ self._logger.info(f"Creating icon-only button '{self._label}'")
+ self._backend_widget = _YPushButtonMeasure(self)
+ self._backend_widget.set_use_underline(True)
+ if self._icon_name:
+ self._backend_widget.set_icon_name(self._icon_name)
+ else:
+ self._logger.info(f"Creating button with icon and label '{self._label}'")
+ try:
+ self._backend_widget = _YPushButtonMeasure(self, label=self._label)
+ self._backend_widget.set_use_underline(True)
+ hb = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
+ if self._icon_name:
+ img = _resolve_icon(self._icon_name)
+ if img is not None:
+ hb.append(img)
+ lbl = Gtk.Label(label=self._label, use_underline=True)
+ # center contents inside the box so button label appears centered
+ try:
+ hb.set_halign(Gtk.Align.CENTER)
+ hb.set_valign(Gtk.Align.CENTER)
+ hb.set_hexpand(False)
+ except Exception:
+ pass
+ try:
+ lbl.set_halign(Gtk.Align.CENTER)
+ except Exception:
+ pass
+ hb.append(lbl)
+ self._backend_widget.set_child(hb)
+ except Exception:
+ self._logger.exception("Failed to create button with icon and label", exc_info=True)
+ raise RuntimeError("Failed to create button with icon and label")
+
+ if self._help_text:
+ try:
+ self._backend_widget.set_tooltip_text(self._help_text)
+ except Exception:
+ self._logger.exception("Failed to set tooltip text", exc_info=True)
+ try:
+ self._backend_widget.set_visible(self.visible())
+ except Exception:
+ self._logger.exception("Failed to set widget visibility", exc_info=True)
+
+ # Prevent button from being stretched horizontally by default.
+ try:
+ self._backend_widget.set_hexpand(self.stretchable(YUIDimension.YD_HORIZ))
+ self._backend_widget.set_vexpand(self.stretchable(YUIDimension.YD_VERT))
+ self._backend_widget.set_halign(Gtk.Align.FILL if self.stretchable(YUIDimension.YD_HORIZ) else Gtk.Align.CENTER)
+ self._backend_widget.set_valign(Gtk.Align.FILL if self.stretchable(YUIDimension.YD_VERT) else Gtk.Align.CENTER)
+ except Exception:
+ pass
+ try:
+ self._backend_widget.set_sensitive(self._enabled)
+ self._backend_widget.connect("clicked", self._on_clicked)
+ except Exception:
+ try:
+ self._logger.error("_create_backend_widget setup failed", exc_info=True)
+ except Exception:
+ pass
+ self._sync_default_visual()
+ try:
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception:
+ pass
+
+ def _on_clicked(self, button):
+ if self.notify() is False:
+ return
+ dlg = self.findDialog()
+ if dlg is not None:
+ dlg._post_event(YWidgetEvent(self, YEventReason.Activated))
+ else:
+ # silent fallback
+ pass
+
+ def _set_backend_enabled(self, enabled):
+ """Enable/disable the push button backend."""
+ try:
+ if self._backend_widget is not None:
+ try:
+ self._backend_widget.set_sensitive(enabled)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def setIcon(self, icon_name: str):
+ """Set/clear the icon for this pushbutton (icon_name may be theme name or path)."""
+ try:
+ self._icon_name = icon_name
+ if getattr(self, "_backend_widget", None) is None:
+ return
+ if self._icon_only:
+ self._backend_widget.set_icon_name(icon_name)
+ return
+ # not icon_only: try to set icon + label
+ img = None
+ try:
+ img = _resolve_icon(icon_name)
+ except Exception:
+ img = None
+ if img is not None:
+ try:
+ # Set composite child with image + label (centered)
+ try:
+ hb = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
+ hb.append(img)
+ lbl = Gtk.Label(label=self._label)
+ try:
+ hb.set_halign(Gtk.Align.CENTER)
+ hb.set_valign(Gtk.Align.CENTER)
+ hb.set_hexpand(False)
+ except Exception:
+ pass
+ try:
+ lbl.set_halign(Gtk.Align.CENTER)
+ except Exception:
+ pass
+ hb.append(lbl)
+ self._backend_widget.set_child(hb)
+ return
+ except Exception:
+ pass
+ except Exception:
+ pass
+ # If we reach here, clear any icon and ensure label is present
+ try:
+ # Reset to simple label-only button
+ try:
+ self._backend_widget.set_label(self._label)
+ self._backend_widget.set_use_underline(True)
+ except Exception:
+ try:
+ # If set_label not available, set child to a label
+ lbl = Gtk.Label(label=self._label)
+ self._backend_widget.set_child(lbl)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ except Exception:
+ try:
+ self._logger.exception("setIcon failed")
+ except Exception:
+ pass
+
+ def setVisible(self, visible=True):
+ super().setVisible(visible)
+ try:
+ if getattr(self, "_backend_widget", None) is not None:
+ try:
+ self._backend_widget.set_visible(visible)
+ except Exception:
+ self._logger.exception("setVisible failed", exc_info=True)
+ except Exception:
+ pass
+
+ def setHelpText(self, help_text: str):
+ super().setHelpText(help_text)
+ try:
+ if getattr(self, "_backend_widget", None) is not None:
+ try:
+ self._backend_widget.set_tooltip_text(help_text)
+ except Exception:
+ self._logger.exception("setHelpText failed", exc_info=True)
+ except Exception:
+ pass
+
+ def _apply_default_state(self, state: bool, notify_dialog: bool):
+ """Store default flag and notify parent dialog if requested."""
+ desired = bool(state)
+ if getattr(self, "_is_default", False) == desired and not notify_dialog:
+ return
+ dlg = self.findDialog() if notify_dialog else None
+ if notify_dialog and dlg is not None:
+ try:
+ if desired:
+ dlg._register_default_button(self)
+ else:
+ dlg._unregister_default_button(self)
+ except Exception:
+ self._logger.exception("Failed to synchronize default state with dialog")
+ self._is_default = desired
+ self._sync_default_visual()
+
+ def _sync_default_visual(self):
+ """Apply/remove Gtk suggested-action styling for default buttons."""
+ widget = getattr(self, "_backend_widget", None)
+ if widget is None:
+ return
+ try:
+ if self._is_default:
+ widget.add_css_class("suggested-action")
+ else:
+ widget.remove_css_class("suggested-action")
+ return
+ except Exception:
+ pass
+ # Fallback for bindings exposing style_context instead of css helpers
+ try:
+ ctx = widget.get_style_context()
+ if ctx:
+ if self._is_default:
+ ctx.add_class("suggested-action")
+ else:
+ ctx.remove_class("suggested-action")
+ except Exception:
+ self._logger.exception("Failed to toggle suggested-action class")
diff --git a/manatools/aui/backends/gtk/radiobuttongtk.py b/manatools/aui/backends/gtk/radiobuttongtk.py
new file mode 100644
index 0000000..a1ac685
--- /dev/null
+++ b/manatools/aui/backends/gtk/radiobuttongtk.py
@@ -0,0 +1,164 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.gtk contains all GTK backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.gtk
+'''
+
+import gi
+gi.require_version('Gtk', '4.0')
+gi.require_version('Gdk', '4.0')
+from gi.repository import Gtk, Gdk, GObject, GdkPixbuf, GLib
+import cairo
+import threading
+import os
+import logging
+from ...yui_common import *
+
+class YRadioButtonGtk(YWidget):
+ def __init__(self, parent=None, label="", isChecked=False):
+ super().__init__(parent)
+ self._label = label
+ self._is_checked = bool(isChecked)
+ self._backend_widget = None
+ # determine radio-group membership among siblings
+ self._group = None
+ try:
+ brothers = getattr(parent, '_children', []) if parent is not None else []
+ for b in brothers:
+ try:
+ if b is self:
+ continue
+ if getattr(b, 'widgetClass', None) and b.widgetClass() == 'YRadioButton':
+ # adopt existing sibling's group if set, otherwise use sibling as group leader
+ grp = getattr(b, '_group', None)
+ if grp is not None:
+ self._group = grp
+ else:
+ self._group = b # should not happen
+ break
+ except Exception:
+ pass
+ if self._group is None:
+ # no sibling found: become group leader
+ self._group = self
+ except Exception:
+ self._group = self
+ self._logger = logging.getLogger(f"manatools.aui.gtk.{self.__class__.__name__}")
+
+ def widgetClass(self):
+ return "YRadioButton"
+
+ def label(self):
+ return self._label
+
+ def setLabel(self, newLabel):
+ try:
+ self._label = str(newLabel)
+ if getattr(self, "_backend_widget", None) is not None:
+ try:
+ self._backend_widget.set_label(self._label)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def isChecked(self):
+ return bool(self._is_checked)
+
+ def setChecked(self, checked):
+ try:
+ self._is_checked = bool(checked)
+ if getattr(self, "_backend_widget", None) is not None:
+ try:
+ # avoid emitting signals while programmatically changing state
+ self._backend_widget.handler_block_by_func(self._on_toggled)
+ self._backend_widget.set_active(self._is_checked)
+ finally:
+ try:
+ self._backend_widget.handler_unblock_by_func(self._on_toggled)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def _create_backend_widget(self):
+ # Create a check-like radio using Gtk.CheckButton (GTK4 bindings may
+ # not provide Gtk.RadioButton reliably). If a sibling group's backend
+ # widget exists, try to join its GTK group via `set_group`.
+ try:
+ self._backend_widget = Gtk.CheckButton(label=self._label)
+ if getattr(self, '_group', None) is not None and self._group is not self:
+ ref_w = getattr(self._group, '_backend_widget', None)
+ if ref_w is not None:
+ try:
+ if hasattr(self._backend_widget, 'set_group'):
+ try:
+ self._backend_widget.set_group(ref_w)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ except Exception:
+ try:
+ self._backend_widget = Gtk.CheckButton(label=self._label)
+ except Exception:
+ self._backend_widget = None
+
+ # Prevent radio button from being stretched horizontally by default.
+ try:
+ self._backend_widget.set_hexpand(self.stretchable(YUIDimension.YD_HORIZ))
+ self._backend_widget.set_vexpand(self.stretchable(YUIDimension.YD_VERT))
+ self._backend_widget.set_halign(Gtk.Align.FILL if self.stretchable(YUIDimension.YD_HORIZ) else Gtk.Align.CENTER)
+ self._backend_widget.set_valign(Gtk.Align.FILL if self.stretchable(YUIDimension.YD_VERT) else Gtk.Align.CENTER)
+ except Exception:
+ pass
+ try:
+ if self._backend_widget is not None:
+ self._backend_widget.set_sensitive(self._enabled)
+ self._backend_widget.connect("toggled", self._on_toggled)
+ self._backend_widget.set_active(self._is_checked)
+ except Exception:
+ try:
+ self._logger.error("_create_backend_widget failed to finalize", exc_info=True)
+ except Exception:
+ pass
+ try:
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception:
+ pass
+
+ def _on_toggled(self, button):
+ try:
+ self._is_checked = bool(button.get_active())
+ except Exception:
+ try:
+ self._is_checked = bool(self._is_checked)
+ except Exception:
+ self._is_checked = False
+
+ if self.notify() is False:
+ return
+
+ try:
+ dlg = self.findDialog()
+ if dlg is not None:
+ dlg._post_event(YWidgetEvent(self, YEventReason.ValueChanged))
+ except Exception:
+ pass
+
+ def _set_backend_enabled(self, enabled):
+ """Enable/disable the Gtk.RadioButton backend."""
+ try:
+ if getattr(self, "_backend_widget", None) is not None:
+ try:
+ self._backend_widget.set_sensitive(enabled)
+ except Exception:
+ pass
+ except Exception:
+ pass
\ No newline at end of file
diff --git a/manatools/aui/backends/gtk/replacepointgtk.py b/manatools/aui/backends/gtk/replacepointgtk.py
new file mode 100644
index 0000000..4b91eea
--- /dev/null
+++ b/manatools/aui/backends/gtk/replacepointgtk.py
@@ -0,0 +1,396 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.gtk contains all GTK backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.gtk
+'''
+import gi
+gi.require_version('Gtk', '4.0')
+from gi.repository import Gtk, GLib
+import logging
+from ...yui_common import *
+
+class YReplacePointGtk(YSingleChildContainerWidget):
+ """
+ GTK backend implementation of YReplacePoint.
+
+ A single-child placeholder container; call showChild() after adding a new
+ child to attach its backend widget inside a Gtk.Box container. deleteChildren()
+ clears both the logical model and the Gtk content.
+ """
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self._backend_widget = None
+ self._content = None
+ # counter to generate stable unique page names for Gtk.Stack
+ self._stack_page_counter = 0
+ self._logger = logging.getLogger(f"manatools.aui.gtk.{self.__class__.__name__}")
+ try:
+ self._logger.debug("%s.__init__", self.__class__.__name__)
+ except Exception:
+ pass
+
+ def widgetClass(self):
+ return "YReplacePoint"
+
+ def _create_backend_widget(self):
+ """Create a container that hosts a Gtk.Stack so only the active child is visible.
+
+ Using a Gtk.Stack mirrors the Qt stacked layout approach and makes
+ showing/hiding the single child more reliable across backends.
+ """
+ try:
+ outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
+ outer.set_hexpand(True)
+ outer.set_vexpand(True)
+ try:
+ outer.set_halign(Gtk.Align.FILL)
+ outer.set_valign(Gtk.Align.FILL)
+ except Exception:
+ pass
+ # Try to give a reasonable default minimum size so the area is visible
+ try:
+ # GTK3/4 compatibility: set_size_request may exist
+ outer.set_size_request(200, 140)
+ except Exception:
+ try:
+ # Fallback: set a minimum content height if available
+ outer.set_minimum_size = getattr(outer, 'set_minimum_size', None)
+ if callable(outer.set_minimum_size):
+ try:
+ outer.set_minimum_size(200, 140)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ stack = Gtk.Stack()
+ try:
+ stack.set_hexpand(True)
+ stack.set_vexpand(True)
+ try:
+ stack.set_halign(Gtk.Align.FILL)
+ stack.set_valign(Gtk.Align.FILL)
+ except Exception:
+ pass
+ try:
+ stack.set_size_request(200, 140)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ outer.append(stack)
+ self._backend_widget = outer
+ self._content = stack
+ # Attach child if already present
+ try:
+ ch = self.child()
+ if ch is not None:
+ cw = ch.get_backend_widget()
+ if cw:
+ try:
+ # add_titled requires a name; use debugLabel to produce a unique id
+ name = f"child_{id(cw)}"
+ stack.add_titled(cw, name, name)
+ try:
+ stack.set_visible_child(cw)
+ except Exception:
+ pass
+ except Exception:
+ try:
+ stack.add(cw)
+ except Exception:
+ pass
+ except Exception as e:
+ try:
+ self._logger.error("_create_backend_widget attach child error: %s", e, exc_info=True)
+ except Exception:
+ pass
+ try:
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception:
+ pass
+ except Exception as e:
+ try:
+ self._logger.error("_create_backend_widget error: %s", e, exc_info=True)
+ except Exception:
+ pass
+ self._backend_widget = None
+ self._content = None
+
+ def _set_backend_enabled(self, enabled):
+ try:
+ if self._backend_widget is not None:
+ self._backend_widget.set_sensitive(bool(enabled))
+ except Exception:
+ pass
+ try:
+ ch = self.child()
+ if ch is not None:
+ ch.setEnabled(enabled)
+ except Exception:
+ pass
+
+ def stretchable(self, dim: YUIDimension):
+ """Propagate stretchability from the single child to the container."""
+ try:
+ ch = self.child()
+ if ch is None:
+ return False
+ try:
+ if bool(ch.stretchable(dim)):
+ return True
+ except Exception:
+ pass
+ try:
+ if bool(ch.weight(dim)):
+ return True
+ except Exception:
+ pass
+ except Exception:
+ pass
+ return False
+
+ def addChild(self, child):
+ super().addChild(child)
+ # Best-effort attach without forcing a full dialog relayout
+ self._attach_child_backend()
+
+ def _clear_content(self):
+ try:
+ if self._content is None:
+ return
+ while True:
+ first = self._content.get_first_child()
+ if first is None:
+ break
+ try:
+ self._content.remove(first)
+ except Exception:
+ break
+ except Exception:
+ pass
+
+ def _attach_child_backend(self):
+ """Attach the current child's backend into the content box (no redraw)."""
+ if not (self._backend_widget and self._content and self.child()):
+ self._logger.debug("_attach_child_backend called but no backend or no child to attach")
+ return
+ try:
+ if self._content is None:
+ self.get_backend_widget()
+ if self._content is None:
+ return
+ # Use idle_add to defer the attach until the child's backend is fully ready
+ def _do_attach():
+ try:
+ # Clear previous stack children
+ try:
+ # Gtk.Stack doesn't provide a direct clear; remove children one by one
+ for c in list(self._content.get_children()):
+ try:
+ self._content.remove(c)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ ch = self.child()
+ if ch is None:
+ return False
+ cw = ch.get_backend_widget()
+ if cw:
+ try:
+ # Wrap the child in a vertical Box so it fills and aligns like other backends
+ wrapper = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5)
+ try:
+ wrapper.set_hexpand(True)
+ wrapper.set_vexpand(True)
+ try:
+ wrapper.set_halign(Gtk.Align.FILL)
+ wrapper.set_valign(Gtk.Align.FILL)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ # If cw has an existing parent, try to unparent it first
+ try:
+ parent = cw.get_parent()
+ if parent is not None:
+ try:
+ parent.remove(cw)
+ except Exception:
+ try:
+ cw.unparent()
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ # append cw into wrapper (Gtk4 uses append)
+ try:
+ wrapper.append(cw)
+ except Exception:
+ try:
+ wrapper.add(cw)
+ except Exception:
+ pass
+
+ # Encourage cw and its children to expand/fill so layout behaves like Qt
+ try:
+ if hasattr(cw, 'set_hexpand'):
+ cw.set_hexpand(True)
+ if hasattr(cw, 'set_vexpand'):
+ cw.set_vexpand(True)
+ if hasattr(cw, 'set_halign'):
+ cw.set_halign(Gtk.Align.FILL)
+ if hasattr(cw, 'set_valign'):
+ cw.set_valign(Gtk.Align.FILL)
+ except Exception:
+ pass
+ try:
+ for inner in list(getattr(cw, 'get_children', lambda: [])()):
+ try:
+ if hasattr(inner, 'set_hexpand'):
+ inner.set_hexpand(True)
+ except Exception:
+ pass
+ try:
+ if hasattr(inner, 'set_vexpand'):
+ inner.set_vexpand(True)
+ except Exception:
+ pass
+ try:
+ if hasattr(inner, 'set_halign'):
+ inner.set_halign(Gtk.Align.FILL)
+ except Exception:
+ pass
+ try:
+ if hasattr(inner, 'set_valign'):
+ inner.set_valign(Gtk.Align.FILL)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ # Remove previous stack children
+ try:
+ for c in list(self._content.get_children()):
+ try:
+ self._content.remove(c)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ # Add wrapper to stack and show it
+ name = f"child_{self._stack_page_counter}"
+ self._stack_page_counter += 1
+ try:
+ self._content.add_titled(wrapper, name, name)
+ except Exception:
+ try:
+ self._content.add(wrapper)
+ except Exception:
+ pass
+ try:
+ self._content.set_visible_child(wrapper)
+ except Exception:
+ pass
+ try:
+ wrapper.set_visible(True)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ except Exception:
+ pass
+ # return False to run only once
+ return False
+
+ try:
+ GLib.idle_add(_do_attach)
+ except Exception:
+ _do_attach()
+ except Exception:
+ pass
+
+ def showChild(self):
+ """Attach child backend and then force a dialog relayout/redraw."""
+ if not (self._backend_widget and self._content and self.child()):
+ self._logger.debug("showChild called but no backend or no child to attach")
+ return
+ # Force a dialog layout/reallocation and redraw (GTK4 best-effort)
+ try:
+ dlg = self.findDialog()
+ win = getattr(dlg, "_window", None) if dlg is not None else None
+ if win is not None:
+ try:
+ if hasattr(win, "queue_resize"):
+ win.queue_resize()
+ except Exception:
+ pass
+ try:
+ if hasattr(win, "queue_allocate"):
+ win.queue_allocate()
+ except Exception:
+ pass
+ try:
+ ctx = GLib.MainContext.default()
+ if ctx is not None:
+ while ctx.pending():
+ ctx.iteration(False)
+ except Exception:
+ pass
+ try:
+ def _idle_relayout():
+ try:
+ if hasattr(win, "queue_resize"):
+ win.queue_resize()
+ except Exception:
+ pass
+ return False
+ GLib.idle_add(_idle_relayout)
+ except Exception:
+ pass
+ # Also ensure stack shows the current child and the window updates
+ try:
+ if self._content is not None:
+ try:
+ cw = None
+ # get visible child if possible
+ try:
+ cw = self._content.get_visible_child()
+ except Exception:
+ # fallback: use first child
+ try:
+ children = self._content.get_children()
+ cw = children[0] if children else None
+ except Exception:
+ cw = None
+ if cw is not None:
+ try:
+ cw.set_visible(True)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def deleteChildren(self):
+ """Clear logical children and content UI for replacement."""
+ try:
+ self._clear_content()
+ super().deleteChildren()
+ except Exception as e:
+ try:
+ self._logger.error("deleteChildren error: %s", e, exc_info=True)
+ except Exception:
+ pass
diff --git a/manatools/aui/backends/gtk/richtextgtk.py b/manatools/aui/backends/gtk/richtextgtk.py
new file mode 100644
index 0000000..ff2fdab
--- /dev/null
+++ b/manatools/aui/backends/gtk/richtextgtk.py
@@ -0,0 +1,434 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+GTK4 backend RichText widget.
+
+- Plain text mode: Gtk.TextView (read-only) inside Gtk.ScrolledWindow
+- Rich text mode: Gtk.Label with markup inside Gtk.ScrolledWindow
+- Link activation: emits YMenuEvent with URL id
+- Auto scroll down: applies to TextView; for Label, scrolled window shows full content
+'''
+import gi
+gi.require_version('Gtk', '4.0')
+gi.require_version('Gdk', '4.0')
+from gi.repository import Gtk, Pango, GLib, Gdk
+import logging
+import re
+from ...yui_common import *
+
+
+
+class _YRichTextMeasureScrolledWindow(Gtk.ScrolledWindow):
+ """Gtk.ScrolledWindow subclass delegating size measurement to YRichTextGtk."""
+
+ def __init__(self, owner):
+ """Initialize the measuring scrolled window.
+
+ Args:
+ owner: Owning YRichTextGtk instance.
+ """
+ super().__init__()
+ self._owner = owner
+
+ def do_measure(self, orientation, for_size):
+ """GTK4 virtual method for size measurement.
+
+ Args:
+ orientation: Gtk.Orientation (HORIZONTAL or VERTICAL)
+ for_size: Size in the opposite orientation (-1 if not constrained)
+
+ Returns:
+ tuple: (minimum_size, natural_size, minimum_baseline, natural_baseline)
+ """
+ try:
+ return self._owner.do_measure(orientation, for_size)
+ except Exception:
+ self._owner._logger.exception("RichText backend do_measure delegation failed", exc_info=True)
+ return (0, 0, -1, -1)
+
+
+class YRichTextGtk(YWidget):
+ """GTK4 rich text widget with plain/markup rendering and link activation."""
+
+ def __init__(self, parent=None, text: str = "", plainTextMode: bool = False):
+ super().__init__(parent)
+ self._text = text or ""
+ self._plain = bool(plainTextMode)
+ self._auto_scroll = False
+ self._last_url = None
+ self._backend_widget = None
+ self._content_widget = None # TextView or Label
+ self._logger = logging.getLogger(f"manatools.aui.gtk.{self.__class__.__name__}")
+ # Default: richtext should be stretchable both horizontally and vertically
+ try:
+ self.setStretchable(YUIDimension.YD_HORIZ, True)
+ self.setStretchable(YUIDimension.YD_VERT, True)
+ except Exception:
+ pass
+
+ def widgetClass(self):
+ return "YRichText"
+
+ # --- size policy helpers ---
+ def setStretchable(self, dimension, stretchable):
+ """Override stretchable to immediately re-apply the GTK size policy."""
+ try:
+ # Call parent implementation first
+ super().setStretchable(dimension, stretchable)
+ except Exception:
+ self._logger.exception("setStretchable: base implementation failed")
+ # Apply to current backend/content
+ self._apply_size_policy()
+
+ def _apply_size_policy(self):
+ """Apply GTK4 expand and alignment based on stretch flags and weights.
+
+ - Stretch or positive weight => expand True and FILL.
+ - Otherwise => expand False and START.
+ - Applies to the Gtk.ScrolledWindow and content widget (Label/TextView).
+ """
+ try:
+ h_stretch = bool(self.stretchable(YUIDimension.YD_HORIZ))
+ v_stretch = bool(self.stretchable(YUIDimension.YD_VERT))
+ try:
+ w_h = float(self.weight(YUIDimension.YD_HORIZ))
+ except Exception:
+ w_h = 0.0
+ try:
+ w_v = float(self.weight(YUIDimension.YD_VERT))
+ except Exception:
+ w_v = 0.0
+
+ eff_h = bool(h_stretch or (w_h > 0.0))
+ eff_v = bool(v_stretch or (w_v > 0.0))
+
+ targets = []
+ if getattr(self, "_backend_widget", None) is not None:
+ targets.append(self._backend_widget)
+ if getattr(self, "_content_widget", None) is not None:
+ targets.append(self._content_widget)
+
+ for ww in targets:
+ try:
+ ww.set_hexpand(eff_h)
+ except Exception:
+ self._logger.debug("set_hexpand failed on %s", type(ww), exc_info=True)
+ try:
+ ww.set_halign(Gtk.Align.FILL if eff_h else Gtk.Align.START)
+ except Exception:
+ self._logger.debug("set_halign failed on %s", type(ww), exc_info=True)
+ try:
+ ww.set_vexpand(eff_v)
+ except Exception:
+ self._logger.debug("set_vexpand failed on %s", type(ww), exc_info=True)
+ try:
+ ww.set_valign(Gtk.Align.FILL if eff_v else Gtk.Align.START)
+ except Exception:
+ self._logger.debug("set_valign failed on %s", type(ww), exc_info=True)
+
+ # Enforce left/top content alignment for Label/TextView
+ try:
+ cw = getattr(self, "_content_widget", None)
+ if isinstance(cw, Gtk.Label):
+ cw.set_justify(Gtk.Justification.LEFT)
+ cw.set_xalign(0.0)
+ elif isinstance(cw, Gtk.TextView):
+ cw.set_monospace(False)
+ cw.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
+ except Exception:
+ self._logger.debug("additional content alignment failed", exc_info=True)
+
+ self._logger.debug(
+ "_apply_size_policy: h_stretch=%s v_stretch=%s w_h=%s w_v=%s eff_h=%s eff_v=%s",
+ h_stretch, v_stretch, w_h, w_v, eff_h, eff_v
+ )
+ except Exception:
+ self._logger.exception("_apply_size_policy: unexpected failure")
+
+ def setValue(self, newValue: str):
+ self._text = newValue or ""
+ w = getattr(self, "_content_widget", None)
+ if w is None:
+ return
+ try:
+ if isinstance(w, Gtk.TextView):
+ buf = w.get_buffer()
+ if buf:
+ try:
+ buf.set_text(self._text)
+ except Exception:
+ pass
+ if self._auto_scroll:
+ try:
+ itr_end = buf.get_end_iter()
+ w.scroll_to_iter(itr_end, 0.0, True, 0.0, 1.0)
+ except Exception:
+ pass
+ elif isinstance(w, Gtk.Label):
+ try:
+ w.set_use_markup(True)
+ except Exception:
+ self._logger.debug("set_use_markup failed on Gtk.Label", exc_info=True)
+ # Convert common HTML tags to Pango markup supported by Gtk.Label
+ converted = self._html_to_pango_markup(self._text)
+ try:
+ w.set_markup(converted)
+ except Exception as e:
+ # If markup fails, log and fallback to plain text
+ self._logger.error("set_markup failed; falling back to set_text: %s", e, exc_info=True)
+ try:
+ w.set_text(re.sub(r"<[^>]+>", "", converted))
+ except Exception:
+ self._logger.debug("set_text failed on Gtk.Label", exc_info=True)
+ except Exception:
+ self._logger.exception("setValue failed", exc_info=True)
+
+ def value(self) -> str:
+ return self._text
+
+ def plainTextMode(self) -> bool:
+ return bool(self._plain)
+
+ def setPlainTextMode(self, on: bool = True):
+ self._plain = bool(on)
+ # rebuild content widget to reflect mode
+ if getattr(self, "_backend_widget", None) is not None:
+ self._create_content()
+ self._apply_size_policy()
+ self.setValue(self._text)
+
+ def autoScrollDown(self) -> bool:
+ return bool(self._auto_scroll)
+
+ def setAutoScrollDown(self, on: bool = True):
+ self._auto_scroll = bool(on)
+ # apply immediately for TextView
+ if self._auto_scroll and isinstance(getattr(self, "_content_widget", None), Gtk.TextView):
+ try:
+ buf = self._content_widget.get_buffer()
+ itr_end = buf.get_end_iter()
+ self._content_widget.scroll_to_iter(itr_end, 0.0, True, 0.0, 1.0)
+ except Exception:
+ pass
+
+ def lastActivatedUrl(self):
+ return self._last_url
+
+ def do_measure(self, orientation, for_size):
+ """GTK4 virtual method for size measurement.
+
+ Args:
+ orientation: Gtk.Orientation (HORIZONTAL or VERTICAL)
+ for_size: Size in the opposite orientation (-1 if not constrained)
+
+ Returns:
+ tuple: (minimum_size, natural_size, minimum_baseline, natural_baseline)
+ """
+ widget = getattr(self, "_backend_widget", None)
+ if widget is not None:
+ try:
+ minimum_size, natural_size, minimum_baseline, natural_baseline = Gtk.ScrolledWindow.do_measure(widget, orientation, for_size)
+ if orientation == Gtk.Orientation.HORIZONTAL:
+ minimum_baseline = -1
+ natural_baseline = -1
+ measured = (minimum_size, natural_size, minimum_baseline, natural_baseline)
+ self._logger.debug("RichText do_measure orientation=%s for_size=%s -> %s", orientation, for_size, measured)
+ return measured
+ except Exception:
+ self._logger.exception("RichText base do_measure failed", exc_info=True)
+
+ text = str(getattr(self, "_text", "") or "")
+ line_count = max(1, text.count("\n") + 1)
+ longest_line = max((len(line) for line in text.splitlines()), default=len(text))
+ if orientation == Gtk.Orientation.HORIZONTAL:
+ minimum_size = 160
+ natural_size = max(minimum_size, min(900, max(220, longest_line * 7)))
+ else:
+ minimum_size = max(72, min(240, line_count * 18))
+ natural_size = max(minimum_size, min(720, line_count * 22))
+ self._logger.debug(
+ "RichText fallback do_measure orientation=%s for_size=%s -> min=%s nat=%s",
+ orientation,
+ for_size,
+ minimum_size,
+ natural_size,
+ )
+ return (minimum_size, natural_size, -1, -1)
+
+ def _create_content(self):
+ # Create the content widget according to mode
+ try:
+ if self._plain:
+ tv = Gtk.TextView()
+ try:
+ tv.set_editable(False)
+ except Exception:
+ self._logger.debug("set_editable failed on Gtk.TextView", exc_info=True)
+ try:
+ tv.set_cursor_visible(False)
+ except Exception:
+ self._logger.debug("set_cursor_visible failed on Gtk.TextView", exc_info=True)
+ try:
+ tv.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
+ except Exception:
+ self._logger.debug("set_wrap_mode failed on Gtk.TextView", exc_info=True)
+ self._content_widget = tv
+ # set initial text
+ try:
+ buf = tv.get_buffer()
+ buf.set_text(self._text)
+ except Exception:
+ self._logger.debug("set_text failed on Gtk.TextBuffer", exc_info=True)
+ else:
+ lbl = Gtk.Label()
+ try:
+ lbl.set_use_markup(True)
+ except Exception:
+ self._logger.debug("set_use_markup failed on Gtk.Label", exc_info=True)
+ # Convert HTML to Pango markup for GTK Label
+ converted = self._html_to_pango_markup(self._text)
+ try:
+ lbl.set_markup(converted)
+ except Exception:
+ self._logger.exception("set_markup failed on Gtk.Label", exc_info=True)
+ lbl.set_text(converted)
+ try:
+ lbl.set_selectable(False) # keep it non-selectable; avoids odd sizing in some themes
+ except Exception:
+ self._logger.exception("set_selectable failed on Gtk.Label", exc_info=True)
+ try:
+ lbl.set_wrap(True)
+ lbl.set_wrap_mode(Pango.WrapMode.WORD_CHAR) # need Pango.WrapMode
+ lbl.set_xalign(0.0)
+ lbl.set_justify(Gtk.Justification.LEFT)
+ except Exception:
+ self._logger.debug("set_justify failed on Gtk.Label", exc_info=True)
+ # connect link activation
+ def _on_activate_link(label, uri):
+ try:
+ self._last_url = uri
+ dlg = self.findDialog()
+ if dlg and self.notify():
+ # emit a MenuEvent for link activation (back-compat)
+ dlg._post_event(YMenuEvent(item=None, id=uri))
+ except Exception:
+ self._logger.debug("activate-link handler failed", exc_info=True)
+ # return True to stop default handling
+ return True
+ try:
+ lbl.connect("activate-link", _on_activate_link)
+ except Exception:
+ self._logger.debug("connect activate-link failed on Gtk.Label", exc_info=True)
+ self._content_widget = lbl
+ except Exception:
+ self._logger.exception("Failed to create content widget", exc_info=True)
+ # fallback to a simple label
+ self._content_widget = Gtk.Label(label=self._text)
+
+ def _create_backend_widget(self):
+ """Create scrolled backend and attach rich text content widget."""
+ sw = _YRichTextMeasureScrolledWindow(self)
+ try:
+ # let size policy decide final expand/align; start with sane defaults
+ sw.set_hexpand(True)
+ sw.set_vexpand(True)
+ sw.set_halign(Gtk.Align.FILL)
+ sw.set_valign(Gtk.Align.FILL)
+ except Exception:
+ self._logger.debug("Failed to set initial expand/align on scrolled window", exc_info=True)
+
+ self._create_content()
+ try:
+ sw.set_child(self._content_widget)
+ except Exception:
+ try:
+ sw.add(self._content_widget)
+ except Exception:
+ self._logger.debug("Failed to attach content to scrolled window", exc_info=True)
+ self._backend_widget = sw
+
+ # Apply consistent size policy after backend and content exist
+ self._apply_size_policy()
+
+ # respect initial enabled state
+ try:
+ self._backend_widget.set_sensitive(bool(self._enabled))
+ except Exception:
+ self._logger.error("Failed to set backend widget sensitive state", exc_info=True)
+ if self._help_text:
+ try:
+ self._backend_widget.set_tooltip_text(self._help_text)
+ except Exception:
+ self._logger.error("Failed to set tooltip text on backend widget", exc_info=True)
+ try:
+ self._backend_widget.set_visible(self.visible())
+ except Exception:
+ self._logger.error("Failed to set backend widget visible", exc_info=True)
+ try:
+ self._backend_widget.set_sensitive(self._enabled)
+ except Exception:
+ self._logger.exception("Failed to set sensitivity on backend widget")
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+
+ def _set_backend_enabled(self, enabled):
+ try:
+ if getattr(self, "_backend_widget", None) is not None:
+ self._backend_widget.set_sensitive(bool(enabled))
+ except Exception:
+ self._logger.exception("Failed to set enabled state", exc_info=True)
+
+ def _html_to_pango_markup(self, s: str) -> str:
+ """Convert a limited subset of HTML into GTK/Pango markup.
+ Supports: h1-h6 (as bold spans with size), b/i/em/u, a href, p/br, ul/li.
+ Unknown tags are stripped.
+ """
+ self._logger.debug("Converted markup: %s", s)
+ if not s:
+ return ""
+ t = s
+ # Normalize newlines for paragraphs and breaks
+ t = re.sub(r"
", "\n", t, flags=re.IGNORECASE)
+ t = re.sub(r"", "\n\n", t, flags=re.IGNORECASE)
+ t = re.sub(r"", "", t, flags=re.IGNORECASE)
+ # Lists
+ t = re.sub(r"
", "\n", t, flags=re.IGNORECASE)
+ t = re.sub(r"
", "\n", t, flags=re.IGNORECASE)
+ t = re.sub(r"", "\n", t, flags=re.IGNORECASE)
+ t = re.sub(r"
", "\n", t, flags=re.IGNORECASE)
+ t = re.sub(r"", "• ", t, flags=re.IGNORECASE)
+ t = re.sub(r"", "\n", t, flags=re.IGNORECASE)
+ # Headings -> bold span with size
+ sizes = {1: "xx-large", 2: "x-large", 3: "large", 4: "medium", 5: "small", 6: "x-small"}
+ for n, sz in sizes.items():
+ t = re.sub(fr"", f"", t, flags=re.IGNORECASE)
+ t = re.sub(fr"", "\n", t, flags=re.IGNORECASE)
+ # Explicitly convert formatting tags to Pango span attributes
+ t = re.sub(r"", "", t, flags=re.IGNORECASE)
+ t = re.sub(r"", "", t, flags=re.IGNORECASE)
+ t = re.sub(r"", "", t, flags=re.IGNORECASE)
+ t = re.sub(r"", "", t, flags=re.IGNORECASE)
+ t = re.sub(r"", "", t, flags=re.IGNORECASE)
+ t = re.sub(r"", "", t, flags=re.IGNORECASE)
+ t = re.sub(r"", "", t, flags=re.IGNORECASE)
+ t = re.sub(r"", "", t, flags=re.IGNORECASE)
+ # Allow basic formatting tags and ; strip all other tags
+ t = re.sub(r"?(?!span\b|a\b)[a-zA-Z0-9]+\b[^>]*>", "", t)
+
+ self._logger.debug("Converted markup: %s", t)
+ return t
+
+ def setVisible(self, visible=True):
+ super().setVisible(visible)
+ try:
+ if getattr(self, "_backend_widget", None) is not None:
+ self._backend_widget.set_visible(visible)
+ except Exception:
+ self._logger.exception("setVisible failed", exc_info=True)
+
+ def setHelpText(self, help_text: str):
+ super().setHelpText(help_text)
+ try:
+ if getattr(self, "_backend_widget", None) is not None:
+ self._backend_widget.set_tooltip_text(help_text)
+ except Exception:
+ self._logger.exception("setHelpText failed", exc_info=True)
diff --git a/manatools/aui/backends/gtk/selectionboxgtk.py b/manatools/aui/backends/gtk/selectionboxgtk.py
new file mode 100644
index 0000000..fae1d61
--- /dev/null
+++ b/manatools/aui/backends/gtk/selectionboxgtk.py
@@ -0,0 +1,695 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.gtk contains all GTK backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.gtk
+'''
+
+import gi
+gi.require_version('Gtk', '4.0')
+gi.require_version('Gdk', '4.0')
+from gi.repository import Gtk, Gdk, GObject, GdkPixbuf, GLib
+import cairo
+import threading
+import os
+from typing import Optional
+import logging
+from ...yui_common import *
+from .commongtk import _resolve_icon
+
+
+class YSelectionBoxGtk(YSelectionWidget):
+ def __init__(self, parent=None, label="", multi_selection: Optional[bool] = False):
+ super().__init__(parent)
+ self._label = label
+ self._value = ""
+ self._old_selected_items = [] # for change detection
+ self._multi_selection = bool(multi_selection)
+ self._listbox = None
+ self._backend_widget = None
+ # keep a stable list of rows we create so we don't rely on ListBox container APIs
+ # (GTK4 bindings may not expose get_children())
+ self._rows = []
+ # Preferred visible rows for layout/paging; parent can give more space when stretchable
+ self._preferred_rows = 6
+ self.setStretchable(YUIDimension.YD_HORIZ, True)
+ self.setStretchable(YUIDimension.YD_VERT, True)
+ self._logger = logging.getLogger(f"manatools.aui.gtk.{self.__class__.__name__}")
+
+ def widgetClass(self):
+ return "YSelectionBox"
+
+ def label(self):
+ return self._label
+
+ def value(self):
+ return self._value
+
+ def selectedItems(self):
+ return list(self._selected_items)
+
+ def selectItem(self, item, selected=True):
+ """Select or deselect a specific item"""
+ if self.multiSelection():
+ if selected:
+ if item not in self._selected_items:
+ self._selected_items.append(item)
+ else:
+ if item in self._selected_items:
+ self._selected_items.remove(item)
+ else:
+ old_selected = self._selected_items[0] if self._selected_items else None
+ if selected:
+ if old_selected is not None:
+ old_selected.setSelected(False)
+ self._selected_items = [item]
+ idx = self._items.index( self._selected_items[0] )
+ row = self._rows[idx]
+ if self._listbox is not None:
+ self._listbox.select_row( row )
+ else:
+ self._selected_items = []
+
+ item.setSelected(bool(selected))
+ self._value = self._selected_items[0].label() if self._selected_items else None
+
+ def setMultiSelection(self, enabled):
+ self._multi_selection = bool(enabled)
+ old_selected = list(self._selected_items)
+ # If listbox already created, update its selection mode at runtime.
+ if self._listbox is None:
+ return
+ try:
+ mode = Gtk.SelectionMode.MULTIPLE if self._multi_selection else Gtk.SelectionMode.SINGLE
+ self._listbox.set_selection_mode(mode)
+ except Exception:
+ pass
+ # Rewire signals: disconnect previous handlers and connect appropriate one.
+ try:
+ # Disconnect any previously stored handlers
+ try:
+ for key, hid in list(getattr(self, "_signal_handlers", {}).items()):
+ if hid and isinstance(hid, int):
+ try:
+ self._listbox.disconnect(hid)
+ except Exception:
+ pass
+ self._signal_handlers = {}
+ except Exception:
+ self._signal_handlers = {}
+
+ # Connect new handler based on mode
+ if self._multi_selection:
+ try:
+ hid = self._listbox.connect("selected-rows-changed", lambda lb: self._on_selected_rows_changed(lb))
+ self._signal_handlers['selected-rows-changed'] = hid
+ hid = self._listbox.connect("row-activated", lambda lb, row: self._on_row_selected_for_multi(lb, row))
+ self._signal_handlers['row-selected_for_multi'] = hid
+ except Exception:
+ pass
+ else:
+ try:
+ hid = self._listbox.connect("row-selected", lambda lb, row: self._on_row_selected(lb, row))
+ self._signal_handlers['row-selected'] = hid
+ except Exception:
+ pass
+
+ selected_item = old_selected[0] if old_selected else None
+ if selected_item:
+ if len(old_selected) > 1:
+ for it in old_selected[1:]:
+ it.setSelected(False)
+ self._selected_items = [selected_item]
+ self._value = self._selected_items[0].label() if self._selected_items else ""
+ if self._selected_items and self._listbox:
+ try:
+ idx = self._items.index( self._selected_items[0] )
+ row = self._rows[idx]
+ self._listbox.select_row( row )
+ except Exception:
+ pass
+ self._logger.debug("setMultiSelection: mode set to %s - value=%r", "MULTIPLE" if self._multi_selection else "SINGLE", self._value)
+ except Exception:
+ self._logger.error("setMultiSelection: failed in multi-selection update", exc_info=True)
+ pass
+
+ def multiSelection(self):
+ return bool(self._multi_selection)
+
+ def _create_backend_widget(self):
+ vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5)
+ if self._label:
+ lbl = Gtk.Label(label=self._label)
+ try:
+ if hasattr(lbl, "set_xalign"):
+ lbl.set_xalign(0.0)
+ except Exception:
+ pass
+ try:
+ vbox.append(lbl)
+ except Exception:
+ vbox.add(lbl)
+
+ # Use Gtk.ListBox inside a ScrolledWindow for Gtk4
+ listbox = Gtk.ListBox()
+ listbox.set_selection_mode(Gtk.SelectionMode.MULTIPLE if self._multi_selection else Gtk.SelectionMode.SINGLE)
+ # allow listbox to expand if parent allocates more space (only when logical stretch/weight allows)
+ try:
+ try:
+ vexpand_flag = bool(self.stretchable(YUIDimension.YD_VERT)) or bool(int(self.weight(YUIDimension.YD_VERT)))
+ except Exception:
+ vexpand_flag = bool(self.stretchable(YUIDimension.YD_VERT))
+ try:
+ hexpand_flag = bool(self.stretchable(YUIDimension.YD_HORIZ)) or bool(int(self.weight(YUIDimension.YD_HORIZ)))
+ except Exception:
+ hexpand_flag = bool(self.stretchable(YUIDimension.YD_HORIZ))
+ listbox.set_vexpand(vexpand_flag)
+ listbox.set_hexpand(hexpand_flag)
+ except Exception:
+ pass
+ # populate rows
+ self._rows = []
+ for it in self._items:
+ row = Gtk.ListBoxRow()
+ # build a horizontal container to hold optional icon + label
+ try:
+ widget_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
+ except Exception:
+ widget_box = Gtk.Box()
+
+ # try to resolve icon for this item
+ icon_name = None
+ try:
+ fn = getattr(it, 'iconName', None)
+ if callable(fn):
+ icon_name = fn()
+ else:
+ icon_name = fn
+ except Exception:
+ icon_name = None
+
+ img = None
+ try:
+ img = _resolve_icon(icon_name, size=16)
+ except Exception:
+ img = None
+
+ lbl = Gtk.Label(label=it.label() or "")
+ try:
+ if hasattr(lbl, "set_xalign"):
+ lbl.set_xalign(0.0)
+ except Exception:
+ pass
+
+ try:
+ if img is not None:
+ try:
+ widget_box.append(img)
+ except Exception:
+ try:
+ widget_box.add(img)
+ except Exception:
+ pass
+ # append label after icon (or alone)
+ try:
+ widget_box.append(lbl)
+ except Exception:
+ try:
+ widget_box.add(lbl)
+ except Exception:
+ pass
+ except Exception:
+ # worst case: fall back to label only on the row
+ try:
+ row.set_child(lbl)
+ except Exception:
+ try:
+ row.add(lbl)
+ except Exception:
+ pass
+
+ # attach widget_box into the row if not already attached
+ try:
+ row.set_child(widget_box)
+ except Exception:
+ try:
+ row.add(widget_box)
+ except Exception:
+ # if that fails, ensure label at least
+ try:
+ row.set_child(lbl)
+ except Exception:
+ try:
+ row.add(lbl)
+ except Exception:
+ pass
+
+ # Make every row selectable so users can multi-select if mode allows.
+ try:
+ row.set_selectable(True)
+ except Exception:
+ pass
+
+ # If this item matches current value, mark selected
+ try:
+ if self._value and it.label() == self._value:
+ row.set_selectable(True)
+ row.set_selected(True)
+ except Exception:
+ pass
+ self._rows.append(row)
+ listbox.append(row)
+
+ # Reflect items' selected flags into the view/model.
+ try:
+ if self._multi_selection:
+ for idx, it in enumerate(self._items):
+ try:
+ if it.selected():
+ try:
+ listbox.select_row( self._rows[idx] )
+ except Exception:
+ pass
+ if it not in self._selected_items:
+ self._selected_items.append(it)
+ if not self._value:
+ self._value = it.label()
+ except Exception:
+ pass
+ else:
+ last_selected_idx = None
+ for idx, it in enumerate(self._items):
+ try:
+ if it.selected():
+ last_selected_idx = idx
+ except Exception:
+ pass
+ if last_selected_idx is not None:
+ try:
+ for i, r in enumerate(self._rows):
+ try:
+ if i == last_selected_idx:
+ listbox.select_row( r )
+ except Exception:
+ try:
+ setattr(r, '_selected_flag', (i == last_selected_idx))
+ except Exception:
+ pass
+ try:
+ for i, it in enumerate(self._items):
+ try:
+ it.setSelected(i == last_selected_idx)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ self._selected_items = [self._items[last_selected_idx]]
+ self._value = self._items[last_selected_idx].label()
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ sw = Gtk.ScrolledWindow()
+ # allow scrolled window to expand vertically and horizontally only when allowed
+ try:
+ sw.set_vexpand(vexpand_flag)
+ sw.set_hexpand(hexpand_flag)
+ # give a reasonable minimum content height so layout initially shows several rows;
+ # Gtk4 expects pixels — try a conservative estimate (rows * ~20px)
+ min_h = int(getattr(self, "_preferred_rows", 6) * 20)
+ try:
+ sw.set_min_content_height(min_h)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ # policy APIs changed in Gtk4: use set_overlay_scrolling and set_min_content_height if needed
+ try:
+ sw.set_child(listbox)
+ except Exception:
+ try:
+ sw.add(listbox)
+ except Exception:
+ pass
+
+ # also request vexpand on the outer vbox so parent layout sees it can grow
+ try:
+ vbox.set_vexpand(vexpand_flag)
+ vbox.set_hexpand(hexpand_flag)
+ except Exception:
+ pass
+
+ try:
+ vbox.append(sw)
+ except Exception:
+ vbox.add(sw)
+
+ self._backend_widget = vbox
+ self._listbox = listbox
+ # connect selection signal: choose appropriate signal per selection mode
+ # if multi-selection has been set before widget creation, ensure correct mode
+ self.setMultiSelection( self._multi_selection )
+ self._backend_widget.set_sensitive(self._enabled)
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+
+ def _set_backend_enabled(self, enabled):
+ """Enable/disable the selection box and its listbox/rows."""
+ try:
+ if getattr(self, "_listbox", None) is not None:
+ try:
+ self._listbox.set_sensitive(enabled)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ try:
+ if self._backend_widget is not None:
+ try:
+ self._backend_widget.set_sensitive(enabled)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ # propagate logical enabled state to child items/widgets
+ try:
+ for c in list(getattr(self, "_rows", []) or []):
+ try:
+ c.set_sensitive(enabled)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def _row_is_selected(self, r):
+ """Robust helper to detect whether a ListBoxRow is selected."""
+ try:
+ return bool(r.get_selected())
+ except Exception:
+ pass
+ try:
+ props = getattr(r, "props", None)
+ if props and hasattr(props, "selected"):
+ return bool(getattr(props, "selected"))
+ except Exception:
+ pass
+ return bool(getattr(r, "_selected_flag", False))
+
+ def _on_row_selected(self, listbox, row):
+ """
+ Handler for row selection. In single-selection mode behaves as before
+ (select provided row and deselect others). In multi-selection mode toggles
+ the provided row and rebuilds the selected items list.
+ """
+ try:
+ # rebuild selected_items scanning cached rows (works for both modes)
+ old_item = self._selected_items[0] if self._selected_items else None
+ if old_item:
+ old_item.setSelected( False )
+ idx = self._rows.index(row)
+ self._selected_items = []
+ self._selected_items.append(self._items[idx])
+ self._items[idx].setSelected( True )
+ self._value = self._selected_items[0].label() if self._selected_items else None
+ except Exception:
+ try:
+ self._logger.error("SelectionBoxGTK: failed to process row-selected event", exc_info=True)
+ except Exception:
+ pass
+ # be defensive
+ self._selected_items = []
+ self._value = None
+
+ if self.notify():
+ dlg = self.findDialog()
+ if dlg is not None:
+ dlg._post_event(YWidgetEvent(self, YEventReason.SelectionChanged))
+
+ def _on_row_selected_for_multi(self, listbox, row):
+ """
+ Handler for row selection in multi-selection mode: for de-selection.
+ """
+ sel_rows = listbox.get_selected_rows()
+ idx = self._rows.index(row)
+ if self._items[idx] in self._old_selected_items:
+ self._listbox.unselect_row( row )
+ self._items[idx].setSelected( False )
+ self._on_selected_rows_changed(listbox)
+ else:
+ self._old_selected_items = self._selected_items
+
+
+ def _on_selected_rows_changed(self, listbox):
+ """
+ Handler for multi-selection (or bulk selection change). Rebuild selected list
+ using either ListBox APIs (if available) or by scanning cached rows.
+ """
+ try:
+ # Try to use any available API that returns selected rows
+ sel_rows = None
+ try:
+ # Some bindings may provide get_selected_rows()
+ sel_rows = listbox.get_selected_rows()
+ try:
+ self._logger.debug("Using get_selected_rows() API, count=%d", len(sel_rows))
+ except Exception:
+ pass
+ except Exception:
+ sel_rows = None
+
+ self._old_selected_items = self._selected_items
+ self._selected_items = []
+ if sel_rows:
+ # sel_rows may be list of Row objects or Paths; try to match by identity
+ for r in sel_rows:
+ try:
+ # if r is a ListBoxRow already
+ if isinstance(r, type(self._rows[0])) if self._rows else False:
+ try:
+ idx = self._rows.index(r)
+ if idx < len(self._items):
+ self._selected_items.append(self._items[idx])
+ self._items[idx].setSelected( True )
+ except Exception:
+ pass
+ else:
+ # fallback: scan cached rows to find selected ones
+ for i, cr in enumerate(getattr(self, "_rows", [])):
+ try:
+ if self._row_is_selected(cr) and i < len(self._items):
+ self._selected_items.append(self._items[i])
+ self._items[i].setSelected( True )
+ except Exception:
+ pass
+ except Exception:
+ pass
+ else:
+ # Generic fallback: scan cached rows and collect selected ones
+ for i, r in enumerate(getattr(self, "_rows", [])):
+ try:
+ if self._row_is_selected(r) and i < len(self._items):
+ self._selected_items.append(self._items[i])
+ except Exception:
+ pass
+
+ self._value = self._selected_items[0].label() if self._selected_items else None
+ except Exception:
+ self._selected_items = []
+ self._value = None
+
+ if self.notify():
+ dlg = self.findDialog()
+ if dlg is not None:
+ dlg._post_event(YWidgetEvent(self, YEventReason.SelectionChanged))
+
+
+ def deleteAllItems(self):
+ """Remove all items from the selection box (model + GTK view)."""
+ # Clear internal model
+ super().deleteAllItems()
+ self._value = ""
+ self._selected_items = []
+
+ # Clear GTK rows/listbox
+ try:
+ rows = list(getattr(self, '_rows', []) or [])
+ for r in rows:
+ try:
+ if getattr(self, '_listbox', None) is not None:
+ try:
+ self._listbox.remove(r)
+ except Exception:
+ try:
+ # fallback: unparent the row
+ r.unparent()
+ except Exception:
+ pass
+ except Exception:
+ pass
+ self._rows = []
+ except Exception:
+ pass
+
+ def addItem(self, item):
+ """Add a single item to the selection box (model + GTK view)."""
+ super().addItem(item)
+ try:
+ new_item = self._items[-1]
+ except Exception:
+ return
+
+ try:
+ new_item.setIndex(len(self._items) - 1)
+ except Exception:
+ pass
+
+ # If listbox exists, create a new row and append
+ try:
+ if getattr(self, '_listbox', None) is not None:
+ row = Gtk.ListBoxRow()
+ # build widget with optional icon + label
+ try:
+ widget_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
+ except Exception:
+ widget_box = Gtk.Box()
+
+ icon_name = None
+ try:
+ fn = getattr(new_item, 'iconName', None)
+ if callable(fn):
+ icon_name = fn()
+ else:
+ icon_name = fn
+ except Exception:
+ icon_name = None
+
+ img = None
+ try:
+ img = _resolve_icon(icon_name, size=16)
+ except Exception:
+ img = None
+
+ lbl = Gtk.Label(label=new_item.label() or "")
+ try:
+ if hasattr(lbl, "set_xalign"):
+ lbl.set_xalign(0.0)
+ except Exception:
+ pass
+
+ try:
+ if img is not None:
+ try:
+ widget_box.append(img)
+ except Exception:
+ try:
+ widget_box.add(img)
+ except Exception:
+ pass
+ try:
+ widget_box.append(lbl)
+ except Exception:
+ try:
+ widget_box.add(lbl)
+ except Exception:
+ pass
+ except Exception:
+ try:
+ row.set_child(lbl)
+ except Exception:
+ try:
+ row.add(lbl)
+ except Exception:
+ pass
+
+ try:
+ row.set_child(widget_box)
+ except Exception:
+ try:
+ row.add(widget_box)
+ except Exception:
+ try:
+ row.set_child(lbl)
+ except Exception:
+ try:
+ row.add(lbl)
+ except Exception:
+ pass
+
+ try:
+ row.set_selectable(True)
+ except Exception:
+ pass
+
+ # append the row first so selection APIs operate on attached rows
+ try:
+ try:
+ self._listbox.append(row)
+ except Exception:
+ try:
+ self._listbox.add(row)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ try:
+ self._rows.append(row)
+ except Exception:
+ pass
+
+ # reflect selected state (no notification on add)
+ try:
+ if new_item.selected():
+ # single-selection: clear previous selections
+ if not self._multi_selection:
+ try:
+ for r in getattr(self, '_rows', []):
+ try:
+ self._listbox.unselect_row( r )
+ #r.set_selected(False)
+ except Exception:
+ try:
+ self._logger.error("Failed to deselect row", exc_info=True)
+ except Exception:
+ pass
+ try:
+ setattr(r, '_selected_flag', False)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ try:
+ for it in self._items[:-1]:
+ try:
+ it.setSelected(False)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ try:
+ self._listbox.select_row( row )
+ except Exception:
+ try:
+ setattr(row, '_selected_flag', True)
+ except Exception:
+ pass
+
+ try:
+ new_item.setSelected(True)
+ except Exception:
+ pass
+
+ if new_item not in self._selected_items:
+ self._selected_items.append(new_item)
+ if not self._value:
+ self._value = new_item.label()
+ except Exception:
+ pass
+ except Exception:
+ pass
+
diff --git a/manatools/aui/backends/gtk/slidergtk.py b/manatools/aui/backends/gtk/slidergtk.py
new file mode 100644
index 0000000..238469d
--- /dev/null
+++ b/manatools/aui/backends/gtk/slidergtk.py
@@ -0,0 +1,125 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.gtk contains all GTK backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.gtk
+'''
+"""
+GTK4 backend Slider widget.
+- Horizontal Gtk.Scale synchronized with Gtk.SpinButton via Gtk.Adjustment.
+- Emits ValueChanged on changes and Activated on user release (value-changed).
+- Default stretchable horizontally.
+"""
+import gi
+gi.require_version('Gtk', '4.0')
+from gi.repository import Gtk, GLib
+import logging
+from ...yui_common import *
+
+class YSliderGtk(YWidget):
+ def __init__(self, parent=None, label: str = "", minVal: int = 0, maxVal: int = 100, initialVal: int = 0):
+ super().__init__(parent)
+ self._logger = logging.getLogger(f"manatools.aui.gtk.{self.__class__.__name__}")
+ self._label_text = str(label) if label else ""
+ self._min = int(minVal)
+ self._max = int(maxVal)
+ if self._min > self._max:
+ self._min, self._max = self._max, self._min
+ self._value = max(self._min, min(self._max, int(initialVal)))
+ self.setStretchable(YUIDimension.YD_HORIZ, True)
+ self.setStretchable(YUIDimension.YD_VERT, False)
+ self._backend_widget = None
+ self._adjustment = None
+
+ def widgetClass(self):
+ return "YSlider"
+
+ def value(self) -> int:
+ return int(self._value)
+
+ def setValue(self, v: int):
+ prev = self._value
+ self._value = max(self._min, min(self._max, int(v)))
+ try:
+ if self._adjustment is not None:
+ self._adjustment.set_value(self._value)
+ except Exception:
+ pass
+ if self._value != prev and self.notify():
+ dlg = self.findDialog()
+ if dlg is not None:
+ dlg._post_event(YWidgetEvent(self, YEventReason.ValueChanged))
+
+ def _create_backend_widget(self):
+ try:
+ box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
+ try:
+ box.set_hexpand(True)
+ box.set_vexpand(False)
+ except Exception:
+ pass
+ if self._label_text:
+ lbl = Gtk.Label.new(self._label_text)
+ try:
+ lbl.set_xalign(0.0)
+ except Exception:
+ pass
+ box.append(lbl)
+
+ adj = Gtk.Adjustment.new(self._value, self._min, self._max, 1, 10, 0)
+ scale = Gtk.Scale.new(Gtk.Orientation.HORIZONTAL, adj)
+ try:
+ scale.set_hexpand(True)
+ scale.set_vexpand(False)
+ except Exception:
+ pass
+ spin = Gtk.SpinButton.new(adj, 1, 0)
+ try:
+ spin.set_numeric(True)
+ except Exception:
+ pass
+
+ box.append(scale)
+ box.append(spin)
+
+ def _on_value_changed(widget):
+ try:
+ val = int(adj.get_value())
+ except Exception:
+ val = self._value
+ old = self._value
+ self._value = val
+ if self.notify() and old != self._value:
+ dlg = self.findDialog()
+ if dlg is not None:
+ dlg._post_event(YWidgetEvent(self, YEventReason.ValueChanged))
+ try:
+ scale.connect('value-changed', _on_value_changed)
+ spin.connect('value-changed', _on_value_changed)
+ except Exception:
+ pass
+
+ self._backend_widget = box
+ self._adjustment = adj
+ self._backend_widget.set_sensitive(self._enabled)
+ try:
+ self._logger.debug("_create_backend_widget: <%s> range=[%d,%d] value=%d", self.debugLabel(), self._min, self._max, self._value)
+ except Exception:
+ pass
+ except Exception:
+ try:
+ self._logger.exception("YSliderGtk _create_backend_widget failed")
+ except Exception:
+ pass
+
+ def _set_backend_enabled(self, enabled):
+ try:
+ if getattr(self, "_backend_widget", None) is not None:
+ self._backend_widget.set_sensitive(bool(enabled))
+ except Exception:
+ pass
diff --git a/manatools/aui/backends/gtk/spacinggtk.py b/manatools/aui/backends/gtk/spacinggtk.py
new file mode 100644
index 0000000..dfbd7c8
--- /dev/null
+++ b/manatools/aui/backends/gtk/spacinggtk.py
@@ -0,0 +1,107 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+GTK backend: YSpacing implementation using an empty Gtk.Box with size requests.
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.gtk
+'''
+import gi
+gi.require_version('Gtk', '4.0')
+gi.require_version('Gdk', '4.0')
+from gi.repository import Gtk
+import logging
+from ...yui_common import *
+
+
+class YSpacingGtk(YWidget):
+ """Spacing/Stretch widget for GTK.
+
+ - `dim`: primary dimension (horizontal or vertical) where spacing applies
+ - `stretchable`: whether the spacing expands in its primary dimension
+ - `size`: spacing size in pixels (device units). Pixels are used for clarity
+ and uniformity across backends; GTK honors `set_size_request` in pixels.
+ """
+ def __init__(self, parent=None, dim: YUIDimension = YUIDimension.YD_HORIZ, stretchable: bool = False, size_px: int = 0):
+ super().__init__(parent)
+ self._dim = dim
+ self._stretchable = bool(stretchable)
+ try:
+ spx = int(size_px)
+ self._size_px = 0 if spx <= 0 else max(1, spx)
+ except Exception:
+ self._size_px = 0
+ self._logger = logging.getLogger(f"manatools.aui.gtk.{self.__class__.__name__}")
+ # Keep base stretch flags in sync for layout containers
+ try:
+ self.setStretchable(self._dim, self._stretchable)
+ except Exception:
+ pass
+ try:
+ self._logger.debug("%s.__init__(dim=%s, stretchable=%s, size_px=%d)", self.__class__.__name__, self._dim, self._stretchable, self._size_px)
+ except Exception:
+ pass
+
+ def widgetClass(self):
+ return "YSpacing"
+
+ def dimension(self):
+ return self._dim
+
+ def size(self):
+ return self._size_px
+
+ def sizeDim(self, dim: YUIDimension):
+ return self._size_px if dim == self._dim else 0
+
+ def _create_backend_widget(self):
+ try:
+ box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
+ # size request in the primary dimension
+ if self._dim == YUIDimension.YD_HORIZ:
+ try:
+ if self._stretchable:
+ box.set_hexpand(True)
+ box.set_halign(Gtk.Align.FILL)
+ box.set_size_request(self._size_px, -1)
+ else:
+ box.set_hexpand(False)
+ box.set_halign(Gtk.Align.CENTER)
+ box.set_size_request(self._size_px, -1)
+ box.set_vexpand(False)
+ box.set_valign(Gtk.Align.CENTER)
+ except Exception:
+ pass
+ else:
+ try:
+ if self._stretchable:
+ box.set_vexpand(True)
+ box.set_valign(Gtk.Align.FILL)
+ box.set_size_request(-1, self._size_px)
+ else:
+ box.set_vexpand(False)
+ box.set_valign(Gtk.Align.CENTER)
+ box.set_size_request(-1, self._size_px)
+ box.set_hexpand(False)
+ box.set_halign(Gtk.Align.CENTER)
+ except Exception:
+ pass
+ self._backend_widget = box
+ self._backend_widget.set_sensitive(self._enabled)
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception:
+ try:
+ self._logger.exception("Failed to create YSpacingGtk backend")
+ except Exception:
+ pass
+ self._backend_widget = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
+
+ def _set_backend_enabled(self, enabled):
+ try:
+ if self._backend_widget is not None:
+ self._backend_widget.set_sensitive(bool(enabled))
+ except Exception:
+ pass
diff --git a/manatools/aui/backends/gtk/tablegtk.py b/manatools/aui/backends/gtk/tablegtk.py
new file mode 100644
index 0000000..86fa53a
--- /dev/null
+++ b/manatools/aui/backends/gtk/tablegtk.py
@@ -0,0 +1,966 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+"""
+GTK backend for YTable using Gtk4.
+
+Renders a header row via Gtk.Grid and rows via Gtk.ListBox. Supports:
+- Column headers from `YTableHeader.header()`
+- Column alignment from `YTableHeader.alignment()`
+- Checkbox columns declared via `YTableHeader.isCheckboxColumn()`
+- Selection driven by `YTableItem.selected()`; emits SelectionChanged on change
+- Checkbox toggles emit ValueChanged and update `YTableCell.checked()`
+- Resizable columns with drag handles between headers
+- Column alignment between header and rows
+
+Sorting UI is not implemented; if needed we can add clickable headers.
+"""
+
+import gi
+gi.require_version('Gtk', '4.0')
+gi.require_version('Gdk', '4.0')
+from gi.repository import Gtk, GLib, Gdk, Pango
+import logging
+from ...yui_common import *
+
+
+class YTableGtk(YSelectionWidget):
+ """
+ GTK4 implementation of YTable with resizable columns and proper alignment.
+
+ Features:
+ - Column resizing via draggable header separators
+ - Uniform column widths across header and all rows
+ - Checkbox column support with proper alignment
+ - Single/Multi selection modes
+ """
+
+ def __init__(self, parent=None, header: YTableHeader = None, multiSelection=False):
+ super().__init__(parent)
+ if header is None:
+ raise ValueError("YTableGtk requires a YTableHeader")
+ self._header = header
+ self._multi = bool(multiSelection)
+
+ # Force single-selection if any checkbox columns present
+ try:
+ for c_idx in range(self._header.columns()):
+ if self._header.isCheckboxColumn(c_idx):
+ self._multi = False
+ break
+ except Exception:
+ pass
+
+ self._backend_widget = None
+ self._header_box = None
+ self._listbox = None
+ self._row_to_item = {}
+ self._item_to_row = {}
+ self._rows = []
+ self._suppress_selection_handler = False
+ self._suppress_item_change = False
+ self._logger = logging.getLogger(f"manatools.aui.gtk.{self.__class__.__name__}")
+ self._old_selected_items = []
+ self._changed_item = None
+
+ # Column width management
+ self._column_widths = []
+ self._min_column_width = 50
+ self._default_column_width = 100
+ self._drag_data = None
+ self._header_cells = []
+ self._header_labels = [] # Store header labels separately
+
+ # Initialize column widths
+ self._init_column_widths()
+
+ def widgetClass(self):
+ return "YTable"
+
+ def _create_backend_widget(self):
+ """
+ Create the GTK widget hierarchy for the table.
+
+ Structure:
+ - Main vertical box (vbox)
+ - Header box with column labels and resize handles
+ - Separator
+ - ScrolledWindow with ListBox for rows
+ """
+ vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
+
+ # Create header with resizable columns
+ self._create_header()
+
+ # Horizontal separator between header and content
+ separator = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
+
+ # ListBox inside ScrolledWindow for rows
+ self._create_listbox()
+
+ sw = Gtk.ScrolledWindow()
+ try:
+ sw.set_child(self._listbox)
+ except Exception:
+ try:
+ sw.add(self._listbox)
+ except Exception:
+ pass
+
+ # Set expansion properties
+ try:
+ vbox.set_hexpand(self.stretchable(YUIDimension.YD_HORIZ))
+ vbox.set_vexpand(self.stretchable(YUIDimension.YD_VERT))
+ vbox.set_valign(Gtk.Align.FILL)
+ self._listbox.set_hexpand(True)
+ self._listbox.set_vexpand(True)
+ self._listbox.set_valign(Gtk.Align.FILL)
+ sw.set_hexpand(True)
+ sw.set_vexpand(True)
+ sw.set_valign(Gtk.Align.FILL)
+ except Exception:
+ pass
+
+ self._backend_widget = vbox
+
+ # Assemble the widget
+ try:
+ vbox.append(self._header_box)
+ vbox.append(separator)
+ vbox.append(sw)
+ except Exception:
+ try:
+ vbox.add(self._header_box)
+ vbox.add(separator)
+ vbox.add(sw)
+ except Exception:
+ pass
+
+ # Apply initial state
+ self._apply_initial_state()
+
+ # Connect selection handlers
+ self._connect_selection_handlers()
+
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+
+ # Populate if items exist
+ try:
+ if getattr(self, "_items", None):
+ self.rebuildTable()
+ except Exception:
+ self._logger.exception("rebuildTable failed during _create_backend_widget")
+
+ def _init_column_widths(self):
+ """Initialize column widths based on header content."""
+ try:
+ cols = self._header.columns()
+ self._column_widths = [self._default_column_width] * cols
+
+ # Adjust based on header text length
+ for col in range(cols):
+ try:
+ header_text = self._header.header(col)
+ if header_text:
+ # Estimate width based on text length
+ text_width = len(header_text) * 8 + 20 # Rough estimate
+ self._column_widths[col] = max(text_width, self._min_column_width)
+ except Exception:
+ pass
+ except Exception:
+ self._column_widths = []
+
+ def _create_header(self):
+ """
+ Create header with column labels and resizable separators.
+
+ Each column has:
+ - Label with proper alignment
+ - Resizable separator (except last column)
+ """
+ self._header_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
+ self._header_cells = []
+ self._header_labels = []
+
+ try:
+ cols = self._header.columns()
+ except Exception:
+ cols = 0
+
+ for col in range(cols):
+ # Create container for this column header
+ col_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
+ col_box.set_hexpand(False)
+
+ # Apply column width
+ width = self._get_column_width(col)
+ col_box.set_size_request(width, -1)
+
+ # Create label with proper alignment
+ try:
+ txt = self._header.header(col)
+ except Exception:
+ txt = ""
+
+ lbl = Gtk.Label(label=txt)
+ lbl.set_halign(Gtk.Align.START)
+ lbl.set_margin_start(5)
+ lbl.set_margin_end(5)
+ lbl.set_hexpand(True)
+
+ # Apply alignment to header label
+ try:
+ align = self._header.alignment(col)
+ if align == YAlignmentType.YAlignCenter:
+ lbl.set_xalign(0.5)
+ elif align == YAlignmentType.YAlignEnd:
+ lbl.set_xalign(1.0)
+ else:
+ lbl.set_xalign(0.0)
+ except Exception:
+ lbl.set_xalign(0.0)
+
+ self._header_labels.append(lbl)
+
+ # Add separator for resizing (except last column)
+ if col < cols - 1:
+ # Create separator container
+ sep_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
+
+ # Create draggable handle
+ sep_handle = Gtk.Box()
+ sep_handle.set_size_request(8, -1)
+ sep_handle.get_style_context().add_class("col-sep-handle")
+
+ # Add event controllers for dragging
+ motion_ctrl = Gtk.EventControllerMotion.new()
+ drag_ctrl = Gtk.GestureDrag.new()
+
+ # Store column index
+ sep_handle.column_index = col
+ sep_container.column_index = col
+
+ # Connect signals
+ motion_ctrl.connect("enter", self._on_separator_enter)
+ motion_ctrl.connect("leave", self._on_separator_leave)
+ drag_ctrl.connect("drag-begin", self._on_drag_begin)
+ drag_ctrl.connect("drag-update", self._on_drag_update)
+ drag_ctrl.connect("drag-end", self._on_drag_end)
+
+ sep_handle.add_controller(motion_ctrl)
+ sep_handle.add_controller(drag_ctrl)
+
+ # Create visual separator
+ sep = Gtk.Separator(orientation=Gtk.Orientation.VERTICAL)
+ sep.set_margin_end(5)
+
+ sep_container.append(sep_handle)
+ sep_container.append(sep)
+
+ col_box.append(lbl)
+ col_box.append(sep_container)
+ else:
+ col_box.append(lbl)
+
+ self._header_box.append(col_box)
+ self._header_cells.append(col_box)
+
+ # Setup CSS for styling
+ self._setup_header_css()
+
+ def _setup_header_css(self):
+ """Setup CSS for header styling."""
+ css_provider = Gtk.CssProvider()
+ css = """
+ /* Style for column separator handles */
+ .col-sep-handle {
+ background-color: transparent;
+ min-width: 8px;
+ }
+
+ .col-sep-handle:hover {
+ background-color: alpha(@theme_fg_color, 0.2);
+ }
+
+ /* Style for header */
+ .y-table-header {
+ padding: 4px 0px;
+ background-color: @theme_bg_color;
+ border-bottom: 1px solid @borders;
+ }
+
+ .y-table-header label {
+ padding: 4px 8px;
+ font-weight: bold;
+ }
+
+ /* Style for table rows */
+ .y-table-row {
+ border-bottom: 1px solid alpha(@theme_fg_color, 0.1);
+ }
+
+ .y-table-row:hover {
+ background-color: alpha(@theme_selected_bg_color, 0.1);
+ }
+ """
+
+ try:
+ try:
+ css_provider.load_from_data(css, -1)
+ except TypeError:
+ css_provider.load_from_data(css.encode())
+
+ # Add CSS class to header
+ ctx = self._header_box.get_style_context()
+ ctx.add_class("y-table-header")
+
+ # Apply provider
+ display = Gdk.Display.get_default()
+ if display:
+ Gtk.StyleContext.add_provider_for_display(
+ display,
+ css_provider,
+ Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
+ )
+ except Exception as e:
+ self._logger.debug("CSS setup failed: %s", str(e))
+
+ def _create_listbox(self):
+ """Create the ListBox for table rows."""
+ self._listbox = Gtk.ListBox()
+ try:
+ mode = Gtk.SelectionMode.MULTIPLE if self._multi else Gtk.SelectionMode.SINGLE
+ self._listbox.set_selection_mode(mode)
+ except Exception:
+ self._logger.exception("Failed to set selection mode on ListBox")
+
+ def _apply_initial_state(self):
+ """Apply initial widget state (enabled, tooltip, visibility)."""
+ # Enabled state
+ try:
+ if hasattr(self._backend_widget, "set_sensitive"):
+ self._backend_widget.set_sensitive(bool(getattr(self, "_enabled", True)))
+ if hasattr(self._listbox, "set_sensitive"):
+ self._listbox.set_sensitive(bool(getattr(self, "_enabled", True)))
+ except Exception:
+ pass
+
+ # Help text (tooltip)
+ try:
+ if getattr(self, "_help_text", None):
+ try:
+ self._backend_widget.set_tooltip_text(self._help_text)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ # Visibility
+ try:
+ if hasattr(self._backend_widget, "set_visible"):
+ self._backend_widget.set_visible(self.visible())
+ except Exception:
+ pass
+
+ def _connect_selection_handlers(self):
+ """Connect selection event handlers."""
+ if self._multi:
+ try:
+ self._listbox.connect("selected-rows-changed",
+ lambda lb: self._on_selected_rows_changed(lb))
+ self._listbox.connect("row-activated",
+ lambda lb, row: self._on_row_selected_for_multi(lb, row))
+ except Exception:
+ self._logger.exception("Failed to connect multi-selection handlers")
+ else:
+ try:
+ self._listbox.connect("row-selected",
+ lambda lb, row: self._on_row_selected(lb, row))
+ except Exception:
+ self._logger.exception("Failed to connect single-selection handler")
+
+ def _get_column_width(self, col):
+ """Get width for specified column."""
+ if col < len(self._column_widths):
+ width = self._column_widths[col]
+ return max(width, self._min_column_width) if width > 0 else self._default_column_width
+ return self._default_column_width
+
+ def _header_is_checkbox(self, col):
+ """Check if column is a checkbox column."""
+ try:
+ return bool(self._header.isCheckboxColumn(col))
+ except Exception:
+ return False
+
+ def rebuildTable(self):
+ """
+ Rebuild the entire table from scratch.
+
+ This ensures column alignment between header and all rows.
+ """
+ self._logger.debug("rebuildTable: %d items", len(self._items) if self._items else 0)
+ if self._backend_widget is None or self._listbox is None:
+ self._create_backend_widget()
+
+ # Clear existing rows
+ self._clear_rows()
+
+ # Build new rows
+ try:
+ cols = self._header.columns()
+ except Exception:
+ cols = 0
+ if cols <= 0:
+ cols = 1
+
+ for row_idx, it in enumerate(list(getattr(self, '_items', []) or [])):
+ try:
+ row = self._create_row(it, cols)
+ if row:
+ self._listbox.append(row)
+ self._row_to_item[row] = it
+ self._item_to_row[it] = row
+ self._rows.append(row)
+ except Exception:
+ self._logger.exception("Failed to create row %d", row_idx)
+
+ # Apply selection from model
+ self._apply_model_selection()
+
+ def _clear_rows(self):
+ """Clear all rows from the table."""
+ try:
+ self._row_to_item.clear()
+ self._item_to_row.clear()
+ except Exception:
+ self._logger.exception("Failed to clear row-item mappings")
+
+ try:
+ for row in self._rows:
+ try:
+ self._listbox.remove(row)
+ except Exception:
+ self._logger.exception("Failed to remove row during clear_rows")
+ self._rows = []
+ except Exception:
+ self._logger.exception("Failed to clear rows")
+
+ def _create_row(self, item, cols):
+ """
+ Create a single table row with proper column alignment.
+
+ Args:
+ item: YTableItem for this row
+ cols: Number of columns
+
+ Returns:
+ Gtk.ListBoxRow or None on error
+ """
+ try:
+ row = Gtk.ListBoxRow()
+ ctx = row.get_style_context()
+ ctx.add_class("y-table-row")
+
+ # Use Grid instead of Box for better column control
+ grid = Gtk.Grid()
+ grid.set_column_spacing(0)
+ grid.set_row_spacing(0)
+ grid.set_hexpand(True)
+
+ # Create cells for each column with exact widths
+ for col in range(cols):
+ cell_widget = self._create_cell_widget(item, col)
+ if cell_widget:
+ # Attach cell to grid at column position
+ grid.attach(cell_widget, col, 0, 1, 1)
+
+ row.set_child(grid)
+ return row
+ except Exception:
+ self._logger.exception("Failed to create row")
+ return None
+
+ def _create_cell_widget(self, item, col):
+ """
+ Create widget for a single cell with proper width and alignment.
+
+ Args:
+ item: YTableItem containing cell data
+ col: Column index
+
+ Returns:
+ Gtk.Widget for the cell
+ """
+ try:
+ # Get cell data
+ cell = item.cell(col) if hasattr(item, 'cell') else None
+ is_cb = self._header_is_checkbox(col)
+
+ # Get alignment for this column
+ try:
+ align_t = self._header.alignment(col)
+ except Exception:
+ align_t = YAlignmentType.YAlignBegin
+
+ # Create container with exact column width
+ cell_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
+ cell_container.set_hexpand(False)
+
+ # Apply exact column width from header
+ width = self._get_column_width(col)
+ cell_container.set_size_request(width, -1)
+
+ # Create cell content based on type
+ if is_cb:
+ content = self._create_checkbox_content(cell, align_t, item, col)
+ else:
+ content = self._create_label_content(cell, align_t)
+
+ if content:
+ cell_container.append(content)
+
+ return cell_container
+ except Exception:
+ self._logger.exception("Failed to create cell widget for column %d", col)
+ # Return empty container with correct width
+ empty = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
+ empty.set_size_request(self._get_column_width(col), -1)
+ return empty
+
+ def _create_checkbox_content(self, cell, align_t, item, col):
+ """
+ Create checkbox cell content with proper alignment.
+
+ Args:
+ cell: YTableCell or None
+ align_t: Alignment type
+ item: Parent YTableItem
+ col: Column index
+
+ Returns:
+ Gtk.Widget for checkbox cell
+ """
+ try:
+ # Create checkbox
+ chk = Gtk.CheckButton()
+ try:
+ chk.set_active(cell.checked() if cell is not None else False)
+ except Exception:
+ chk.set_active(False)
+
+ # Build an explicit fill wrapper and place checkbox according to
+ # column alignment. This is more reliable than relying on halign
+ # on Gtk.CheckButton alone in fixed-width table cells.
+ wrapper = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
+ wrapper.set_hexpand(True)
+ wrapper.set_halign(Gtk.Align.FILL)
+
+ def _hspacer():
+ spacer = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
+ spacer.set_hexpand(True)
+ return spacer
+
+ if align_t == YAlignmentType.YAlignCenter:
+ wrapper.append(_hspacer())
+ chk.set_margin_start(0)
+ chk.set_margin_end(0)
+ wrapper.append(chk)
+ wrapper.append(_hspacer())
+ elif align_t == YAlignmentType.YAlignEnd:
+ wrapper.append(_hspacer())
+ chk.set_margin_start(0)
+ chk.set_margin_end(6)
+ wrapper.append(chk)
+ else:
+ chk.set_margin_start(6)
+ chk.set_margin_end(0)
+ wrapper.append(chk)
+ wrapper.append(_hspacer())
+
+ chk.set_valign(Gtk.Align.CENTER)
+
+ # Connect toggle handler
+ def _on_toggled(btn, item=item, cindex=col):
+ try:
+ c = item.cell(cindex)
+ if c is not None:
+ c.setChecked(bool(btn.get_active()))
+ # Track changed item
+ self._changed_item = item
+ # Emit value changed
+ dlg = self.findDialog()
+ if dlg is not None and self.notify():
+ dlg._post_event(YWidgetEvent(self, YEventReason.ValueChanged))
+ except Exception:
+ self._logger.exception("Checkbox toggle failed")
+
+ chk.connect("toggled", _on_toggled)
+
+ return wrapper
+ except Exception:
+ self._logger.exception("Failed to create checkbox content")
+ return Gtk.Label(label="")
+
+ def _create_label_content(self, cell, align_t):
+ """
+ Create label cell content with proper alignment.
+
+ Args:
+ cell: YTableCell or None
+ align_t: Alignment type
+
+ Returns:
+ Gtk.Label widget
+ """
+ try:
+ # Get text
+ txt = cell.label() if cell is not None else ""
+ lbl = Gtk.Label(label=txt)
+ lbl.set_halign(Gtk.Align.FILL)
+ lbl.set_margin_start(5)
+ lbl.set_margin_end(5)
+ lbl.set_hexpand(True)
+ lbl.set_ellipsize(Pango.EllipsizeMode.END)
+
+ # Apply alignment
+ if align_t == YAlignmentType.YAlignCenter:
+ lbl.set_xalign(0.5)
+ elif align_t == YAlignmentType.YAlignEnd:
+ lbl.set_xalign(1.0)
+ else:
+ lbl.set_xalign(0.0)
+
+ return lbl
+ except Exception:
+ self._logger.exception("Failed to create label content")
+ lbl = Gtk.Label(label="")
+ lbl.set_xalign(0.0)
+ return lbl
+
+ def _apply_model_selection(self):
+ """Apply selection state from model to UI."""
+ try:
+ self._suppress_selection_handler = True
+
+ # Clear all selections first
+ try:
+ self._listbox.unselect_all()
+ except Exception:
+ pass
+
+ # Apply selection from model
+ selected_items = []
+ for it in list(getattr(self, '_items', []) or []):
+ if hasattr(it, 'selected') and it.selected():
+ selected_items.append(it)
+
+ # Select items in UI
+ for it in selected_items:
+ try:
+ row = self._item_to_row.get(it)
+ if row is not None:
+ self._listbox.select_row(row)
+ except Exception:
+ pass
+
+ # For single selection, ensure only one is selected
+ if not self._multi and len(selected_items) > 1:
+ for it in selected_items[1:]:
+ try:
+ row = self._item_to_row.get(it)
+ if row is not None:
+ self._listbox.unselect_row(row)
+ except Exception:
+ pass
+
+ finally:
+ self._suppress_selection_handler = False
+
+ # Column resizing handlers
+ def _on_separator_enter(self, controller, x, y):
+ """Change cursor to col-resize when hovering over separator."""
+ try:
+ widget = controller.get_widget()
+ display = widget.get_display()
+ cursor = Gdk.Cursor.new_from_name(display, "col-resize")
+ if cursor:
+ widget.get_root().set_cursor(cursor)
+ except Exception:
+ pass
+
+ def _on_separator_leave(self, controller):
+ """Reset cursor when leaving separator."""
+ try:
+ widget = controller.get_widget()
+ widget.get_root().set_cursor(None)
+ except Exception:
+ pass
+
+ def _on_drag_begin(self, gesture, start_x, start_y):
+ """Begin column resize drag operation."""
+ try:
+ widget = gesture.get_widget()
+ col_index = widget.column_index
+
+ if col_index < len(self._column_widths):
+ current_width = self._column_widths[col_index]
+ self._drag_data = {
+ 'column_index': col_index,
+ 'start_width': current_width,
+ 'start_x': start_x
+ }
+ except Exception:
+ self._drag_data = None
+
+ def _on_drag_update(self, gesture, offset_x, offset_y):
+ """Update column width during drag."""
+ if not self._drag_data:
+ return
+
+ try:
+ col_index = self._drag_data['column_index']
+ new_width = max(self._min_column_width,
+ self._drag_data['start_width'] + offset_x)
+
+ # Update column width
+ self._update_column_width(col_index, new_width)
+
+ self._drag_data['current_width'] = new_width
+ except Exception:
+ self._logger.exception("Drag update failed")
+
+ def _on_drag_end(self, gesture, offset_x, offset_y):
+ """End column resize drag operation."""
+ self._drag_data = None
+
+ def _update_column_width(self, col_index, new_width):
+ """
+ Update width for a specific column in header and all rows.
+ """
+ if col_index >= len(self._column_widths):
+ return
+
+ # Update stored width
+ self._column_widths[col_index] = new_width
+
+ # Update header cell
+ if col_index < len(self._header_cells):
+ try:
+ self._header_cells[col_index].set_size_request(new_width, -1)
+ except Exception:
+ pass
+
+ # Update all rows
+ for row in self._rows:
+ try:
+ grid = row.get_child()
+ if grid and isinstance(grid, Gtk.Grid):
+ # Get the cell container at this column index
+ cell_widget = grid.get_child_at(col_index, 0)
+ if cell_widget:
+ cell_widget.set_size_request(new_width, -1)
+ except Exception:
+ pass
+
+ # Selection handlers (maintained from original)
+ def _on_row_selected(self, listbox, row):
+ if self._suppress_selection_handler:
+ return
+ try:
+ # Update selected flags
+ for it in list(getattr(self, '_items', []) or []):
+ try:
+ it.setSelected(False)
+ except Exception:
+ pass
+ if row is not None:
+ it = self._row_to_item.get(row)
+ if it is not None:
+ try:
+ it.setSelected(True)
+ except Exception:
+ pass
+ self._selected_items = [it]
+ else:
+ self._selected_items = []
+ # notify
+ try:
+ if self.notify():
+ dlg = self.findDialog()
+ if dlg is not None:
+ dlg._post_event(YWidgetEvent(self, YEventReason.SelectionChanged))
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def _on_selected_rows_changed(self, listbox):
+ if self._suppress_selection_handler:
+ return
+ try:
+ selected_rows = listbox.get_selected_rows() or []
+ new_selected = []
+ for row in selected_rows:
+ it = self._row_to_item.get(row)
+ if it is not None:
+ new_selected.append(it)
+ # set flags
+ try:
+ for it in list(getattr(self, '_items', []) or []):
+ it.setSelected(False)
+ for it in new_selected:
+ it.setSelected(True)
+ except Exception:
+ pass
+ if not self._multi and len(new_selected) > 1:
+ new_selected = [new_selected[-1]]
+
+ self._old_selected_items = self._selected_items
+ self._selected_items = new_selected
+ # notify
+ try:
+ if self.notify():
+ dlg = self.findDialog()
+ if dlg is not None:
+ dlg._post_event(YWidgetEvent(self, YEventReason.SelectionChanged))
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def _on_row_selected_for_multi(self, listbox, row):
+ """
+ Handler for row selection in multi-selection mode: for de-selection.
+ """
+ if self._suppress_selection_handler:
+ return
+ self._logger.debug("_on_row_selected_for_multi called")
+ sel_rows = listbox.get_selected_rows()
+ it = self._row_to_item.get(row, None)
+ if it is not None:
+ if it in self._old_selected_items:
+ self._listbox.unselect_row(row)
+ it.setSelected(False)
+ self._on_selected_rows_changed(listbox)
+ else:
+ self._old_selected_items = self._selected_items
+
+ # API methods
+ def addItem(self, item):
+ """Add a single item to the table."""
+ if isinstance(item, str):
+ item = YTableItem(item)
+ if not isinstance(item, YTableItem):
+ raise TypeError("YTableGtk.addItem expects a YTableItem or string label")
+ super().addItem(item)
+ item.setIndex(len(self._items) - 1)
+ if getattr(self, '_listbox', None) is not None:
+ self.rebuildTable()
+
+ def addItems(self, items):
+ """Add multiple items to the table efficiently."""
+ for item in items:
+ if isinstance(item, str):
+ item = YTableItem(item)
+ super().addItem(item)
+ elif isinstance(item, YTableItem):
+ super().addItem(item)
+ else:
+ self._logger.error("YTable.addItem: invalid item type %s", type(item))
+ raise TypeError("YTableGtk.addItem expects a YTableItem or string label")
+ item.setIndex(len(self._items) - 1)
+ if getattr(self, '_listbox', None) is not None:
+ self.rebuildTable()
+
+ def selectItem(self, item, selected=True):
+ """Select or deselect an item."""
+ try:
+ item.setSelected(bool(selected))
+ except Exception:
+ pass
+
+ if getattr(self, '_listbox', None) is None:
+ if selected:
+ if item not in self._selected_items:
+ self._selected_items.append(item)
+ else:
+ try:
+ if item in self._selected_items:
+ self._selected_items.remove(item)
+ except Exception:
+ pass
+ return
+
+ try:
+ row = self._item_to_row.get(item)
+ if row is None:
+ self.rebuildTable()
+ row = self._item_to_row.get(item)
+ if row is None:
+ return
+
+ self._suppress_selection_handler = True
+ if selected:
+ if not self._multi:
+ try:
+ for r in list(self._listbox.get_selected_rows() or []):
+ self._listbox.unselect_row(r)
+ except Exception:
+ pass
+ self._listbox.select_row(row)
+ else:
+ try:
+ self._listbox.unselect_row(row)
+ except Exception:
+ pass
+ finally:
+ self._suppress_selection_handler = False
+
+ def deleteAllItems(self):
+ """Delete all items from the table."""
+ try:
+ super().deleteAllItems()
+ except Exception:
+ self._items = []
+ self._selected_items = []
+ self._changed_item = None
+
+ self._clear_rows()
+
+ def changedItem(self):
+ """Get the item that was most recently changed."""
+ return getattr(self, "_changed_item", None)
+
+ def _set_backend_enabled(self, enabled):
+ """Enable/disable the GTK table at runtime."""
+ try:
+ if getattr(self, "_backend_widget", None) is not None and hasattr(self._backend_widget, "set_sensitive"):
+ self._backend_widget.set_sensitive(bool(enabled))
+ except Exception:
+ pass
+ try:
+ if getattr(self, "_listbox", None) is not None and hasattr(self._listbox, "set_sensitive"):
+ self._listbox.set_sensitive(bool(enabled))
+ except Exception:
+ pass
+
+ def setVisible(self, visible: bool = True):
+ """Set widget visibility."""
+ super().setVisible(visible)
+ try:
+ if getattr(self, "_backend_widget", None) is not None and hasattr(self._backend_widget, "set_visible"):
+ self._backend_widget.set_visible(bool(visible))
+ except Exception:
+ self._logger.exception("setVisible failed")
+
+ def setHelpText(self, help_text: str):
+ """Set help text (tooltip) for the widget."""
+ super().setHelpText(help_text)
+ try:
+ if getattr(self, "_backend_widget", None) is not None:
+ try:
+ self._backend_widget.set_tooltip_text(help_text)
+ except Exception:
+ pass
+ except Exception:
+ self._logger.exception("setHelpText failed")
diff --git a/manatools/aui/backends/gtk/timefieldgtk.py b/manatools/aui/backends/gtk/timefieldgtk.py
new file mode 100644
index 0000000..6c94546
--- /dev/null
+++ b/manatools/aui/backends/gtk/timefieldgtk.py
@@ -0,0 +1,217 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.gtk contains all gtk backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.gtk
+'''
+import logging
+import datetime
+from gi.repository import Gtk
+from ...yui_common import *
+
+
+class YTimeFieldGtk(YWidget):
+ """GTK backend YTimeField implemented as an Entry + MenuButton Popover with three SpinButtons (H/M/S).
+ value()/setValue() use HH:MM:SS. No change events posted.
+ """
+ def __init__(self, parent=None, label: str = ""):
+ super().__init__(parent)
+ self._label = label or ""
+ self._logger = logging.getLogger(f"manatools.aui.gtk.{self.__class__.__name__}")
+ self._time = datetime.time(0, 0, 0)
+ self._popover = None
+ self._pending = None # pending datetime.time (not used when updating live)
+ self._spin_h = None
+ self._spin_m = None
+ self._spin_s = None
+ self.setStretchable(YUIDimension.YD_HORIZ, False)
+ self.setStretchable(YUIDimension.YD_VERT, False)
+
+ def widgetClass(self):
+ return "YTimeField"
+
+ def value(self) -> str:
+ try:
+ t = getattr(self, '_time', None)
+ if t is None:
+ return ''
+ return f"{t.hour:02d}:{t.minute:02d}:{t.second:02d}"
+ except Exception:
+ return ""
+
+ def setValue(self, timestr: str):
+ try:
+ parts = str(timestr).split(':')
+ if len(parts) != 3:
+ return
+ h, m, s = [int(p) for p in parts]
+ h = max(0, min(23, h))
+ m = max(0, min(59, m))
+ s = max(0, min(59, s))
+ self._time = datetime.time(h, m, s)
+ self._update_display()
+ # Keep spin buttons in sync with the current time
+ try:
+ self._sync_spins_from_time()
+ except Exception:
+ self._logger.exception("setValue: failed to sync spins from time")
+ except Exception as e:
+ self._logger.exception("setValue failed: %s", e)
+
+ def _update_display(self):
+ try:
+ if getattr(self, '_entry', None) is not None:
+ t = getattr(self, '_time', None)
+ txt = '' if t is None else f"{t.hour:02d}:{t.minute:02d}:{t.second:02d}"
+ self._entry.set_text(txt)
+ except Exception:
+ self._logger.exception("_update_display failed")
+ pass
+
+ def _sync_spins_from_time(self):
+ try:
+ if self._spin_h is not None:
+ self._spin_h.set_value(self._time.hour)
+ if self._spin_m is not None:
+ self._spin_m.set_value(self._time.minute)
+ if self._spin_s is not None:
+ self._spin_s.set_value(self._time.second)
+ except Exception:
+ self._logger.exception("_sync_spins_from_time failed")
+
+ def _create_backend_widget(self):
+ outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
+ if self._label:
+ lbl = Gtk.Label(label=self._label)
+ lbl.set_xalign(0.0)
+ outer.append(lbl)
+ self._label_widget = lbl
+
+ row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
+
+ entry = Gtk.Entry()
+ # Default: do not expand horizontally unless the widget was created stretchable
+ entry.set_hexpand(bool(self.stretchable(YUIDimension.YD_HORIZ)))
+ row.append(entry)
+ self._entry = entry
+
+ btn = Gtk.MenuButton()
+ row.append(btn)
+
+ outer.append(row)
+
+ pop = Gtk.Popover()
+ grid = Gtk.Grid(column_spacing=6, row_spacing=6)
+
+ adj_h = Gtk.Adjustment(lower=0, upper=23, step_increment=1, page_increment=5)
+ spin_h = Gtk.SpinButton(adjustment=adj_h)
+ adj_m = Gtk.Adjustment(lower=0, upper=59, step_increment=1, page_increment=5)
+ spin_m = Gtk.SpinButton(adjustment=adj_m)
+ adj_s = Gtk.Adjustment(lower=0, upper=59, step_increment=1, page_increment=5)
+ spin_s = Gtk.SpinButton(adjustment=adj_s)
+ self._spin_h, self._spin_m, self._spin_s = spin_h, spin_m, spin_s
+ # Initialize spin buttons to the current time so popover opens consistent with entry
+ try:
+ self._sync_spins_from_time()
+ except Exception:
+ self._logger.exception("init: failed to sync spins from time")
+
+ grid.attach(Gtk.Label(label="H:"), 0, 0, 1, 1)
+ grid.attach(spin_h, 1, 0, 1, 1)
+ grid.attach(Gtk.Label(label="M:"), 0, 1, 1, 1)
+ grid.attach(spin_m, 1, 1, 1, 1)
+ grid.attach(Gtk.Label(label="S:"), 0, 2, 1, 1)
+ grid.attach(spin_s, 1, 2, 1, 1)
+
+ pop.set_child(grid)
+ btn.set_popover(pop)
+
+ def _sync_spins():
+ try:
+ self._sync_spins_from_time()
+ except Exception:
+ self._logger.exception("_sync_spins failed")
+
+ def _on_spin_changed(*_):
+ try:
+ h = int(spin_h.get_value())
+ m = int(spin_m.get_value())
+ s = int(spin_s.get_value())
+ self._time = datetime.time(max(0, min(23, h)), max(0, min(59, m)), max(0, min(59, s)))
+ self._logger.debug("spin commit: %02d:%02d:%02d", self._time.hour, self._time.minute, self._time.second)
+ self._update_display()
+ except Exception:
+ self._logger.exception("spin changed handler failed")
+
+ for sp in (spin_h, spin_m, spin_s):
+ try:
+ sp.connect('value-changed', _on_spin_changed)
+ except Exception:
+ self._logger.exception("couldn't connect spin value-changed")
+
+ def _on_btn_activate(*_):
+ try:
+ self._pending = None
+ _sync_spins()
+ except Exception:
+ self._logger.exception("menu button activate handler failed")
+ try:
+ # Gtk.MenuButton uses 'activate' in GTK4
+ btn.connect('activate', _on_btn_activate)
+ except Exception:
+ self._logger.exception("couldn't connect button activate")
+
+ # No need to commit on close as we update live on spin changes
+
+ def _on_entry_activate(e):
+ try:
+ parts = str(e.get_text()).strip().split(':')
+ if len(parts) == 3:
+ h, m, s = [int(p) for p in parts]
+ h = max(0, min(23, h))
+ m = max(0, min(59, m))
+ s = max(0, min(59, s))
+ self._time = datetime.time(h, m, s)
+ self._logger.debug("entry commit: %02d:%02d:%02d", h, m, s)
+ self._update_display()
+ # Sync spins if popover is open or later when it opens
+ self._sync_spins_from_time()
+ except Exception as ex:
+ self._logger.exception("entry activate parse failed: %s", ex)
+ try:
+ entry.connect('activate', _on_entry_activate)
+ except Exception:
+ self._logger.exception("couldn't connect entry activate")
+ try:
+ # Commit on focus loss in GTK4 via notify::has-focus
+ def _on_entry_focus_notify(e, pspec):
+ try:
+ if not e.has_focus():
+ _on_entry_activate(e)
+ except Exception:
+ self._logger.exception("entry focus notify failed")
+ entry.connect('notify::has-focus', _on_entry_focus_notify)
+ except Exception:
+ self._logger.exception("couldn't connect entry focus notify")
+
+ # Ensure spins are in sync at startup
+ try:
+ self._sync_spins_from_time()
+ except Exception:
+ self._logger.exception("startup: failed to sync spins from time")
+ self._update_display()
+ self._backend_widget = outer
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+
+ def _set_backend_enabled(self, enabled):
+ try:
+ for w in (getattr(self, '_entry', None), getattr(self, '_label_widget', None)):
+ if w is not None:
+ w.set_sensitive(bool(enabled))
+ except Exception:
+ pass
diff --git a/manatools/aui/backends/gtk/treegtk.py b/manatools/aui/backends/gtk/treegtk.py
new file mode 100644
index 0000000..bfa9eb4
--- /dev/null
+++ b/manatools/aui/backends/gtk/treegtk.py
@@ -0,0 +1,1085 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.gtk contains all GTK backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.gtk
+'''
+
+import gi
+gi.require_version('Gtk', '4.0')
+gi.require_version('Gdk', '4.0')
+from gi.repository import Gtk, Gdk, GObject, GdkPixbuf, GLib
+import cairo
+import threading
+import os
+import logging
+from ...yui_common import *
+from .commongtk import _resolve_icon
+
+
+class YTreeGtk(YSelectionWidget):
+ """
+ Stable Gtk4 implementation of a tree using Gtk.ListBox + ScrolledWindow.
+
+ - Renders visible nodes (respecting YTreeItem._is_open).
+ - Supports multiselection and recursiveSelection (select/deselect parents -> children).
+ - Preserves stretching: the ScrolledWindow/ListBox expand to fill container.
+ """
+ def __init__(self, parent=None, label="", multiselection=False, recursiveselection=False):
+ super().__init__(parent)
+ self._label = label
+ self._multi = bool(multiselection)
+ self._recursive = bool(recursiveselection)
+ if self._recursive:
+ # recursive selection implies multi-selection semantics
+ self._multi = True
+ self._immediate = self.notify()
+ self._backend_widget = None
+ self._listbox = None
+ self._logger = logging.getLogger(f"manatools.aui.gtk.{self.__class__.__name__}")
+ self._old_selected_items = [] # for change detection
+ # cached rows and mappings
+ self._rows = [] # ordered list of Gtk.ListBoxRow
+ self._row_to_item = {} # row -> YTreeItem
+ self._item_to_row = {} # YTreeItem -> row
+ self._visible_items = [] # list of (item, depth)
+ self._suppress_selection_handler = False
+ self._last_selected_ids = set()
+ # preferred visible rows to hint initial/min height (approx 24px per row)
+ self._preferred_rows = 8
+ self.setStretchable(YUIDimension.YD_HORIZ, True)
+ self.setStretchable(YUIDimension.YD_VERT, True)
+
+ def widgetClass(self):
+ return "YTree"
+
+ def _create_backend_widget(self):
+ vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
+
+ if self._label:
+ try:
+ lbl = Gtk.Label(label=self._label)
+ if hasattr(lbl, "set_xalign"):
+ lbl.set_xalign(0.0)
+ vbox.append(lbl)
+ except Exception:
+ pass
+
+ # ListBox (flat, shows only visible nodes). Put into ScrolledWindow so it won't grow parent on expand.
+ listbox = Gtk.ListBox()
+ try:
+ mode = Gtk.SelectionMode.MULTIPLE if self._multi else Gtk.SelectionMode.SINGLE
+ listbox.set_selection_mode(mode)
+ # Let listbox expand in available area
+ listbox.set_vexpand(self.stretchable(YUIDimension.YD_VERT))
+ listbox.set_hexpand(self.stretchable(YUIDimension.YD_HORIZ))
+ listbox.set_valign(Gtk.Align.FILL)
+ except Exception:
+ self._logger.debug("Failed to set expansion on tree listbox")
+
+ sw = Gtk.ScrolledWindow()
+ try:
+ sw.set_child(listbox)
+ except Exception:
+ try:
+ sw.add(listbox)
+ except Exception:
+ pass
+
+ # Make scrolled window expand to fill container (so tree respects parent stretching)
+ try:
+
+ sw.set_vexpand(self.stretchable(YUIDimension.YD_VERT))
+ sw.set_hexpand(self.stretchable(YUIDimension.YD_HORIZ))
+ # In GTK4, scrolled windows may clamp to natural child height; disable propagation
+ sw.set_propagate_natural_height(False)
+ # hint a reasonable minimum height based on preferred rows
+ min_h = int(getattr(self, "_preferred_rows", 8) * 24)
+ sw.set_min_content_height(min_h)
+ vbox.set_vexpand(self.stretchable(YUIDimension.YD_VERT))
+ vbox.set_hexpand(self.stretchable(YUIDimension.YD_HORIZ))
+ vbox.set_valign(Gtk.Align.FILL)
+ except Exception:
+ self._logger.debug("Failed to set expansion on tree scrolled window / vbox")
+ sw.set_vexpand(True)
+ sw.set_hexpand(True)
+ vbox.set_vexpand(True)
+ vbox.set_hexpand(True)
+
+ self._backend_widget = vbox
+ self._listbox = listbox
+ self._backend_widget.set_sensitive(self._enabled)
+
+ try:
+ vbox.append(sw)
+ except Exception:
+ try:
+ vbox.add(sw)
+ except Exception:
+ pass
+
+ # populate if items already exist
+ try:
+ if getattr(self, "_items", None):
+ self._rebuildTree()
+ except Exception:
+ try:
+ self._logger.error("rebuildTree failed during _create_backend_widget", exc_info=True)
+ except Exception:
+ pass
+
+ # connect selection signal; use defensive handler that scans rows
+ if self._multi:
+ try:
+ self._listbox.connect("selected-rows-changed", lambda lb: self._on_selected_rows_changed(lb))
+ self._listbox.connect("row-activated", lambda lb, row: self._on_row_selected_for_multi(lb, row))
+ except Exception:
+ pass
+ else:
+ try:
+ self._listbox.connect("row-selected", lambda lb, row: self._on_row_selected(lb, row))
+ except Exception:
+ pass
+
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+
+ def _make_row(self, item, depth):
+ """Create a ListBoxRow for item with indentation and (optional) toggle button."""
+ row = Gtk.ListBoxRow()
+ hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
+
+ # indentation spacer
+ try:
+ indent = Gtk.Box()
+ indent.set_size_request(depth * 12, 1)
+ hbox.append(indent)
+ except Exception:
+ pass
+
+ # toggle if item has children
+ has_children = False
+ try:
+ childs = []
+ if callable(getattr(item, "children", None)):
+ childs = item.children() or []
+ else:
+ childs = getattr(item, "_children", []) or []
+ has_children = len(childs) > 0
+ except Exception:
+ has_children = False
+
+ if has_children:
+ try:
+ # Replace toggle button with Gtk.Expander to show standard tree affordance
+ exp = Gtk.Expander()
+ try:
+ # keep the expander focused off to avoid selection side-effects
+ exp.set_can_focus(False)
+ except Exception:
+ pass
+ try:
+ # use an empty label so only the arrow is shown here
+ exp.set_label("")
+ except Exception:
+ pass
+ try:
+ # keep a compact width similar to the former button
+ exp.set_size_request(14, 1)
+ except Exception:
+ pass
+ try:
+ # reflect current open state
+ exp.set_expanded(bool(getattr(item, "_is_open", False)))
+ except Exception:
+ pass
+
+ def _on_expanded_changed(expander, pspec, target_item=item):
+ """Handler for expander expanded change: update model and rebuild."""
+ self._logger.debug("_on_expanded_changed called for item <%s>", str(target_item))
+ try:
+ self._suppress_selection_handler = True
+ except Exception:
+ pass
+ try:
+ # Sync model open state from expander
+ try:
+ is_open = bool(getattr(expander, "get_expanded", None) and expander.get_expanded())
+ except Exception:
+ try:
+ # fallback property access
+ is_open = bool(getattr(expander, "expanded", False))
+ except Exception:
+ is_open = False
+ try:
+ target_item.setOpen(is_open)
+ except Exception:
+ try:
+ target_item._is_open = is_open
+ except Exception:
+ pass
+ # preserve selection and rebuild
+ try:
+ self._last_selected_ids = set(id(i) for i in getattr(self, "_selected_items", []) or [])
+ except Exception:
+ self._last_selected_ids = set()
+ try:
+ self._rebuildTree()
+ except Exception:
+ pass
+ finally:
+ try:
+ self._suppress_selection_handler = False
+ except Exception:
+ pass
+
+ try:
+ exp.connect("notify::expanded", _on_expanded_changed)
+ except Exception:
+ # fallback: use activate if notify is not available
+ try:
+ exp.connect("activate", lambda e, it=item: self._on_toggle_clicked(it))
+ except Exception:
+ pass
+
+ hbox.append(exp)
+ except Exception:
+ # fallback spacer
+ try:
+ spacer = Gtk.Box()
+ spacer.set_size_request(14, 1)
+ hbox.append(spacer)
+ except Exception:
+ pass
+ else:
+ try:
+ spacer = Gtk.Box()
+ spacer.set_size_request(14, 1)
+ hbox.append(spacer)
+ except Exception:
+ pass
+
+ # label
+ try:
+ lbl = Gtk.Label(label=item.label() if hasattr(item, "label") else str(item))
+ if hasattr(lbl, "set_xalign"):
+ lbl.set_xalign(0.0)
+ # ensure label expands to take remaining space
+ try:
+ lbl.set_hexpand(True)
+ except Exception:
+ pass
+ # try to resolve icon and prepend image if available
+ try:
+ icon_name = None
+ fn = getattr(item, 'iconName', None)
+ if callable(fn):
+ icon_name = fn()
+ else:
+ icon_name = fn
+ except Exception:
+ icon_name = None
+ img = None
+ try:
+ img = _resolve_icon(icon_name, size=16)
+ except Exception:
+ img = None
+ try:
+ if img is not None:
+ try:
+ hbox.append(img)
+ except Exception:
+ try:
+ hbox.add(img)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ hbox.append(lbl)
+ except Exception:
+ pass
+
+ try:
+ row.set_child(hbox)
+ except Exception:
+ try:
+ row.add(hbox)
+ except Exception:
+ pass
+
+ try:
+ row.set_selectable(True)
+ except Exception:
+ pass
+
+ return row
+
+ def _on_toggle_clicked(self, item):
+ """Toggle _is_open and rebuild, preserving selection."""
+ try:
+ # Ensure a single-click toggle: suppress selection events during the operation
+ try:
+ self._suppress_selection_handler = True
+ except Exception:
+ pass
+ try:
+ try:
+ cur = item.isOpen()
+ item.setOpen(not cur)
+ except Exception:
+ try:
+ cur = bool(getattr(item, "_is_open", False))
+ item._is_open = not cur
+ except Exception:
+ pass
+ # preserve selection ids and rebuild the visible rows
+ try:
+ self._last_selected_ids = set(id(i) for i in getattr(self, "_selected_items", []) or [])
+ except Exception:
+ self._last_selected_ids = set()
+ try:
+ self._rebuildTree()
+ except Exception:
+ pass
+ finally:
+ try:
+ self._suppress_selection_handler = False
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def _collect_all_descendants(self, item):
+ """Return set of all descendant items (recursive)."""
+ out = set()
+ stack = []
+ try:
+ for c in getattr(item, "_children", []) or []:
+ stack.append(c)
+ except Exception:
+ pass
+ while stack:
+ cur = stack.pop()
+ out.add(cur)
+ try:
+ for ch in getattr(cur, "_children", []) or []:
+ stack.append(ch)
+ except Exception:
+ pass
+ return out
+
+ def rebuildTree(self):
+ """RebuildTree to maintain compatibility."""
+ self._logger.warning("rebuildTree is deprecated and should not be needed anymore")
+ self._rebuildTree()
+
+ def _rebuildTree(self):
+ """Flatten visible items according to _is_open and populate the ListBox."""
+ _suppress_selection_handler = True
+ if self._backend_widget is None or self._listbox is None:
+ #self._create_backend_widget()
+ return
+ try:
+ # clear listbox rows robustly: repeatedly remove first child until none remain
+ try:
+ while True:
+ first = None
+ try:
+ first = self._listbox.get_first_child()
+ except Exception:
+ # some bindings may return None / raise; try children()
+ try:
+ chs = self._listbox.get_children()
+ first = chs[0] if chs else None
+ except Exception:
+ first = None
+ if not first:
+ break
+ try:
+ self._listbox.remove(first)
+ except Exception:
+ try:
+ # fallback API
+ self._listbox.unbind_model()
+ break
+ except Exception:
+ break
+ except Exception:
+ pass
+
+ self._rows = []
+ self._row_to_item.clear()
+ self._item_to_row.clear()
+ self._visible_items = []
+
+ # Depth-first traversal producing visible nodes only when ancestors are open
+ def _visit(nodes, depth=0):
+ for n in nodes:
+ self._visible_items.append((n, depth))
+ try:
+ is_open = bool(getattr(n, "_is_open", False))
+ except Exception:
+ is_open = False
+ if is_open:
+ try:
+ childs = []
+ if callable(getattr(n, "children", None)):
+ childs = n.children() or []
+ else:
+ childs = getattr(n, "_children", []) or []
+ except Exception:
+ childs = getattr(n, "_children", []) or []
+ if childs:
+ _visit(childs, depth + 1)
+
+ roots = list(getattr(self, "_items", []) or [])
+
+ # Ensure that any selected item has its ancestors opened so it becomes visible.
+ try:
+ def _collect_all_nodes(nodes):
+ out = []
+ for n in nodes:
+ out.append(n)
+ try:
+ chs = callable(getattr(n, "children", None)) and n.children() or getattr(n, "_children", []) or []
+ except Exception:
+ chs = getattr(n, "_children", []) or []
+ if chs:
+ out.extend(_collect_all_nodes(chs))
+ return out
+
+ all_nodes = _collect_all_nodes(roots)
+ for it in all_nodes:
+ try:
+ sel = False
+ if callable(getattr(it, 'selected', None)):
+ sel = it.selected()
+ else:
+ sel = bool(getattr(it, '_selected', False))
+ if sel:
+ # walk up parent chain and open each ancestor
+ parent = None
+ try:
+ parent = it.parentItem() if callable(getattr(it, 'parentItem', None)) else getattr(it, '_parent_item', None)
+ except Exception:
+ parent = getattr(it, '_parent_item', None)
+ while parent:
+ try:
+ # prefer public API
+ try:
+ parent.setOpen(True)
+ except Exception:
+ parent._is_open = True
+ except Exception:
+ pass
+ try:
+ parent = parent.parentItem() if callable(getattr(parent, 'parentItem', None)) else getattr(parent, '_parent_item', None)
+ except Exception:
+ break
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ _visit(roots, 0)
+
+ # create rows
+ for item, depth in self._visible_items:
+ try:
+ row = self._make_row(item, depth)
+ self._listbox.append(row)
+ self._rows.append(row)
+ self._row_to_item[row] = item
+ self._item_to_row[item] = row
+ except Exception:
+ pass
+
+ # restore selection without emitting selection-changed while syncing UI to model
+ # Desired selection source: collect all nodes in the tree with selected()==True.
+ self._suppress_selection_handler = True
+ try:
+ try:
+ self._listbox.unselect_all()
+ except Exception:
+ pass
+
+ # collect all nodes in the current model
+ def _collect_all_nodes(nodes):
+ out = []
+ for n in nodes:
+ out.append(n)
+ try:
+ chs = callable(getattr(n, "children", None)) and n.children() or getattr(n, "_children", []) or []
+ except Exception:
+ chs = getattr(n, "_children", []) or []
+ if chs:
+ out.extend(_collect_all_nodes(chs))
+ return out
+ all_nodes = _collect_all_nodes(roots)
+
+ desired_ids = set()
+ for n in all_nodes:
+ try:
+ if callable(getattr(n, 'selected', None)):
+ if n.selected():
+ desired_ids.add(id(n))
+ else:
+ if bool(getattr(n, '_selected', False)):
+ desired_ids.add(id(n))
+ except Exception:
+ pass
+
+ if desired_ids:
+ # apply to visible rows
+ try:
+ self._apply_desired_ids_to_rows(desired_ids)
+ except Exception:
+ # fallback per-row loop
+ try:
+ for row, item in list(self._row_to_item.items()):
+ if id(item) in desired_ids:
+ try:
+ self._listbox.select_row(row)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ # mirror selection flags back to items and build logical list
+ self._selected_items = []
+ for n in all_nodes:
+ try:
+ sel = id(n) in desired_ids
+ try:
+ n.setSelected(sel)
+ except Exception:
+ pass
+ if sel:
+ self._selected_items.append(n)
+ except Exception:
+ pass
+
+ self._last_selected_ids = set(id(i) for i in self._selected_items)
+ finally:
+ self._suppress_selection_handler = False
+ except Exception:
+ pass
+ _suppress_selection_handler = False
+
+ def _row_is_selected(self, r):
+ """Robust helper to detect whether a ListBoxRow is selected."""
+ try:
+ # preferred API
+ return bool(r.is_selected())
+ except Exception:
+ pass
+ try:
+ props = getattr(r, "props", None)
+ if props and hasattr(props, "selected"):
+ return bool(getattr(props, "selected"))
+ except Exception:
+ pass
+ # fallback: check whether the listbox reports this row as selected (some bindings)
+ try:
+ if self._listbox is not None and hasattr(self._listbox, "get_selected_rows"):
+ rows = self._listbox.get_selected_rows() or []
+ for rr in rows:
+ if rr is r:
+ return True
+ except Exception:
+ pass
+ # last-resort flag
+ return bool(getattr(r, "_selected_flag", False))
+
+ def _gather_selected_rows(self):
+ """Return list of selected ListBoxRow objects (visible rows)."""
+ rows = []
+ try:
+ # prefer listbox API if available
+ if self._listbox is not None and hasattr(self._listbox, "get_selected_rows"):
+ try:
+ sel = self._listbox.get_selected_rows() or []
+ # If API returns Gtk.ListBoxRow-like objects, include them; otherwise fallback
+ for s in sel:
+ if s is None:
+ continue
+ # if path-like, ignore (we rely on visible rows)
+ if isinstance(s, type(self._rows[0])) if self._rows else False:
+ rows.append(s)
+ if rows:
+ return rows
+ except Exception:
+ pass
+ # fallback: scan our cached rows
+ for r in list(self._rows or []):
+ try:
+ if self._row_is_selected(r):
+ rows.append(r)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ return rows
+
+ def _apply_desired_ids_to_rows(self, desired_ids):
+ """Set visible rows selected state to match desired_ids (ids of items)."""
+ if self._listbox is None:
+ return
+ try:
+ self._suppress_selection_handler = True
+ except Exception:
+ pass
+ try:
+ try:
+ self._listbox.unselect_all()
+ except Exception:
+ # continue even if unsupported
+ pass
+ for row, it in list(self._row_to_item.items()):
+ try:
+ target = id(it) in desired_ids
+ try:
+ if target:
+ self._listbox.select_row(row)
+ else:
+ self._listbox.unselect_row(row)
+ except Exception:
+ try:
+ setattr(row, "_selected_flag", bool(target))
+ except Exception:
+ pass
+ except Exception:
+ pass
+ finally:
+ try:
+ self._suppress_selection_handler = False
+ except Exception:
+ pass
+
+ def _on_row_selected_for_multi(self, listbox, row):
+ """
+ Handler for row selection in multi-selection mode: for de-selection.
+ """
+ if self._suppress_selection_handler:
+ return
+ self._logger.debug("_on_row_selected_for_multi called")
+ sel_rows = listbox.get_selected_rows()
+ it = self._row_to_item.get(row, None)
+ if it is not None:
+ if it in self._old_selected_items:
+ self._listbox.unselect_row( row )
+ it.setSelected( False )
+ self._on_selected_rows_changed(listbox)
+ else:
+ self._old_selected_items = self._selected_items
+
+
+ def _on_selected_rows_changed(self, listbox):
+ """
+ Handler for multi-selection (or bulk selection change). Rebuild selected list
+ using either ListBox APIs (if available) or by scanning cached rows.
+ """
+ self._logger.debug("_on_selected_rows_changed called")
+ # ignore if programmatic change in progress
+ if self._suppress_selection_handler:
+ return
+
+ try:
+ selected_rows = self._gather_selected_rows()
+
+ # map rows -> items
+ cur_selected_items = []
+ for r in selected_rows:
+ try:
+ it = self._row_to_item.get(r, None)
+ if it is not None:
+ cur_selected_items.append(it)
+ except Exception:
+ pass
+
+ prev_ids = set(self._last_selected_ids or [])
+ cur_ids = set(id(i) for i in cur_selected_items)
+ added = cur_ids - prev_ids
+ removed = prev_ids - cur_ids
+
+ # If recursive+multi, compute desired ids by adding descendants of added and removing descendants of removed.
+ desired_ids = set(cur_ids)
+ if self._recursive and self._multi and (added or removed):
+ try:
+ # add descendants of newly added items
+ for a in list(added):
+ # find object
+ obj = None
+ for it in cur_selected_items:
+ if id(it) == a:
+ obj = it
+ break
+ if obj is None:
+ # try to find in whole tree
+ def _find_by_id(tid, nodes):
+ for n in nodes:
+ if id(n) == tid:
+ return n
+ try:
+ chs = callable(getattr(n, "children", None)) and n.children() or getattr(n, "_children", []) or []
+ except Exception:
+ chs = getattr(n, "_children", []) or []
+ r = _find_by_id(tid, chs)
+ if r:
+ return r
+ return None
+ obj = _find_by_id(a, list(getattr(self, "_items", []) or []))
+ if obj is not None:
+ for d in self._collect_all_descendants(obj):
+ desired_ids.add(id(d))
+
+ # remove descendants of removed items
+ for r_id in list(removed):
+ try:
+ obj = None
+ # try find in tree
+ def _find_by_id2(tid, nodes):
+ for n in nodes:
+ if id(n) == tid:
+ return n
+ try:
+ chs = callable(getattr(n, "children", None)) and n.children() or getattr(n, "_children", []) or []
+ except Exception:
+ chs = getattr(n, "_children", []) or []
+ rr = _find_by_id2(tid, chs)
+ if rr:
+ return rr
+ return None
+ obj = _find_by_id2(r_id, list(getattr(self, "_items", []) or []))
+ if obj is not None:
+ for d in self._collect_all_descendants(obj):
+ if id(d) in desired_ids:
+ desired_ids.discard(id(d))
+ except Exception:
+ pass
+
+ except Exception:
+ pass
+
+ # Apply desired selection to visible rows
+ try:
+ self._apply_desired_ids_to_rows(desired_ids)
+ except Exception:
+ pass
+
+ # Recompute cur_selected_items including non-visible descendants
+ new_selected = []
+ try:
+ # visible rows
+ for r in list(self._rows or []):
+ try:
+ if self._row_is_selected(r):
+ it = self._row_to_item.get(r)
+ if it is not None:
+ new_selected.append(it)
+ except Exception:
+ pass
+ # include non-visible nodes that are requested by desired_ids
+ def _collect_all_nodes(nodes):
+ out = []
+ for n in nodes:
+ out.append(n)
+ try:
+ chs = callable(getattr(n, "children", None)) and n.children() or getattr(n, "_children", []) or []
+ except Exception:
+ chs = getattr(n, "_children", []) or []
+ if chs:
+ out.extend(_collect_all_nodes(chs))
+ return out
+ for root in list(getattr(self, "_items", []) or []):
+ for n in _collect_all_nodes([root]):
+ try:
+ if id(n) in desired_ids and n not in new_selected:
+ new_selected.append(n)
+ except Exception:
+ pass
+ cur_selected_items = new_selected
+ cur_ids = set(id(i) for i in cur_selected_items)
+ except Exception:
+ pass
+
+ # Update logical selection flags
+ try:
+ def _clear_flags(nodes):
+ for n in nodes:
+ try:
+ n.setSelected(False)
+ except Exception:
+ pass
+ try:
+ chs = callable(getattr(n, "children", None)) and n.children() or getattr(n, "_children", []) or []
+ except Exception:
+ chs = getattr(n, "_children", []) or []
+ if chs:
+ _clear_flags(chs)
+ _clear_flags(list(getattr(self, "_items", []) or []))
+ except Exception:
+ pass
+
+ for it in cur_selected_items:
+ try:
+ it.setSelected(True)
+ except Exception:
+ pass
+
+ # store logical selection
+ self._old_selected_items = self._selected_items
+ self._selected_items = list(cur_selected_items)
+ self._last_selected_ids = set(id(i) for i in self._selected_items)
+
+ # notify immediate mode
+ if self._immediate and self.notify():
+ try:
+ dlg = self.findDialog()
+ if dlg:
+ dlg._post_event(YWidgetEvent(self, YEventReason.SelectionChanged))
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def _on_row_selected(self, listbox, row):
+ """Handle selection change; update logical selected items reliably.
+
+ When recursive selection is enabled and multi-selection is on,
+ selecting/deselecting a parent will also select/deselect all its descendants.
+ """
+ try:
+ mapped = self._row_to_item.get(row, None) if row is not None else None
+ try:
+ mapped_label = mapped.label() if (mapped is not None and callable(getattr(mapped, 'label', None))) else str(mapped)
+ except Exception:
+ mapped_label = repr(mapped)
+ self._logger.debug("_on_row_selected called row=%r -> item=%r label=%r", row, mapped, mapped_label)
+ except Exception:
+ try:
+ self._logger.debug("_on_row_selected called (row=%r)", row)
+ except Exception:
+ pass
+ # ignore if programmatic change in progress
+ if self._suppress_selection_handler:
+ return
+
+ try:
+ selected_rows = self._gather_selected_rows()
+
+ # map rows -> items
+ cur_selected_items = []
+ for r in selected_rows:
+ try:
+ it = self._row_to_item.get(r, None)
+ if it is not None:
+ cur_selected_items.append(it)
+ except Exception:
+ pass
+
+ # Update logical selection flags
+ try:
+ def _clear_flags(nodes):
+ for n in nodes:
+ try:
+ n.setSelected(False)
+ except Exception:
+ pass
+ try:
+ chs = callable(getattr(n, "children", None)) and n.children() or getattr(n, "_children", []) or []
+ except Exception:
+ chs = getattr(n, "_children", []) or []
+ if chs:
+ _clear_flags(chs)
+ _clear_flags(list(getattr(self, "_items", []) or []))
+ except Exception:
+ pass
+
+ if len(cur_selected_items) > 1:
+ self._logger.warning(f"Multiple selected items: {[it.label() for it in cur_selected_items]}")
+
+ for it in cur_selected_items:
+ try:
+ it.setSelected(True)
+ except Exception:
+ pass
+
+ # store logical selection
+ self._selected_items = list(cur_selected_items)
+ self._last_selected_ids = set(id(i) for i in self._selected_items)
+
+ # notify immediate mode
+ if self._immediate and self.notify():
+ try:
+ dlg = self.findDialog()
+ if dlg:
+ dlg._post_event(YWidgetEvent(self, YEventReason.SelectionChanged))
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def currentItem(self):
+ try:
+ return self._selected_items[0] if self._selected_items else None
+ except Exception:
+ return None
+
+ def getSelectedItem(self):
+ return self.currentItem()
+
+ def getSelectedItems(self):
+ return list(self._selected_items)
+
+ def activate(self):
+ try:
+ itm = self.currentItem()
+ if itm is None:
+ return False
+ dlg = self.findDialog()
+ if dlg is not None:
+ dlg._post_event(YWidgetEvent(self, YEventReason.Activated))
+ return True
+ except Exception:
+ return False
+
+ def hasMultiSelection(self):
+ """Return True if the tree allows selecting multiple items at once."""
+ return bool(self._multi)
+
+ def immediateMode(self):
+ return bool(self._immediate)
+
+ def setImmediateMode(self, on:bool=True):
+ self._immediate = on
+ self.setNotify(on)
+
+ def _set_backend_enabled(self, enabled):
+ try:
+ if self._backend_widget is not None:
+ try:
+ self._backend_widget.set_sensitive(enabled)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ try:
+ for it in list(getattr(self, "_items", []) or []):
+ try:
+ if hasattr(it, "setEnabled"):
+ it.setEnabled(enabled)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def get_backend_widget(self):
+ if self._backend_widget is None:
+ self._create_backend_widget()
+ return self._backend_widget
+
+ def addItem(self, item):
+ """Add YTreeItem to model and refresh view when needed."""
+ if isinstance(item, str):
+ item = YTreeItem(item)
+ super().addItem(item)
+ else:
+ super().addItem(item)
+ try:
+ item.setIndex(len(self._items) - 1)
+ except Exception:
+ pass
+ try:
+ if getattr(self, '_listbox', None) is not None:
+ try:
+ self._rebuildTree()
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def addItems(self, items):
+ '''Add multiple items to the table. This is more efficient than calling addItem repeatedly.'''
+ for item in items:
+ if isinstance(item, str):
+ item = YTreeItem(item)
+ super().addItem(item)
+ else:
+ super().addItem(item)
+ item.setIndex(len(self._items) - 1)
+ try:
+ if getattr(self, '_listbox', None) is not None:
+ self._rebuildTree()
+ except Exception:
+ pass
+
+
+ def selectItem(self, item, selected=True):
+ """Select/deselect a logical YTreeItem and reflect changes in the Gtk.ListBox."""
+ try:
+ if selected:
+ if not self._multi:
+ if len(self._selected_items) > 0:
+ self._selected_items[0].setSelected(False)
+ self._selected_items = [item]
+ else:
+ if item not in self._selected_items:
+ self._selected_items.append(item)
+ else:
+ try:
+ if item in self._selected_items:
+ self._selected_items.remove(item)
+ except Exception:
+ pass
+
+ item.setSelected(bool(selected))
+
+ if getattr(self, '_listbox', None) is None:
+ return
+ # ensure mapping exists
+ self._rebuildTree()
+ except Exception:
+ pass
+
+ def deleteAllItems(self):
+ """Clear model and view rows for this tree."""
+ self._suppress_selection_handler = True
+ try:
+ super().deleteAllItems()
+ except Exception:
+ self._items = []
+ self._selected_items = []
+ try:
+ self._rows = []
+ self._row_to_item.clear()
+ self._item_to_row.clear()
+ self._visible_items = []
+ try:
+ self._last_selected_ids = set()
+ except Exception:
+ pass
+ except Exception:
+ pass
+ try:
+ if getattr(self, '_listbox', None) is not None:
+ try:
+ # remove visible rows
+ for r in list(self._listbox.get_children() or []) if hasattr(self._listbox, 'get_children') else []:
+ try:
+ self._listbox.remove(r)
+ except Exception:
+ pass
+ try:
+ self._listbox.unselect_all()
+ except Exception:
+ pass
+ except Exception:
+ pass
+ except Exception:
+ pass
+ self._suppress_selection_handler = False
diff --git a/manatools/aui/backends/gtk/vboxgtk.py b/manatools/aui/backends/gtk/vboxgtk.py
new file mode 100644
index 0000000..22a8491
--- /dev/null
+++ b/manatools/aui/backends/gtk/vboxgtk.py
@@ -0,0 +1,252 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.gtk contains all GTK backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.gtk
+'''
+
+import gi
+gi.require_version('Gtk', '4.0')
+gi.require_version('Gdk', '4.0')
+from gi.repository import Gtk, Gdk, GObject, GdkPixbuf, GLib
+import cairo
+import threading
+import os
+import logging
+from ...yui_common import *
+
+
+class _YVBoxMeasureBox(Gtk.Box):
+ """Gtk.Box subclass delegating size requests to YVBoxGtk."""
+
+ def __init__(self, owner):
+ """Initialize the measuring box.
+
+ Args:
+ owner: Owning YVBoxGtk instance.
+ """
+ super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=5)
+ self._owner = owner
+
+ def do_measure(self, orientation, for_size):
+ """GTK4 virtual method for size measurement.
+
+ Args:
+ orientation: Gtk.Orientation (HORIZONTAL or VERTICAL)
+ for_size: Size in the opposite orientation (-1 if not constrained)
+
+ Returns:
+ tuple: (minimum_size, natural_size, minimum_baseline, natural_baseline)
+ """
+ try:
+ return self._owner.do_measure(orientation, for_size)
+ except Exception:
+ self._owner._logger.exception("VBox backend do_measure delegation failed", exc_info=True)
+ return (0, 0, -1, -1)
+
+
+class YVBoxGtk(YWidget):
+ """Vertical GTK4 container with weight-aware geometry management."""
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self._logger = logging.getLogger(f"manatools.aui.gtk.{self.__class__.__name__}")
+
+ def widgetClass(self):
+ return "YVBox"
+
+ # Returns the stretchability of the layout box:
+ def stretchable(self, dim):
+ for child in self._children:
+ expand = bool(child.stretchable(dim))
+ weight = bool(child.weight(dim))
+ if expand or weight:
+ return True
+ return False
+
+ def do_measure(self, orientation, for_size):
+ """GTK4 virtual method for size measurement.
+
+ Args:
+ orientation: Gtk.Orientation (HORIZONTAL or VERTICAL)
+ for_size: Size in the opposite orientation (-1 if not constrained)
+
+ Returns:
+ tuple: (minimum_size, natural_size, minimum_baseline, natural_baseline)
+ """
+ children = list(getattr(self, "_children", []) or [])
+ if not children:
+ return (0, 0, -1, -1)
+
+ spacing = 5
+ try:
+ if getattr(self, "_backend_widget", None) is not None and hasattr(self._backend_widget, "get_spacing"):
+ spacing = int(self._backend_widget.get_spacing())
+ except Exception:
+ self._logger.exception("Failed to read VBox spacing for measure", exc_info=True)
+ spacing = 5
+
+ minimum_size = 0
+ natural_size = 0
+ maxima_minimum = 0
+ maxima_natural = 0
+
+ for child in children:
+ try:
+ child_widget = child.get_backend_widget()
+ cmin, cnat, _cbase_min, _cbase_nat = child_widget.measure(orientation, for_size)
+ except Exception:
+ self._logger.exception("VBox measure failed for child %s", getattr(child, "debugLabel", lambda: "")(), exc_info=True)
+ cmin, cnat = 0, 0
+
+ if orientation == Gtk.Orientation.VERTICAL:
+ minimum_size += int(cmin)
+ natural_size += int(cnat)
+ else:
+ maxima_minimum = max(maxima_minimum, int(cmin))
+ maxima_natural = max(maxima_natural, int(cnat))
+
+ if orientation == Gtk.Orientation.VERTICAL:
+ gap_total = max(0, len(children) - 1) * spacing
+ minimum_size += gap_total
+ natural_size += gap_total
+ else:
+ minimum_size = maxima_minimum
+ natural_size = maxima_natural
+
+ self._logger.debug(
+ "VBox do_measure orientation=%s for_size=%s -> min=%s nat=%s",
+ orientation,
+ for_size,
+ minimum_size,
+ natural_size,
+ )
+ return (minimum_size, natural_size, -1, -1)
+
+ def _create_backend_widget(self):
+ """Create backend widget and configure weight/stretch behavior."""
+ self._backend_widget = _YVBoxMeasureBox(self)
+
+ # Collect children first so we can apply weight-based heuristics
+ children = list(self._children)
+
+ for child in children:
+ widget = child.get_backend_widget()
+ try:
+ # Respect the child's stretchable/weight hints instead of forcing expansion
+ vert_stretch = bool(child.stretchable(YUIDimension.YD_VERT)) or bool(child.weight(YUIDimension.YD_VERT))
+ horiz_stretch = bool(child.stretchable(YUIDimension.YD_HORIZ)) or bool(child.weight(YUIDimension.YD_HORIZ))
+ widget.set_vexpand(bool(vert_stretch))
+ widget.set_hexpand(bool(horiz_stretch))
+ try:
+ widget.set_valign(Gtk.Align.FILL if vert_stretch else Gtk.Align.START)
+ except Exception:
+ pass
+ try:
+ widget.set_halign(Gtk.Align.FILL if horiz_stretch else Gtk.Align.START)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ # Gtk4: use append instead of pack_start
+ try:
+ self._backend_widget.append(widget)
+ except Exception:
+ try:
+ self._backend_widget.add(widget)
+ except Exception:
+ pass
+ self._backend_widget.set_sensitive(self._enabled)
+ try:
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception:
+ pass
+
+ # If there are exactly two children and both declare positive vertical
+ # weights, attempt to enforce a proportional vertical split according
+ # to weights. Similar to horizontal HBox, Gtk.Box does not support
+ # per-child weight factors, so we set size requests on the children
+ # based on the container allocation.
+ try:
+ if len(children) == 2:
+ w0 = int(children[0].weight(YUIDimension.YD_VERT) or 0)
+ w1 = int(children[1].weight(YUIDimension.YD_VERT) or 0)
+ if w0 > 0 and w1 > 0:
+ top = children[0].get_backend_widget()
+ bottom = children[1].get_backend_widget()
+ total_weight = max(1, w0 + w1)
+
+ def _apply_vweights(*args):
+ try:
+ alloc = self._backend_widget.get_height()
+ if not alloc or alloc <= 0:
+ return True
+ spacing = getattr(self._backend_widget, 'get_spacing', lambda: 5)()
+ avail = max(0, alloc - spacing)
+ top_px = int(avail * w0 / total_weight)
+ bot_px = max(0, avail - top_px)
+ try:
+ top.set_size_request(-1, top_px)
+ except Exception:
+ pass
+ try:
+ bottom.set_size_request(-1, bot_px)
+ except Exception:
+ pass
+ except Exception:
+ self._logger.exception("_apply_vweights: failed", exc_info=True)
+ return False
+
+ try:
+ GLib.idle_add(_apply_vweights)
+ except Exception:
+ try:
+ _apply_vweights()
+ except Exception:
+ pass
+ def _on_resize_update(*_args):
+ try:
+ _apply_vweights()
+ except Exception:
+ self._logger.exception("_on_resize_update: failed", exc_info=True)
+
+ connected = False
+ for signal_name in ("size-allocate", "notify::height", "notify::height-request"):
+ try:
+ self._backend_widget.connect(signal_name, _on_resize_update)
+ connected = True
+ self._logger.debug("Connected VBox resize hook using signal '%s'", signal_name)
+ break
+ except Exception:
+ continue
+ if not connected:
+ self._logger.debug("No supported resize signal found for VBox; dynamic weight refresh disabled")
+ except Exception:
+ pass
+
+
+ def _set_backend_enabled(self, enabled):
+ """Enable/disable the VBox and propagate to children."""
+ try:
+ if self._backend_widget is not None:
+ try:
+ self._backend_widget.set_sensitive(enabled)
+ except Exception:
+ self._logger.exception("_set_backend_enabled: failed to set_sensitive", exc_info=True)
+ except Exception:
+ self._logger.exception("_set_backend_enabled: failed", exc_info=True)
+ # propagate logical enabled state to child widgets so they update their backends
+ try:
+ for c in list(getattr(self, "_children", []) or []):
+ try:
+ c.setEnabled(enabled)
+ except Exception:
+ pass
+ except Exception:
+ pass
diff --git a/manatools/aui/backends/qt/__init__.py b/manatools/aui/backends/qt/__init__.py
new file mode 100644
index 0000000..599238d
--- /dev/null
+++ b/manatools/aui/backends/qt/__init__.py
@@ -0,0 +1,63 @@
+from .dialogqt import YDialogQt
+from .checkboxqt import YCheckBoxQt
+from .hboxqt import YHBoxQt
+from .vboxqt import YVBoxQt
+from .labelqt import YLabelQt
+from .pushbuttonqt import YPushButtonQt
+from .treeqt import YTreeQt
+from .alignmentqt import YAlignmentQt
+from .comboboxqt import YComboBoxQt
+from .frameqt import YFrameQt
+from .inputfieldqt import YInputFieldQt
+from .selectionboxqt import YSelectionBoxQt
+from .checkboxframeqt import YCheckBoxFrameQt
+from .progressbarqt import YProgressBarQt
+from .radiobuttonqt import YRadioButtonQt
+from .tableqt import YTableQt
+from .richtextqt import YRichTextQt
+from .menubarqt import YMenuBarQt
+from .replacepointqt import YReplacePointQt
+from .intfieldqt import YIntFieldQt
+from .datefieldqt import YDateFieldQt
+from .multilineeditqt import YMultiLineEditQt
+from .spacingqt import YSpacingQt
+from .imageqt import YImageQt
+from .dumbtabqt import YDumbTabQt
+from .sliderqt import YSliderQt
+from .logviewqt import YLogViewQt
+from .timefieldqt import YTimeFieldQt
+from .panedqt import YPanedQt
+
+
+__all__ = [
+ "YDialogQt",
+ "YFrameQt",
+ "YVBoxQt",
+ "YHBoxQt",
+ "YTreeQt",
+ "YSelectionBoxQt",
+ "YLabelQt",
+ "YPushButtonQt",
+ "YInputFieldQt",
+ "YCheckBoxQt",
+ "YComboBoxQt",
+ "YAlignmentQt",
+ "YCheckBoxFrameQt",
+ "YProgressBarQt",
+ "YRadioButtonQt",
+ "YTableQt",
+ "YRichTextQt",
+ "YMenuBarQt",
+ "YReplacePointQt",
+ "YIntFieldQt",
+ "YDateFieldQt",
+ "YMultiLineEditQt",
+ "YSpacingQt",
+ "YImageQt",
+ "YDumbTabQt",
+ "YSliderQt",
+ "YLogViewQt",
+ "YTimeFieldQt",
+ "YPanedQt",
+ # ... add new widgets here ...
+]
diff --git a/manatools/aui/backends/qt/alignmentqt.py b/manatools/aui/backends/qt/alignmentqt.py
new file mode 100644
index 0000000..86e89c9
--- /dev/null
+++ b/manatools/aui/backends/qt/alignmentqt.py
@@ -0,0 +1,249 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.qt contains all Qt backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.qt
+'''
+from PySide6 import QtWidgets, QtCore
+import logging
+from ...yui_common import *
+
+class YAlignmentQt(YSingleChildContainerWidget):
+ """
+ Single-child alignment container for Qt6. Uses a QWidget + QGridLayout,
+ applying Qt.Alignment flags to the child. The container expands along
+ axes needed by Right/HCenter/VCenter/HVCenter to allow alignment.
+ """
+ def __init__(self, parent=None, horAlign: YAlignmentType=YAlignmentType.YAlignUnchanged, vertAlign: YAlignmentType=YAlignmentType.YAlignUnchanged):
+ super().__init__(parent)
+ self._logger = logging.getLogger(f"manatools.aui.qt.{self.__class__.__name__}")
+ self._halign_spec = horAlign
+ self._valign_spec = vertAlign
+ self._min_width_px = 0
+ self._min_height_px = 0
+ self._backend_widget = None
+ self._layout = None
+
+ def widgetClass(self):
+ return "YAlignment"
+
+ def _to_qt_halign(self):
+ """Convert Horizontal YAlignmentType to QtCore.Qt.AlignmentFlag or None."""
+ if self._halign_spec:
+ if self._halign_spec == YAlignmentType.YAlignBegin:
+ return QtCore.Qt.AlignmentFlag.AlignLeft
+ if self._halign_spec == YAlignmentType.YAlignCenter:
+ return QtCore.Qt.AlignmentFlag.AlignHCenter
+ if self._halign_spec == YAlignmentType.YAlignEnd:
+ return QtCore.Qt.AlignmentFlag.AlignRight
+ return None
+
+ def _to_qt_valign(self):
+ """Convert Vertical YAlignmentType to QtCore.Qt.AlignmentFlag or None."""
+ if self._valign_spec:
+ if self._valign_spec == YAlignmentType.YAlignBegin:
+ return QtCore.Qt.AlignmentFlag.AlignTop
+ if self._valign_spec == YAlignmentType.YAlignCenter:
+ return QtCore.Qt.AlignmentFlag.AlignVCenter
+ if self._valign_spec == YAlignmentType.YAlignEnd:
+ return QtCore.Qt.AlignmentFlag.AlignBottom
+ return None
+
+
+ def stretchable(self, dim: YUIDimension):
+ ''' Returns the stretchability of the layout box:
+ * The layout box is stretchable if the alignment spec requests expansion
+ * (Right/HCenter/HVCenter for horizontal, VCenter/HVCenter for vertical)
+ * OR if the child itself requests stretchability or has a layout weight.
+ '''
+ # Expand if alignment spec requests it
+ try:
+ if dim == YUIDimension.YD_HORIZ:
+ if self._halign_spec in (YAlignmentType.YAlignEnd, YAlignmentType.YAlignCenter):
+ return True
+ if dim == YUIDimension.YD_VERT:
+ if self._valign_spec in (YAlignmentType.YAlignCenter,):
+ return True
+ except Exception:
+ pass
+
+ # Otherwise honor child's own stretchability/weight
+ try:
+ if self.child():
+ expand = bool(self.child().stretchable(dim))
+ weight = bool(self.child().weight(dim))
+ if expand or weight:
+ return True
+ except Exception:
+ pass
+ return False
+
+ def setAlignment(self, horAlign: YAlignmentType=YAlignmentType.YAlignUnchanged, vertAlign: YAlignmentType=YAlignmentType.YAlignUnchanged):
+ self._halign_spec = horAlign
+ self._valign_spec = vertAlign
+ self._reapply_alignment()
+
+ def _reapply_alignment(self):
+ if not (self._layout and self.child()):
+ return
+ try:
+ w = self.child().get_backend_widget()
+ if w:
+ self._layout.removeWidget(w)
+ flags = QtCore.Qt.AlignmentFlag(0)
+ ha = self._to_qt_halign()
+ va = self._to_qt_valign()
+ if ha:
+ flags |= ha
+ if va:
+ flags |= va
+ self._layout.addWidget(w, 0, 0, flags)
+ except Exception:
+ pass
+
+ def addChild(self, child):
+ super().addChild(child)
+ self._attach_child_backend()
+
+ def setMinWidth(self, width_px: int):
+ try:
+ self._min_width_px = int(width_px) if width_px is not None else 0
+ except Exception:
+ self._min_width_px = 0
+ try:
+ # if child already attached, re-apply
+ if self.child() and getattr(self, '_layout', None) is not None:
+ self._attach_child_backend()
+ except Exception:
+ pass
+
+ def setMinHeight(self, height_px: int):
+ try:
+ self._min_height_px = int(height_px) if height_px is not None else 0
+ except Exception:
+ self._min_height_px = 0
+ try:
+ if self.child() and getattr(self, '_layout', None) is not None:
+ self._attach_child_backend()
+ except Exception:
+ pass
+
+ def setMinSize(self, width_px: int, height_px: int):
+ self.setMinWidth(width_px)
+ self.setMinHeight(height_px)
+
+
+ def _attach_child_backend(self):
+ if not (self._backend_widget and self._layout and self.child()):
+ return
+ try:
+ w = self.child().get_backend_widget()
+ if w:
+ # clear previous
+ try:
+ self._layout.removeWidget(w)
+ except Exception:
+ pass
+ flags = QtCore.Qt.AlignmentFlag(0)
+ ha = self._to_qt_halign()
+ va = self._to_qt_valign()
+ if ha:
+ flags |= ha
+ if va:
+ flags |= va
+ # If the child requests horizontal stretch, set its QSizePolicy to Expanding
+ try:
+ if self.child() and self.child().stretchable(YUIDimension.YD_HORIZ):
+ sp = w.sizePolicy()
+ try:
+ sp.setHorizontalPolicy(QtWidgets.QSizePolicy.Policy.Expanding)
+ except Exception:
+ try:
+ sp.setHorizontalPolicy(QtWidgets.QSizePolicy.Expanding)
+ except Exception:
+ pass
+ w.setSizePolicy(sp)
+ # If child requests vertical stretch, set vertical policy
+ if self.child() and self.child().stretchable(YUIDimension.YD_VERT):
+ sp = w.sizePolicy()
+ try:
+ sp.setVerticalPolicy(QtWidgets.QSizePolicy.Policy.Expanding)
+ except Exception:
+ try:
+ sp.setVerticalPolicy(QtWidgets.QSizePolicy.Expanding)
+ except Exception:
+ pass
+ w.setSizePolicy(sp)
+ # Enforce minimum size on child if requested (pixels)
+ try:
+ if getattr(self, '_min_width_px', 0) and hasattr(w, 'setMinimumWidth'):
+ try:
+ w.setMinimumWidth(int(self._min_width_px))
+ except Exception:
+ pass
+ if getattr(self, '_min_height_px', 0) and hasattr(w, 'setMinimumHeight'):
+ try:
+ w.setMinimumHeight(int(self._min_height_px))
+ except Exception:
+ pass
+ except Exception:
+ pass
+ except Exception:
+ pass
+ self._layout.addWidget(w, 0, 0, flags)
+ except Exception:
+ pass
+
+ def _create_backend_widget(self):
+ container = QtWidgets.QWidget()
+ grid = QtWidgets.QGridLayout(container)
+ grid.setContentsMargins(0, 0, 0, 0)
+ grid.setSpacing(0)
+
+ # Size policy: expand along axes needed for alignment to work
+ sp = container.sizePolicy()
+ try:
+ sp.setHorizontalPolicy(QtWidgets.QSizePolicy.Policy.Expanding if self.stretchable(YUIDimension.YD_HORIZ)
+ else QtWidgets.QSizePolicy.Policy.Fixed)
+ sp.setVerticalPolicy(QtWidgets.QSizePolicy.Policy.Expanding if self.stretchable(YUIDimension.YD_VERT)
+ else QtWidgets.QSizePolicy.Policy.Fixed)
+ except Exception:
+ pass
+ container.setSizePolicy(sp)
+
+ self._backend_widget = container
+ self._layout = grid
+ self._backend_widget.setEnabled(bool(self._enabled))
+
+ if self.hasChildren():
+ self._attach_child_backend()
+ try:
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception:
+ pass
+
+ def _set_backend_enabled(self, enabled):
+ """Enable/disable the alignment container and propagate to its logical child."""
+ try:
+ if getattr(self, "_backend_widget", None) is not None:
+ try:
+ self._backend_widget.setEnabled(bool(enabled))
+ except Exception:
+ pass
+ except Exception:
+ pass
+ # propagate to logical child
+ try:
+ child = self.child()
+ if child is not None:
+ try:
+ child.setEnabled(enabled)
+ except Exception:
+ pass
+ except Exception:
+ pass
diff --git a/manatools/aui/backends/qt/checkboxframeqt.py b/manatools/aui/backends/qt/checkboxframeqt.py
new file mode 100644
index 0000000..ea251cc
--- /dev/null
+++ b/manatools/aui/backends/qt/checkboxframeqt.py
@@ -0,0 +1,255 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.qt contains all Qt backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.qt
+'''
+from PySide6 import QtWidgets, QtCore
+import logging
+from ...yui_common import *
+
+class YCheckBoxFrameQt(YSingleChildContainerWidget):
+ """
+ Qt backend for YCheckBoxFrame: a frame with a checkbox that can enable/disable its child.
+ """
+ def __init__(self, parent=None, label: str = "", checked: bool = False):
+ super().__init__(parent)
+ self._label = label or ""
+ self._checked = bool(checked)
+ self._auto_enable = True
+ self._invert_auto = False
+ self._backend_widget = None
+ self._checkbox = None
+ self._content_widget = None
+ self._content_layout = None
+ self._logger = logging.getLogger(f"manatools.aui.qt.{self.__class__.__name__}")
+
+ def widgetClass(self):
+ return "YCheckBoxFrame"
+
+ def label(self):
+ return self._label
+
+ def setLabel(self, new_label):
+ try:
+ self._label = str(new_label)
+ if getattr(self, "_checkbox", None) is not None:
+ try:
+ # QGroupBox uses setTitle; keep compatibility if _checkbox is a QCheckBox
+ if hasattr(self._checkbox, "setTitle"):
+ self._checkbox.setTitle(self._label)
+ else:
+ self._checkbox.setText(self._label)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def setValue(self, isChecked: bool):
+ try:
+ self._checked = bool(isChecked)
+ if self._checkbox is not None:
+ try:
+ # QGroupBox supports setChecked when checkable
+ if hasattr(self._checkbox, "setChecked"):
+ self._checkbox.blockSignals(True)
+ self._checkbox.setChecked(self._checked)
+ self._checkbox.blockSignals(False)
+ else:
+ # fallback for plain checkbox widget
+ self._checkbox.blockSignals(True)
+ self._checkbox.setChecked(self._checked)
+ self._checkbox.blockSignals(False)
+ except Exception:
+ pass
+ # propagate enablement based on new value
+ self.handleChildrenEnablement(self._checked)
+ except Exception:
+ pass
+
+ def value(self):
+ try:
+ if self._checkbox is not None:
+ # QGroupBox isChecked exists when checkable; otherwise fallback
+ if hasattr(self._checkbox, "isChecked"):
+ return bool(self._checkbox.isChecked())
+ if hasattr(self._checkbox, "isChecked"):
+ return bool(self._checkbox.isChecked())
+ except Exception:
+ pass
+ return bool(self._checked)
+
+ def autoEnable(self):
+ return bool(self._auto_enable)
+
+ def setAutoEnable(self, autoEnable: bool):
+ try:
+ self._auto_enable = bool(autoEnable)
+ # re-evaluate children enablement
+ self.handleChildrenEnablement(self.value())
+ except Exception:
+ pass
+
+ def invertAutoEnable(self):
+ return bool(self._invert_auto)
+
+ def setInvertAutoEnable(self, invert: bool):
+ try:
+ self._invert_auto = bool(invert)
+ self.handleChildrenEnablement(self.value())
+ except Exception:
+ pass
+
+ def _create_backend_widget(self):
+ """Create widget: use QGroupBox checkable (theme-aware) so the checkbox is in the title."""
+ try:
+ # Use QGroupBox to present a themed frame with a checkable title area.
+ grp = QtWidgets.QGroupBox()
+ grp.setTitle(self._label)
+ grp.setCheckable(True)
+ grp.setChecked(self._checked)
+ layout = QtWidgets.QVBoxLayout(grp)
+ layout.setContentsMargins(6, 6, 6, 6)
+ layout.setSpacing(4)
+
+ content = QtWidgets.QWidget()
+ content_layout = QtWidgets.QVBoxLayout(content)
+ content_layout.setContentsMargins(0, 0, 0, 0)
+ content_layout.setSpacing(4)
+ layout.addWidget(content)
+
+ self._backend_widget = grp
+ # keep attribute name _checkbox for compatibility, but it's a QGroupBox now
+ self._checkbox = grp
+ self._content_widget = content
+ self._content_layout = content_layout
+ self._backend_widget.setEnabled(bool(self._enabled))
+
+ # connect group toggled signal
+ try:
+ grp.toggled.connect(self._on_checkbox_toggled)
+ except Exception:
+ # older bindings or non-checkable objects may not have toggled
+ pass
+
+ # attach existing child if present
+ try:
+ if self.hasChildren():
+ self._attach_child_backend()
+ except Exception:
+ pass
+ except Exception:
+ self._backend_widget = None
+ self._checkbox = None
+ self._content_widget = None
+ self._content_layout = None
+ try:
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception:
+ pass
+
+ def _attach_child_backend(self):
+ """Attach child's backend widget into content area."""
+ if not (self._backend_widget and self._content_layout and self.child()):
+ return
+
+ # Safely clear existing content layout
+ try:
+ while self._content_layout.count():
+ it = self._content_layout.takeAt(0)
+ if it and it.widget():
+ it.widget().setParent(None)
+ except Exception:
+ pass
+
+ # Try to obtain/create child's backend widget and insert it
+ try:
+ child = self.child()
+ w = None
+ try:
+ w = child.get_backend_widget()
+ except Exception:
+ w = None
+
+ if w is None:
+ try:
+ child._create_backend_widget()
+ w = child.get_backend_widget()
+ except Exception:
+ w = None
+
+ if w is not None:
+ # determine stretch factor from child's weight()/stretchable()
+ try:
+ weight = int(child.weight(YUIDimension.YD_VERT))
+ except Exception:
+ weight = 0
+ try:
+ stretchable_vert = bool(child.stretchable(YUIDimension.YD_VERT))
+ except Exception:
+ stretchable_vert = False
+ stretch = weight if weight > 0 else (1 if stretchable_vert else 0)
+
+ # set child's Qt size policy according to logical stretchable flags
+ try:
+ sp = w.sizePolicy()
+ try:
+ horiz = QtWidgets.QSizePolicy.Expanding if bool(child.stretchable(YUIDimension.YD_HORIZ)) else QtWidgets.QSizePolicy.Fixed
+ except Exception:
+ horiz = QtWidgets.QSizePolicy.Fixed
+ try:
+ vert = QtWidgets.QSizePolicy.Expanding if stretch > 0 else QtWidgets.QSizePolicy.Fixed
+ except Exception:
+ vert = QtWidgets.QSizePolicy.Fixed
+ sp.setHorizontalPolicy(horiz)
+ sp.setVerticalPolicy(vert)
+ # When autoscale/height-for-width semantics are used by the child,
+ # preserve its existing height-for-width capability.
+ try:
+ if hasattr(sp, "setHeightForWidth"):
+ sp.setHeightForWidth(bool(getattr(child, "_auto_scale", False)))
+ except Exception:
+ pass
+ w.setSizePolicy(sp)
+ except Exception:
+ pass
+
+ # add widget with stretch factor if supported
+ added = False
+ try:
+ # Some PySide/PyQt bindings accept (widget, stretch)
+ self._content_layout.addWidget(w, stretch)
+ added = True
+ except TypeError:
+ added = False
+ except Exception:
+ added = False
+
+ if not added:
+ try:
+ self._content_layout.addWidget(w)
+ added = True
+ except Exception:
+ added = False
+
+ if not added:
+ # final fallback: parent the widget to content container
+ try:
+ w.setParent(self._content_widget)
+ added = True
+ except Exception:
+ added = False
+ except Exception:
+ # top-level protection; do not raise to caller
+ pass
+
+ # apply current enablement state (outside try that manipulates the layout)
+ try:
+ self.handleChildrenEnablement(self.value())
+ except Exception:
+ pass
diff --git a/manatools/aui/backends/qt/checkboxqt.py b/manatools/aui/backends/qt/checkboxqt.py
new file mode 100644
index 0000000..cf90893
--- /dev/null
+++ b/manatools/aui/backends/qt/checkboxqt.py
@@ -0,0 +1,92 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.qt contains all Qt backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.qt
+'''
+from PySide6 import QtWidgets, QtCore
+import logging
+from ...yui_common import *
+
+class YCheckBoxQt(YWidget):
+ def __init__(self, parent=None, label="", is_checked=False):
+ super().__init__(parent)
+ self._label = label
+ self._is_checked = is_checked
+ self._logger = logging.getLogger(f"manatools.aui.qt.{self.__class__.__name__}")
+
+ def widgetClass(self):
+ return "YCheckBox"
+
+ def value(self):
+ return self._is_checked
+
+ def setValue(self, checked):
+ self._is_checked = checked
+ if self._backend_widget:
+ try:
+ # avoid emitting signals while programmatically changing state
+ self._backend_widget.blockSignals(True)
+ self._backend_widget.setChecked(checked)
+ finally:
+ try:
+ self._backend_widget.blockSignals(False)
+ except Exception:
+ pass
+
+ def isChecked(self):
+ '''
+ Simplified access to value(): Return 'true' if the CheckBox is checked.
+ '''
+ return self.value()
+
+ def setChecked(self, checked: bool = True):
+ '''
+ Simplified access to setValue(): Set the CheckBox to 'checked' state if 'checked' is true.
+ '''
+ self.setValue(checked)
+
+ def label(self):
+ return self._label
+
+ def _create_backend_widget(self):
+ self._backend_widget = QtWidgets.QCheckBox(self._label)
+ self._backend_widget.setChecked(self._is_checked)
+ self._backend_widget.stateChanged.connect(self._on_state_changed)
+ self._backend_widget.setEnabled(bool(self._enabled))
+ try:
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception:
+ pass
+
+ def _set_backend_enabled(self, enabled):
+ """Enable/disable the QCheckBox backend."""
+ try:
+ if getattr(self, "_backend_widget", None) is not None:
+ try:
+ self._backend_widget.setEnabled(bool(enabled))
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def _on_state_changed(self, state):
+ # Update internal state
+ # state is QtCore.Qt.CheckState (Unchecked=0, PartiallyChecked=1, Checked=2)
+ self._is_checked = (QtCore.Qt.CheckState(state) == QtCore.Qt.CheckState.Checked)
+
+ if self.notify():
+ # Post a YWidgetEvent to the containing dialog
+ dlg = self.findDialog()
+ if dlg is not None:
+ dlg._post_event(YWidgetEvent(self, YEventReason.ValueChanged))
+ else:
+ try:
+ self._logger.warning("CheckBox state changed (no dialog found): %s = %s", self._label, self._is_checked)
+ except Exception:
+ pass
diff --git a/manatools/aui/backends/qt/comboboxqt.py b/manatools/aui/backends/qt/comboboxqt.py
new file mode 100644
index 0000000..4c40883
--- /dev/null
+++ b/manatools/aui/backends/qt/comboboxqt.py
@@ -0,0 +1,344 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.qt contains all Qt backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.qt
+'''
+from PySide6 import QtWidgets, QtGui
+import logging
+from ...yui_common import *
+from .commonqt import _resolve_icon as _qt_resolve_icon
+
+class YComboBoxQt(YSelectionWidget):
+ def __init__(self, parent=None, label="", editable=False):
+ super().__init__(parent)
+ self._label = label
+ self._editable = editable
+ self._value = ""
+ self._selected_items = []
+ self._logger = logging.getLogger(f"manatools.aui.qt.{self.__class__.__name__}")
+ self._combo_widget = None
+ # reference to the visual label widget (if any)
+ self._label_widget = None
+
+ def widgetClass(self):
+ return "YComboBox"
+
+ def value(self):
+ return self._value
+
+ def setValue(self, text):
+ self._value = text
+ if hasattr(self, '_combo_widget') and self._combo_widget:
+ index = self._combo_widget.findText(text)
+ if index >= 0:
+ self._combo_widget.setCurrentIndex(index)
+ elif self._editable:
+ self._combo_widget.setEditText(text)
+ # update selected_items to keep internal state consistent
+ self._selected_items = []
+ for item in self._items:
+ if item.label() == text:
+ self._selected_items.append(item)
+ break
+ try:
+ # ensure selected flags consistent: only one selected
+ for it in self._items:
+ try:
+ it.setSelected(it.label() == text)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def editable(self):
+ return self._editable
+
+ def setLabel(self, new_label: str):
+ """Set logical label and update/create the visual QLabel in the container."""
+ super().setLabel(new_label)
+ try:
+ if self._backend_widget is None:
+ return
+ if new_label:
+ self._label_widget.setText(new_label)
+ self._label_widget.setVisible(True)
+ else:
+ self._label_widget.setVisible(False)
+ except Exception:
+ self._logger.exception("setLabel: error updating label=%r", new_label)
+
+ def _create_backend_widget(self):
+ container = QtWidgets.QWidget()
+ # use vertical layout so label sits above the combo control
+ layout = QtWidgets.QVBoxLayout(container)
+ layout.setContentsMargins(0, 0, 0, 0)
+
+ self._label_widget = QtWidgets.QLabel()
+ layout.addWidget(self._label_widget)
+ if self._label:
+ self._label_widget.setText(self._label)
+ self._label_widget.setVisible(True)
+ else:
+ self._label_widget.setVisible(False)
+
+ if self._editable:
+ combo = QtWidgets.QComboBox()
+ combo.setEditable(True)
+ else:
+ combo = QtWidgets.QComboBox()
+
+ # Add items to combo box
+ for item in self._items:
+ try:
+ icon_name = None
+ try:
+ fn = getattr(item, 'iconName', None)
+ icon_name = fn() if callable(fn) else fn
+ except Exception:
+ icon_name = None
+ qicon = None
+ if icon_name:
+ try:
+ qicon = _qt_resolve_icon(icon_name)
+ except Exception:
+ qicon = None
+ if qicon is not None:
+ combo.addItem(qicon, item.label())
+ else:
+ combo.addItem(item.label())
+ except Exception:
+ try:
+ combo.addItem(item.label())
+ except Exception:
+ pass
+
+
+ combo.currentTextChanged.connect(self._on_text_changed)
+ # also handle index change (safer for some input methods)
+ combo.currentIndexChanged.connect(lambda idx: self._on_text_changed(combo.currentText()))
+
+ # pick initial selection: first item marked selected
+ try:
+ selected_idx = -1
+ for idx, it in enumerate(self._items):
+ try:
+ if it.selected():
+ selected_idx = idx
+ break
+ except Exception:
+ pass
+ if selected_idx >= 0:
+ combo.setCurrentIndex(selected_idx)
+ self._value = self._items[selected_idx].label()
+ self._selected_items = [self._items[selected_idx]]
+ else:
+ self._selected_items = []
+ except Exception:
+ pass
+ # Honor logical stretch/weight: set QSizePolicy and add with layout stretch factor
+ try:
+ try:
+ weight_v = int(self.weight(YUIDimension.YD_VERT))
+ except Exception:
+ weight_v = 0
+ try:
+ stretchable_v = bool(self.stretchable(YUIDimension.YD_VERT))
+ except Exception:
+ stretchable_v = False
+ stretch = weight_v if weight_v > 0 else (1 if stretchable_v else 0)
+
+ # set QSizePolicy according to logical flags
+ try:
+ sp = combo.sizePolicy()
+ try:
+ horiz = QtWidgets.QSizePolicy.Expanding if bool(self.stretchable(YUIDimension.YD_HORIZ)) else QtWidgets.QSizePolicy.Fixed
+ except Exception:
+ horiz = QtWidgets.QSizePolicy.Fixed
+ try:
+ vert = QtWidgets.QSizePolicy.Expanding if stretch > 0 else QtWidgets.QSizePolicy.Fixed
+ except Exception:
+ vert = QtWidgets.QSizePolicy.Fixed
+ sp.setHorizontalPolicy(horiz)
+ sp.setVerticalPolicy(vert)
+ combo.setSizePolicy(sp)
+ except Exception:
+ pass
+
+ # add to layout with stretch factor when supported (vertical layout: label above, combo below)
+ added = False
+ try:
+ layout.addWidget(combo, stretch)
+ added = True
+ except TypeError:
+ added = False
+ except Exception:
+ added = False
+
+ if not added:
+ try:
+ layout.addWidget(combo)
+ except Exception:
+ try:
+ combo.setParent(container)
+ except Exception:
+ pass
+ except Exception:
+ try:
+ layout.addWidget(combo)
+ except Exception:
+ pass
+
+ self._backend_widget = container
+ self._combo_widget = combo
+ self._backend_widget.setEnabled(bool(self._enabled))
+ try:
+ if self._help_text:
+ self._backend_widget.setToolTip(self._help_text)
+ except Exception:
+ self._logger.exception("Failed to set tooltip text", exc_info=True)
+ try:
+ self._backend_widget.setVisible(self.visible())
+ except Exception:
+ self._logger.exception("Failed to set widget visibility", exc_info=True)
+ # allow logger to raise if misconfigured
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+
+ def _set_backend_enabled(self, enabled):
+ """Enable/disable the combobox and its container."""
+ try:
+ if getattr(self, "_combo_widget", None) is not None:
+ try:
+ self._combo_widget.setEnabled(bool(enabled))
+ except Exception:
+ pass
+ except Exception:
+ pass
+ try:
+ if getattr(self, "_backend_widget", None) is not None:
+ try:
+ self._backend_widget.setEnabled(bool(enabled))
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ # New: add single item at runtime
+ def addItem(self, item):
+ super().addItem(item)
+ new_item = self._items[-1]
+
+ # update backend widget if present
+ if getattr(self, "_combo_widget", None):
+ try:
+ icon_name = None
+ try:
+ fn = getattr(new_item, 'iconName', None)
+ icon_name = fn() if callable(fn) else fn
+ except Exception:
+ icon_name = None
+ qicon = None
+ if icon_name:
+ try:
+ qicon = _qt_resolve_icon(icon_name)
+ except Exception:
+ qicon = None
+ if qicon is not None:
+ self._combo_widget.addItem(qicon, new_item.label())
+ else:
+ self._combo_widget.addItem(new_item.label())
+ # reflect selection state
+ try:
+ if new_item.selected():
+ # ensure only one is selected
+ for it in self._items:
+ try:
+ it.setSelected(False)
+ except Exception:
+ pass
+ new_item.setSelected(True)
+ idx = len(self._items) - 1
+ self._combo_widget.setCurrentIndex(idx)
+ self._selected_items = [new_item]
+ self._value = new_item.label()
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ # New: delete all items at runtime
+ def deleteAllItems(self):
+ try:
+ self._items = []
+ self._selected_items = []
+ self._value = ""
+ except Exception:
+ pass
+ if getattr(self, "_combo_widget", None):
+ try:
+ self._combo_widget.clear()
+ except Exception:
+ # fallback: recreate widget if clear not supported
+ try:
+ parent = self._combo_widget.parent()
+ layout = self._combo_widget.parent().layout() if parent is not None else None
+ if layout is not None:
+ try:
+ layout.removeWidget(self._combo_widget)
+ self._combo_widget.deleteLater()
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def _on_text_changed(self, text):
+ # keep previous behaviour, but update model selection flags robustly
+ try:
+ self._value = text
+ except Exception:
+ self._value = text
+ # update selected items: only one selected in combo
+ try:
+ old_item = self._selected_items[0] if self._selected_items else None
+ if old_item:
+ old_item.setSelected( False )
+ self._selected_items = []
+ for it in self._items:
+ try:
+ was = (it.label() == text)
+ it.setSelected(was)
+ if was:
+ self._selected_items.append(it)
+ except Exception:
+ pass
+ except Exception:
+ self._selected_items = []
+ # notify
+ if self.notify():
+ try:
+ dlg = self.findDialog()
+ if dlg is not None:
+ dlg._post_event(YWidgetEvent(self, YEventReason.SelectionChanged))
+ except Exception:
+ pass
+
+ def setVisible(self, visible=True):
+ super().setVisible(visible)
+ try:
+ if getattr(self, "_backend_widget", None) is not None:
+ self._backend_widget.setVisible(visible)
+ except Exception:
+ self._logger.exception("setVisible failed", exc_info=True)
+
+ def setHelpText(self, help_text: str):
+ super().setHelpText(help_text)
+ try:
+ if getattr(self, "_backend_widget", None) is not None:
+ self._backend_widget.setToolTip(help_text)
+ except Exception:
+ self._logger.exception("setHelpText failed", exc_info=True)
\ No newline at end of file
diff --git a/manatools/aui/backends/qt/commonqt.py b/manatools/aui/backends/qt/commonqt.py
new file mode 100644
index 0000000..45cb5d2
--- /dev/null
+++ b/manatools/aui/backends/qt/commonqt.py
@@ -0,0 +1,77 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.qt contains all Qt backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.qt
+'''
+from PySide6 import QtWidgets, QtGui
+import os
+import logging
+from ...yui_common import *
+
+
+__all__ = ["_resolve_icon"]
+
+def _resolve_icon(icon_name):
+ """Resolve an icon name to a QtGui.QIcon or None.
+
+ - If icon_name is an existing absolute or relative path -> load from path.
+ - If icon_name contains a path separator or exists on filesystem -> treat as path.
+ - Otherwise strip extension (if any) and try QIcon.fromTheme, then fallback to QIcon(name).
+ """
+ try:
+ if not icon_name:
+ return None
+ logger = logging.getLogger(f"manatools.aui.qt.common")
+ # If icon_name looks like a filesystem path (absolute or contains a
+ # path separator), prefer loading from disk. If it has no
+ # extension, also try the same path with a .png suffix to help
+ # debugging/test cases where a directory+basename is provided.
+ try:
+ if os.path.isabs(icon_name) or os.path.sep in icon_name:
+ if os.path.isfile(icon_name):
+ return QtGui.QIcon(icon_name)
+ # if there's no extension, try .png
+ base, ext = os.path.splitext(icon_name)
+ if not ext:
+ png_candidate = icon_name + '.png'
+ if os.path.isfile(png_candidate):
+ logger.debug("Resolved icon %r to %r", icon_name, png_candidate)
+ return QtGui.QIcon(png_candidate)
+ # not found on filesystem: fall through to theme/name
+ else:
+ # non-path might still be a relative file name
+ if os.path.isfile(icon_name):
+ logger.debug("Resolved icon %r to filesystem path", icon_name)
+ return QtGui.QIcon(icon_name)
+ except Exception:
+ pass
+ base = icon_name
+ try:
+ if "." in base:
+ base = os.path.splitext(base)[0]
+ except Exception:
+ pass
+ try:
+ logger.debug("Trying to resolve icon %r via QIcon.fromTheme(%r)", icon_name, base)
+ ico = QtGui.QIcon.fromTheme(base)
+ if ico and not ico.isNull():
+ logger.debug("Resolved icon %r to theme icon %r", icon_name, base)
+ return ico
+ except Exception:
+ pass
+ try:
+ ico = QtGui.QIcon(icon_name)
+ if ico and not ico.isNull():
+ logger.debug("Resolved icon %r to QIcon(%r)", icon_name, icon_name)
+ return ico
+ except Exception:
+ pass
+ except Exception:
+ pass
+ return None
\ No newline at end of file
diff --git a/manatools/aui/backends/qt/datefieldqt.py b/manatools/aui/backends/qt/datefieldqt.py
new file mode 100644
index 0000000..dbeca41
--- /dev/null
+++ b/manatools/aui/backends/qt/datefieldqt.py
@@ -0,0 +1,152 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.qt contains all qt backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.qt
+'''
+from PySide6 import QtWidgets, QtCore
+import logging
+from ...yui_common import *
+
+
+class YDateFieldQt(YWidget):
+ """Qt backend YDateField implementation using QDateEdit.
+ value()/setValue() use ISO format YYYY-MM-DD. No change events posted.
+ """
+ def __init__(self, parent=None, label: str = ""):
+ super().__init__(parent)
+ self._label = label or ""
+ self._logger = logging.getLogger(f"manatools.aui.qt.{self.__class__.__name__}")
+ self._date = QtCore.QDate.currentDate()
+ self.setStretchable(YUIDimension.YD_HORIZ, False)
+ self.setStretchable(YUIDimension.YD_VERT, False)
+
+ def widgetClass(self):
+ return "YDateField"
+
+ def value(self) -> str:
+ try:
+ return self._date.toString("yyyy-MM-dd")
+ except Exception:
+ return ""
+
+ def setValue(self, datestr: str):
+ try:
+ parts = str(datestr).split("-")
+ if len(parts) == 3:
+ y, m, d = [int(p) for p in parts]
+ qd = QtCore.QDate(y, m, d)
+ if qd.isValid():
+ self._date = qd
+ if getattr(self, '_dateedit', None) is not None:
+ try:
+ self._dateedit.setDate(self._date)
+ except Exception:
+ pass
+ except Exception:
+ try:
+ self._logger.debug("Invalid date for setValue: %r", datestr)
+ except Exception:
+ pass
+
+ def _on_date_changed(self, qdate):
+ """Update internal date when the QDateEdit value changes in the UI."""
+ try:
+ # qdate is a QtCore.QDate
+ self._date = qdate
+ except Exception:
+ try:
+ self._logger.debug("_on_date_changed: couldn't set date from %r", qdate)
+ except Exception:
+ pass
+
+ def _create_backend_widget(self):
+ container = QtWidgets.QWidget()
+ layout = QtWidgets.QVBoxLayout(container)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(2)
+ if self._label:
+ lbl = QtWidgets.QLabel(self._label)
+ layout.addWidget(lbl)
+ self._label_widget = lbl
+
+ de = QtWidgets.QDateEdit()
+ try:
+ de.setCalendarPopup(True)
+ except Exception:
+ pass
+ try:
+ # Display in system locale for user friendliness
+ loc = QtCore.QLocale.system()
+ fmt = loc.dateFormat(QtCore.QLocale.FormatType.ShortFormat)
+ if fmt:
+ de.setDisplayFormat(fmt)
+ except Exception:
+ pass
+ try:
+ de.setDate(self._date)
+ except Exception:
+ pass
+ try:
+ # keep internal date in sync when user selects a new date
+ de.dateChanged.connect(self._on_date_changed)
+ except Exception:
+ try:
+ self._logger.debug("could not connect dateChanged signal")
+ except Exception:
+ pass
+ # Do not emit any events; value is read on demand
+ layout.addWidget(de)
+ self._backend_widget = container
+ self._dateedit = de
+ try:
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception:
+ pass
+
+ # Apply size policy based on stretchable hints to both the date edit and its container
+ try:
+ # derive policies from stretchable flags
+ try:
+ horiz_policy = QtWidgets.QSizePolicy.Policy.Expanding if self.stretchable(YUIDimension.YD_HORIZ) else QtWidgets.QSizePolicy.Policy.Fixed
+ vert_policy = QtWidgets.QSizePolicy.Policy.Expanding if self.stretchable(YUIDimension.YD_VERT) else QtWidgets.QSizePolicy.Policy.Fixed
+ except Exception:
+ horiz_policy = QtWidgets.QSizePolicy.Expanding if self.stretchable(YUIDimension.YD_HORIZ) else QtWidgets.QSizePolicy.Fixed
+ vert_policy = QtWidgets.QSizePolicy.Expanding if self.stretchable(YUIDimension.YD_VERT) else QtWidgets.QSizePolicy.Fixed
+
+ # apply to date edit
+ try:
+ sp_de = de.sizePolicy()
+ sp_de.setHorizontalPolicy(horiz_policy)
+ sp_de.setVerticalPolicy(vert_policy)
+ de.setSizePolicy(sp_de)
+ except Exception:
+ pass
+
+ # apply to container as well so layout won't force expansion contrary to stretchable()
+ try:
+ sp_cont = container.sizePolicy()
+ sp_cont.setHorizontalPolicy(horiz_policy)
+ sp_cont.setVerticalPolicy(vert_policy)
+ container.setSizePolicy(sp_cont)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def _set_backend_enabled(self, enabled):
+ try:
+ if getattr(self, '_dateedit', None) is not None:
+ self._dateedit.setEnabled(bool(enabled))
+ except Exception:
+ pass
+ try:
+ if getattr(self, '_label_widget', None) is not None:
+ self._label_widget.setEnabled(bool(enabled))
+ except Exception:
+ pass
diff --git a/manatools/aui/backends/qt/dialogqt.py b/manatools/aui/backends/qt/dialogqt.py
new file mode 100644
index 0000000..09d7998
--- /dev/null
+++ b/manatools/aui/backends/qt/dialogqt.py
@@ -0,0 +1,509 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.qt contains all Qt backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.qt
+'''
+
+from PySide6 import QtWidgets, QtCore, QtGui
+from ...yui_common import (
+ YSingleChildContainerWidget,
+ YUIDimension,
+ YPropertySet,
+ YProperty,
+ YPropertyType,
+ YUINoDialogException,
+ YDialogType,
+ YDialogColorMode,
+ YEvent,
+ YCancelEvent,
+ YTimeoutEvent,
+)
+from .commonqt import _resolve_icon
+from ... import yui as yui_mod
+import os
+import logging
+import signal
+import fcntl
+
+class YDialogQt(YSingleChildContainerWidget):
+ """Qt6 main window wrapper that manages dialog state and default buttons."""
+ _open_dialogs = []
+
+ def __init__(self, dialog_type=YDialogType.YMainDialog, color_mode=YDialogColorMode.YDialogNormalColor):
+ super().__init__()
+ self._dialog_type = dialog_type
+ self._color_mode = color_mode
+ self._is_open = False
+ self._qwidget = None
+ self._event_result = None
+ self._qt_event_loop = None
+ # SIGINT handling state
+ self._sigint_r = None
+ self._sigint_w = None
+ self._sigint_notifier = None
+ self._prev_wakeup_fd = None
+ self._prev_sigint_handler = None
+ self._default_button = None
+ YDialogQt._open_dialogs.append(self)
+ self._logger = logging.getLogger(f"manatools.aui.qt.{self.__class__.__name__}")
+
+ def widgetClass(self):
+ return "YDialog"
+
+ @staticmethod
+ def currentDialog(doThrow=True):
+ '''Return the currently open dialog (topmost), or raise if none.'''
+ open_dialog = YDialogQt._open_dialogs[-1] if YDialogQt._open_dialogs else None
+ if not open_dialog and doThrow:
+ raise YUINoDialogException("No dialog is currently open")
+ return open_dialog
+
+ @staticmethod
+ def topmostDialog(doThrow=True):
+ ''' same as currentDialog '''
+ return YDialogQt.currentDialog(doThrow=doThrow)
+
+ def isTopmostDialog(self):
+ '''Return whether this dialog is the topmost open dialog.'''
+ return YDialogQt._open_dialogs[-1] == self if YDialogQt._open_dialogs else False
+
+ def open(self):
+ """
+ Finalize and show the dialog in a non-blocking way.
+
+ Matches libyui semantics: open() should only finalize and make visible.
+ If the application expects blocking behavior it should call waitForEvent()
+ which will start a nested event loop as required.
+ """
+ if not self._is_open:
+ if not self._qwidget:
+ self._create_backend_widget()
+
+ self._qwidget.show()
+ self._is_open = True
+
+ def isOpen(self):
+ return self._is_open
+
+ def destroy(self, doThrow=True):
+ self._clear_default_button()
+ if self._qwidget:
+ self._qwidget.close()
+ self._qwidget = None
+ self._is_open = False
+ if self in YDialogQt._open_dialogs:
+ YDialogQt._open_dialogs.remove(self)
+ return True
+
+ @classmethod
+ def deleteTopmostDialog(cls, doThrow=True):
+ if cls._open_dialogs:
+ dialog = cls._open_dialogs[-1]
+ return dialog.destroy(doThrow)
+ return False
+
+ @classmethod
+ def deleteAllDialogs(cls, doThrow=True):
+ """Delete all open dialogs (best-effort)."""
+ ok = True
+ try:
+ while cls._open_dialogs:
+ try:
+ dlg = cls._open_dialogs[-1]
+ dlg.destroy(doThrow)
+ except Exception:
+ ok = False
+ try:
+ cls._open_dialogs.pop()
+ except Exception:
+ break
+ except Exception:
+ ok = False
+ return ok
+
+ @classmethod
+ def currentDialog(cls, doThrow=True):
+ if not cls._open_dialogs:
+ if doThrow:
+ raise YUINoDialogException("No dialog open")
+ return None
+ return cls._open_dialogs[-1]
+
+ def setDefaultButton(self, button):
+ """Set or clear the default push button for this dialog."""
+ if button is None:
+ self._clear_default_button()
+ return True
+ try:
+ if button.widgetClass() != "YPushButton":
+ raise ValueError("Default button must be a YPushButton")
+ except Exception:
+ self._logger.error("Invalid widget passed to setDefaultButton", exc_info=True)
+ return False
+ try:
+ dlg = button.findDialog() if hasattr(button, "findDialog") else None
+ except Exception:
+ dlg = None
+ if dlg not in (None, self):
+ self._logger.error("Refusing to reuse a button owned by a different dialog")
+ return False
+ try:
+ button.setDefault(True)
+ except Exception:
+ self._logger.exception("Failed to flag button as default")
+ return False
+ return True
+
+ def _create_backend_widget(self):
+ """Create the Qt window and initialize title, icon, content, and initial size.
+
+ Popup dialogs should honor content-driven minimum size hints (e.g. from
+ createMinSize in BaseDialog) instead of forcing a large default size.
+ Main dialogs keep a larger default for usability.
+ """
+ self._qwidget = QtWidgets.QMainWindow()
+ # Determine window title:from YApplicationQt instance stored on the YUI backend
+ title = "Manatools Qt Dialog"
+
+ try:
+ appobj = yui_mod.YUI.ui().application()
+ atitle = appobj.applicationTitle()
+ if atitle:
+ title = atitle
+ # try to obtain a resolved QIcon from the application backend if available
+ app_qicon = None
+ if appobj:
+ # prefer cached Qt icon if set by setApplicationIcon
+ app_qicon = getattr(appobj, "_qt_icon", None)
+ # otherwise try to resolve applicationIcon string on the fly
+ if not app_qicon:
+ try:
+ icon_spec = appobj.applicationIcon()
+ if icon_spec:
+ app_qicon = _resolve_icon(icon_spec)
+ except Exception:
+ pass
+ # if we have a qicon, set it on the QApplication and the new window
+ if app_qicon:
+ try:
+ qapp = QtWidgets.QApplication.instance()
+ if qapp:
+ qapp.setWindowIcon(app_qicon)
+ except Exception:
+ pass
+ # store resolved qicon locally to apply to this window
+ _resolved_qicon = app_qicon
+ except Exception:
+ # ignore and keep default
+ _resolved_qicon = None
+
+ self._qwidget.setWindowTitle(title)
+ try:
+ if _resolved_qicon:
+ self._qwidget.setWindowIcon(_resolved_qicon)
+ except Exception:
+ pass
+
+ central_widget = QtWidgets.QWidget()
+ self._qwidget.setCentralWidget(central_widget)
+
+ if self.child():
+ layout = QtWidgets.QVBoxLayout(central_widget)
+ layout.setSizeConstraint(QtWidgets.QLayout.SetMinimumSize)
+ layout.addWidget(self.child().get_backend_widget())
+ # If the child is a layout box with a menubar as first child, Qt can display QMenuBar inline.
+ # Alternatively, backends may add YMenuBarQt directly to layout.
+
+ try:
+ self._apply_initial_size()
+ except Exception:
+ self._logger.exception("Failed to apply initial dialog size", exc_info=True)
+
+ self._backend_widget = self._qwidget
+ self._qwidget.closeEvent = self._on_close_event
+ self._backend_widget.setEnabled(bool(self._enabled))
+ try:
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception:
+ pass
+
+ def _apply_initial_size(self):
+ """Apply initial size policy based on dialog type and content hints.
+
+ - Main dialogs: keep a generous default size.
+ - Popup dialogs: size to content/minimum hints from the layout tree.
+ """
+ if getattr(self, "_qwidget", None) is None:
+ return
+
+ if self._dialog_type == YDialogType.YMainDialog:
+ try:
+ self._qwidget.resize(600, 400)
+ except Exception:
+ self._logger.exception("Failed to set default main-dialog size", exc_info=True)
+ return
+
+ # Popup: derive initial size from content to better match GTK behavior.
+ try:
+ self._qwidget.adjustSize()
+ except Exception:
+ self._logger.exception("adjustSize failed for popup dialog", exc_info=True)
+
+ try:
+ hint = self._qwidget.sizeHint()
+ if hint is not None and hint.isValid():
+ self._qwidget.resize(hint)
+ self._logger.debug("Applied popup size from sizeHint: %sx%s", hint.width(), hint.height())
+ except Exception:
+ self._logger.exception("Failed to apply popup sizeHint", exc_info=True)
+
+ def _set_backend_enabled(self, enabled):
+ """Enable/disable the dialog window and propagate to logical child widgets."""
+ try:
+ if getattr(self, "_qwidget", None) is not None:
+ try:
+ self._qwidget.setEnabled(bool(enabled))
+ except Exception:
+ pass
+ except Exception:
+ pass
+ # propagate logical enabled state to contained YWidget(s)
+ try:
+ if self.child():
+ try:
+ self.child().setEnabled(enabled)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def _on_close_event(self, event):
+ # Post a cancel event so waitForEvent returns a YCancelEvent when the user
+ # closes the window with the window manager 'X' button.
+ try:
+ self._post_event(YCancelEvent())
+ except Exception:
+ pass
+ # Ensure dialog is destroyed and accept the close
+ self.destroy()
+ event.accept()
+
+ def _post_event(self, event):
+ """Internal: post an event to this dialog and quit local event loop if running."""
+ self._event_result = event
+ if self._qt_event_loop is not None and self._qt_event_loop.isRunning():
+ self._qt_event_loop.quit()
+
+ def waitForEvent(self, timeout_millisec=0):
+ """
+ Ensure dialog is finalized/open, then run a nested Qt QEventLoop until an
+ event is posted or timeout occurs. Returns a YEvent (YWidgetEvent, YTimeoutEvent, ...).
+
+ If the application called open() previously this will just block until an event.
+ If open() was not called, it will finalize and show the dialog here (so creation
+ followed by immediate waitForEvent behaves like libyui).
+ """
+ # Ensure dialog is created and visible (finalize if needed)
+ if not self._qwidget:
+ self.open()
+
+ # give Qt a chance to process pending show/layout events
+ app = QtWidgets.QApplication.instance()
+ if app:
+ app.processEvents()
+
+ self._event_result = None
+ loop = QtCore.QEventLoop()
+ self._qt_event_loop = loop
+
+ timer = None
+ if timeout_millisec and timeout_millisec > 0:
+ timer = QtCore.QTimer()
+ timer.setSingleShot(True)
+ def on_timeout():
+ # post timeout event and quit
+ self._event_result = YTimeoutEvent()
+ if loop.isRunning():
+ loop.quit()
+ timer.timeout.connect(on_timeout)
+ timer.start(timeout_millisec)
+
+ # Robust SIGINT (Ctrl-C) handling: use wakeup fd + QSocketNotifier to exit loop
+ self._setup_sigint_notifier(loop)
+
+ # PySide6 / Qt6 uses exec()
+ loop.exec()
+
+ # cleanup
+ if timer and timer.isActive():
+ timer.stop()
+ # teardown SIGINT notifier and restore previous wakeup fd
+ self._teardown_sigint_notifier()
+ self._qt_event_loop = None
+ return self._event_result if self._event_result is not None else YEvent()
+
+ def _setup_sigint_notifier(self, loop):
+ """Install a wakeup fd and QSocketNotifier to gracefully quit on Ctrl-C."""
+ try:
+ # create non-blocking pipe
+ rfd, wfd = os.pipe()
+ for fd in (rfd, wfd):
+ try:
+ flags = fcntl.fcntl(fd, fcntl.F_GETFL)
+ fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
+ except Exception:
+ pass
+ prev = None
+ try:
+ # store previous wakeup fd to restore later
+ prev = signal.set_wakeup_fd(wfd)
+ except Exception:
+ prev = None
+ self._sigint_r = rfd
+ self._sigint_w = wfd
+ self._prev_wakeup_fd = prev
+ # install a noop SIGINT handler to prevent KeyboardInterrupt
+ try:
+ self._prev_sigint_handler = signal.getsignal(signal.SIGINT)
+ except Exception:
+ self._prev_sigint_handler = None
+ try:
+ def _noop_sigint(_sig, _frm):
+ # no-op; wakeup fd + notifier will handle termination
+ pass
+ signal.signal(signal.SIGINT, _noop_sigint)
+ except Exception:
+ pass
+ # notifier to watch read end
+ notifier = QtCore.QSocketNotifier(self._sigint_r, QtCore.QSocketNotifier.Read)
+ def _on_readable():
+ try:
+ # drain bytes written by signal module
+ while True:
+ try:
+ os.read(self._sigint_r, 1024)
+ except BlockingIOError:
+ break
+ except KeyboardInterrupt:
+ # suppress default Ctrl-C exception; we'll quit gracefully
+ break
+ except Exception:
+ break
+ except Exception:
+ pass
+ # Post cancel event and quit the loop
+ try:
+ self._post_event(YCancelEvent())
+ except Exception:
+ pass
+ try:
+ if loop is not None and loop.isRunning():
+ loop.quit()
+ except Exception:
+ pass
+ notifier.activated.connect(_on_readable)
+ self._sigint_notifier = notifier
+ except Exception:
+ # fall back: plain signal handler attempting to quit
+ def _on_sigint(_sig, _frm):
+ try:
+ self._post_event(YCancelEvent())
+ except Exception:
+ pass
+ try:
+ if loop is not None and loop.isRunning():
+ loop.quit()
+ except Exception:
+ pass
+ try:
+ signal.signal(signal.SIGINT, _on_sigint)
+ except Exception:
+ pass
+
+ def _teardown_sigint_notifier(self):
+ """Remove SIGINT notifier and restore previous wakeup fd."""
+ try:
+ if self._sigint_notifier is not None:
+ try:
+ self._sigint_notifier.setEnabled(False)
+ except Exception:
+ pass
+ self._sigint_notifier = None
+ except Exception:
+ pass
+ try:
+ if self._sigint_r is not None:
+ try:
+ os.close(self._sigint_r)
+ except Exception:
+ pass
+ self._sigint_r = None
+ if self._sigint_w is not None:
+ try:
+ os.close(self._sigint_w)
+ except Exception:
+ pass
+ self._sigint_w = None
+ except Exception:
+ pass
+ try:
+ # restore previous wakeup fd
+ if self._prev_wakeup_fd is not None:
+ try:
+ signal.set_wakeup_fd(self._prev_wakeup_fd)
+ except Exception:
+ pass
+ else:
+ # reset to default (no wakeup fd)
+ try:
+ signal.set_wakeup_fd(-1)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ # restore previous SIGINT handler
+ try:
+ if self._prev_sigint_handler is not None:
+ try:
+ signal.signal(signal.SIGINT, self._prev_sigint_handler)
+ except Exception:
+ pass
+ else:
+ try:
+ signal.signal(signal.SIGINT, signal.default_int_handler)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def _register_default_button(self, button):
+ """Ensure only one Qt push button is tracked as default."""
+ if getattr(self, "_default_button", None) == button:
+ return
+ if getattr(self, "_default_button", None) is not None:
+ try:
+ self._default_button._apply_default_state(False, notify_dialog=False)
+ except Exception:
+ self._logger.exception("Failed to clear previous default button")
+ self._default_button = button
+
+ def _unregister_default_button(self, button):
+ """Drop reference when the dialog loses its default button."""
+ if getattr(self, "_default_button", None) == button:
+ self._default_button = None
+
+ def _clear_default_button(self):
+ """Clear existing default button, if any."""
+ if getattr(self, "_default_button", None) is not None:
+ try:
+ self._default_button._apply_default_state(False, notify_dialog=False)
+ except Exception:
+ self._logger.exception("Failed to reset default button state")
+ self._default_button = None
+
diff --git a/manatools/aui/backends/qt/dumbtabqt.py b/manatools/aui/backends/qt/dumbtabqt.py
new file mode 100644
index 0000000..b76d3a1
--- /dev/null
+++ b/manatools/aui/backends/qt/dumbtabqt.py
@@ -0,0 +1,222 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.qt contains all Qt backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.qt
+'''
+'''
+Qt backend DumbTab (tab bar + single content area)
+
+Implements a simple tab bar using QTabBar and exposes a single child
+content area where applications typically attach a YReplacePoint.
+
+This is a YSelectionWidget: it manages items, single selection, and
+emits WidgetEvent(Activated) when the active tab changes.
+'''
+from PySide6 import QtWidgets, QtCore
+import logging
+from ...yui_common import *
+
+class YDumbTabQt(YSelectionWidget):
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self._logger = logging.getLogger(f"manatools.aui.qt.{self.__class__.__name__}")
+ self._container = None
+ self._tabbar = None
+ self._content = None # placeholder QWidget for the single child
+ # DumbTab is horizontally stretchable and minimally vertically
+ self.setStretchable(YUIDimension.YD_HORIZ, True)
+ self.setStretchable(YUIDimension.YD_VERT, True)
+
+ def widgetClass(self):
+ return "YDumbTab"
+
+ def _create_backend_widget(self):
+ try:
+ container = QtWidgets.QWidget()
+ v = QtWidgets.QVBoxLayout(container)
+ v.setContentsMargins(0, 0, 0, 0)
+ v.setSpacing(5)
+
+ tabbar = QtWidgets.QTabBar()
+ tabbar.setExpanding(False)
+ tabbar.setMovable(False)
+ tabbar.setTabsClosable(False)
+
+ content = QtWidgets.QWidget()
+ content.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding)
+ content_layout = QtWidgets.QVBoxLayout(content)
+ content_layout.setContentsMargins(0, 0, 0, 0)
+
+ v.addWidget(tabbar)
+ v.addWidget(content, 1)
+
+ tabbar.currentChanged.connect(self._on_tab_changed)
+
+ # populate from existing items respecting selected flags
+ selected_idx = -1
+ for idx, it in enumerate(self._items):
+ try:
+ tabbar.addTab(it.label())
+ if it.selected():
+ selected_idx = idx
+ except Exception:
+ tabbar.addTab(str(it))
+ if selected_idx < 0 and len(self._items) > 0:
+ selected_idx = 0
+ try:
+ self._items[0].setSelected(True)
+ except Exception:
+ pass
+ if selected_idx >= 0:
+ try:
+ tabbar.setCurrentIndex(selected_idx)
+ except Exception:
+ pass
+ self._selected_items = [ self._items[selected_idx] ]
+
+ container.setEnabled(bool(self._enabled))
+
+ self._backend_widget = container
+ self._container = container
+ self._tabbar = tabbar
+ self._content = content
+ # If a child was added before backend creation (common in tests), attach it now
+ try:
+ if self.hasChildren():
+ ch = self.firstChild()
+ if ch is not None:
+ w = ch.get_backend_widget()
+ lay = self._content.layout() or QtWidgets.QVBoxLayout(self._content)
+ self._content.setLayout(lay)
+ try:
+ w.setParent(self._content)
+ except Exception:
+ pass
+ lay.addWidget(w)
+ try:
+ w.show()
+ w.updateGeometry()
+ except Exception:
+ pass
+ try:
+ self._logger.debug("YDumbTabQt._create_backend_widget: attached pre-existing child %s", ch.widgetClass())
+ except Exception:
+ pass
+ except Exception:
+ pass
+ try:
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception:
+ pass
+ except Exception:
+ try:
+ self._logger.exception("YDumbTabQt _create_backend_widget failed")
+ except Exception:
+ pass
+
+ def addChild(self, child):
+ # Accept only a single child; attach its backend into content area
+ if self.hasChildren():
+ raise YUIInvalidWidgetException("YDumbTab can only have one child")
+ super().addChild(child)
+ try:
+ if self._content is not None:
+ w = child.get_backend_widget()
+ lay = self._content.layout() or QtWidgets.QVBoxLayout(self._content)
+ self._content.setLayout(lay)
+ try:
+ # Ensure proper parenting before adding to layout to avoid invisibility
+ w.setParent(self._content)
+ except Exception:
+ pass
+ lay.addWidget(w)
+ try:
+ w.show()
+ w.updateGeometry()
+ except Exception:
+ pass
+ try:
+ self._logger.debug("YDumbTabQt.addChild: attached %s into content", child.widgetClass())
+ except Exception:
+ pass
+ except Exception:
+ try:
+ self._logger.exception("YDumbTabQt.addChild failed")
+ except Exception:
+ pass
+
+ def addItem(self, item):
+ super().addItem(item)
+ try:
+ if self._tabbar is not None:
+ idx = self._tabbar.addTab(item.label())
+ if item.selected():
+ try:
+ self._tabbar.setCurrentIndex(idx)
+ except Exception:
+ pass
+ # sync internal selection list
+ self._sync_selection_from_tabbar()
+ except Exception:
+ try:
+ self._logger.exception("YDumbTabQt.addItem failed")
+ except Exception:
+ pass
+
+ def selectItem(self, item, selected=True):
+ # single selection: set current tab to item's index
+ try:
+ if not selected:
+ return
+ idx = None
+ for i, it in enumerate(self._items):
+ if it is item:
+ idx = i
+ break
+ if idx is None:
+ return
+ if self._tabbar is not None:
+ self._tabbar.setCurrentIndex(idx)
+ # sync model
+ for it in self._items:
+ it.setSelected(False)
+ item.setSelected(True)
+ self._selected_items = [item]
+ except Exception:
+ try:
+ self._logger.exception("YDumbTabQt.selectItem failed")
+ except Exception:
+ pass
+
+ def _on_tab_changed(self, index):
+ try:
+ # Update model selection
+ for i, it in enumerate(self._items):
+ try:
+ it.setSelected(i == index)
+ except Exception:
+ pass
+ self._selected_items = [ self._items[index] ] if 0 <= index < len(self._items) else []
+ # Post activation event
+ if self.notify():
+ dlg = self.findDialog()
+ if dlg is not None:
+ dlg._post_event(YWidgetEvent(self, YEventReason.Activated))
+ except Exception:
+ try:
+ self._logger.exception("YDumbTabQt._on_tab_changed failed")
+ except Exception:
+ pass
+
+ def _sync_selection_from_tabbar(self):
+ try:
+ idx = self._tabbar.currentIndex() if self._tabbar is not None else -1
+ self._selected_items = [ self._items[idx] ] if 0 <= idx < len(self._items) else []
+ except Exception:
+ self._selected_items = []
diff --git a/manatools/aui/backends/qt/frameqt.py b/manatools/aui/backends/qt/frameqt.py
new file mode 100644
index 0000000..6e62087
--- /dev/null
+++ b/manatools/aui/backends/qt/frameqt.py
@@ -0,0 +1,297 @@
+"""
+Qt backend implementation for YUI
+"""
+
+from PySide6 import QtWidgets
+import logging
+from ...yui_common import *
+
+
+class YFrameQt(YSingleChildContainerWidget):
+ """
+ Qt backend implementation of YFrame.
+ - Uses QGroupBox to present a labeled framed container.
+ - Single child is placed inside the group's layout.
+ - Exposes simple property support for 'label'.
+ """
+
+ def __init__(self, parent=None, label=""):
+ super().__init__(parent)
+ self._label = label
+ self._backend_widget = None
+ self._group_layout = None
+ self._logger = logging.getLogger(
+ f"manatools.aui.qt.{self.__class__.__name__}"
+ )
+
+ def widgetClass(self):
+ return "YFrame"
+
+ def stretchable(self, dim: YUIDimension):
+ """Return True if the frame should stretch in given dimension."""
+ try:
+ child = self.child()
+ if child is None:
+ return False
+ try:
+ if bool(child.stretchable(dim)):
+ return True
+ except Exception:
+ pass
+ try:
+ if bool(child.weight(dim)):
+ return True
+ except Exception:
+ pass
+ except Exception:
+ pass
+ return False
+
+ def label(self):
+ return self._label
+
+ def setLabel(self, newLabel):
+ """Set the frame label and update the Qt widget if created."""
+ try:
+ self._label = newLabel
+ if getattr(self, "_backend_widget", None) is not None:
+ try:
+ # QGroupBox uses setTitle
+ if hasattr(self._backend_widget, "setTitle"):
+ self._backend_widget.setTitle(self._label)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def _apply_child_policy_and_stretch(self, child_widget):
+ """Compute child's stretch and apply Qt size policy and return stretch."""
+ stretch = 0
+ try:
+ try:
+ weight = int(self.child().weight(YUIDimension.YD_VERT))
+ except Exception:
+ weight = 0
+ try:
+ stretchable_vert = bool(self.child().stretchable(YUIDimension.YD_VERT))
+ except Exception:
+ stretchable_vert = False
+ stretch = weight if weight > 0 else (1 if stretchable_vert else 0)
+ except Exception:
+ stretch = 0
+
+ # set child's QSizePolicy based on logical flags
+ try:
+ sp = child_widget.sizePolicy()
+ try:
+ horiz_expand = (
+ QtWidgets.QSizePolicy.Expanding
+ if bool(self.child().stretchable(YUIDimension.YD_HORIZ))
+ else QtWidgets.QSizePolicy.Fixed
+ )
+ except Exception:
+ horiz_expand = QtWidgets.QSizePolicy.Fixed
+ try:
+ vert_expand = (
+ QtWidgets.QSizePolicy.Expanding if stretch > 0 else QtWidgets.QSizePolicy.Preferred
+ )
+ except Exception:
+ vert_expand = QtWidgets.QSizePolicy.Fixed
+ sp.setHorizontalPolicy(horiz_expand)
+ sp.setVerticalPolicy(vert_expand)
+ child_widget.setSizePolicy(sp)
+ except Exception:
+ pass
+
+ return stretch
+
+ def _attach_child_backend(self):
+ """Attach existing child backend widget to the groupbox layout."""
+ if not (self._backend_widget and self._group_layout and self.child()):
+ return
+ try:
+ w = self.child().get_backend_widget()
+ if not w:
+ return
+
+ # clear existing widgets
+ try:
+ while self._group_layout.count():
+ it = self._group_layout.takeAt(0)
+ if it and it.widget():
+ it.widget().setParent(None)
+ except Exception:
+ pass
+
+ # compute stretch and apply policy
+ stretch = self._apply_child_policy_and_stretch(w)
+
+ # set group size policy to reflect child's desire for expansion
+ try:
+ sp_grp = self._backend_widget.sizePolicy()
+ grp_vert_expand = (
+ QtWidgets.QSizePolicy.Expanding if stretch > 0 else QtWidgets.QSizePolicy.Preferred
+ )
+ sp_grp.setVerticalPolicy(grp_vert_expand)
+ sp_grp.setHorizontalPolicy(QtWidgets.QSizePolicy.Expanding)
+ self._backend_widget.setSizePolicy(sp_grp)
+ except Exception:
+ pass
+
+ # add widget with stretch factor (if supported)
+ try:
+ self._group_layout.addWidget(w, stretch)
+ except Exception:
+ try:
+ self._group_layout.addWidget(w)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def addChild(self, child):
+ """Override to attach backend child when available."""
+ super().addChild(child)
+ self._attach_child_backend()
+
+ def _create_backend_widget(self):
+ """Create the QGroupBox + layout and attach child if present."""
+ try:
+ grp = QtWidgets.QGroupBox(self._label)
+ layout = QtWidgets.QVBoxLayout(grp)
+ # Ensure the group's widget minimum size follows the layout's minimumSizeHint.
+ # This prevents Qt from compressing the groupbox contents to zero when nested
+ # inside other layouts. We keep this local to frame/groupbox layouts.
+ try:
+ layout.setSizeConstraint(QtWidgets.QLayout.SetMinimumSize)
+ except Exception:
+ pass
+ layout.setContentsMargins(6, 6, 6, 6)
+ layout.setSpacing(4)
+ self._backend_widget = grp
+ self._group_layout = layout
+ self._backend_widget.setEnabled(bool(self._enabled))
+
+ # set group's size policy according to logical stretch of this frame
+ try:
+ frame_stretchable = bool(self.stretchable(YUIDimension.YD_VERT))
+ except Exception:
+ frame_stretchable = False
+ try:
+ sp_grp = self._backend_widget.sizePolicy()
+ sp_grp.setHorizontalPolicy(QtWidgets.QSizePolicy.Expanding)
+ sp_grp.setVerticalPolicy(
+ QtWidgets.QSizePolicy.Expanding if frame_stretchable else QtWidgets.QSizePolicy.Preferred
+ )
+ self._backend_widget.setSizePolicy(sp_grp)
+ except Exception:
+ pass
+
+ # attach child if present (apply same policy/stretches)
+ if self.child():
+ try:
+ w = self.child().get_backend_widget()
+ if w:
+ stretch = self._apply_child_policy_and_stretch(w)
+ try:
+ layout.addWidget(w, stretch)
+ except Exception:
+ try:
+ layout.addWidget(w)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ try:
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception:
+ pass
+ return
+ except Exception:
+ # fallback to a plain QWidget container
+ try:
+ container = QtWidgets.QWidget()
+ layout = QtWidgets.QVBoxLayout(container)
+ # Keep fallback container constrained as well (same rationale).
+ try:
+ layout.setSizeConstraint(QtWidgets.QLayout.SetMinimumSize)
+ except Exception:
+ pass
+ layout.setContentsMargins(6, 6, 6, 6)
+ layout.setSpacing(4)
+ self._backend_widget = container
+ self._group_layout = layout
+ if self.child():
+ try:
+ w = self.child().get_backend_widget()
+ if w:
+ stretch = self._apply_child_policy_and_stretch(w)
+ try:
+ layout.addWidget(w, stretch)
+ except Exception:
+ try:
+ layout.addWidget(w)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ try:
+ self._logger.debug("_create_backend_widget (container): <%s>", self.debugLabel())
+ except Exception:
+ pass
+ return
+ except Exception:
+ self._backend_widget = None
+ self._group_layout = None
+ return
+
+ def _set_backend_enabled(self, enabled):
+ """Enable/disable the frame and propagate state to the child."""
+ try:
+ if getattr(self, "_backend_widget", None) is not None:
+ try:
+ self._backend_widget.setEnabled(bool(enabled))
+ except Exception:
+ pass
+ except Exception:
+ pass
+ # propagate to logical child
+ try:
+ child = self.child()
+ if child is not None:
+ try:
+ child.setEnabled(enabled)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def setProperty(self, propertyName, val):
+ """Handle simple properties; returns True if handled."""
+ try:
+ if propertyName == "label":
+ self.setLabel(str(val))
+ return True
+ except Exception:
+ pass
+ return False
+
+ def getProperty(self, propertyName):
+ try:
+ if propertyName == "label":
+ return self.label()
+ except Exception:
+ pass
+ return None
+
+ def propertySet(self):
+ """Return a minimal property set description (used by some backends)."""
+ try:
+ props = YPropertySet()
+ try:
+ props.add(YProperty("label", YPropertyType.YStringProperty))
+ except Exception:
+ pass
+ return props
+ except Exception:
+ return None
diff --git a/manatools/aui/backends/qt/hboxqt.py b/manatools/aui/backends/qt/hboxqt.py
new file mode 100644
index 0000000..ea10a98
--- /dev/null
+++ b/manatools/aui/backends/qt/hboxqt.py
@@ -0,0 +1,169 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.qt contains all Qt backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.qt
+'''
+from PySide6 import QtWidgets, QtCore
+import logging
+from ...yui_common import *
+
+class YHBoxQt(YWidget):
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self._logger = logging.getLogger(f"manatools.aui.qt.{self.__class__.__name__}")
+
+ def widgetClass(self):
+ return "YHBox"
+
+ # Returns the stretchability of the layout box:
+ # * The layout box is stretchable if one of the children is stretchable in
+ # * this dimension or if one of the child widgets has a layout weight in
+ # * this dimension.
+ def stretchable(self, dim):
+ for child in self._children:
+ widget = child.get_backend_widget()
+ expand = bool(child.stretchable(dim))
+ weight = bool(child.weight(dim))
+ if expand or weight:
+ return True
+ # No child is stretchable in this dimension
+ return False
+
+ def _create_backend_widget(self):
+ self._backend_widget = QtWidgets.QWidget()
+ layout = QtWidgets.QHBoxLayout(self._backend_widget)
+ layout.setContentsMargins(1, 1, 1, 1)
+ # Keep the layout constrained to its minimum sizeHint so children are not
+ # compressed to invisible sizes by parent layouts. This is required in our
+ # UI because many child widgets use Preferred/Fixed policies and rely on
+ # the layout's minimumSizeHint to be respected.
+ layout.setSizeConstraint(QtWidgets.QLayout.SetMinimumSize)
+ #layout.setSpacing(1)
+
+ # Map YWidget weights and stretchable flags to Qt layout stretch factors.
+ # Weight semantics: if any child has a positive weight (>0) use those
+ # weights as stretch factors; otherwise give equal stretch (1) to
+ # children that are marked stretchable. Non-stretchable children get 0.
+ try:
+ child_weights = [int(child.weight(YUIDimension.YD_HORIZ) or 0) for child in self._children]
+ except Exception:
+ child_weights = [0 for _ in self._children]
+ has_positive = any(w > 0 for w in child_weights)
+
+ for idx, child in enumerate(self._children):
+ widget = child.get_backend_widget()
+ weight = int(child_weights[idx]) if idx < len(child_weights) else 0
+ if has_positive:
+ stretch = weight
+ else:
+ stretch = 1 if child.stretchable(YUIDimension.YD_HORIZ) else 0
+
+ # If the child will receive extra space, set its QSizePolicy to Expanding
+ try:
+ if stretch > 0:
+ sp = widget.sizePolicy()
+ try:
+ sp.setHorizontalPolicy(QtWidgets.QSizePolicy.Policy.Expanding)
+ except Exception:
+ try:
+ sp.setHorizontalPolicy(QtWidgets.QSizePolicy.Expanding)
+ except Exception:
+ pass
+ widget.setSizePolicy(sp)
+ except Exception:
+ pass
+
+ self._backend_widget.setEnabled(bool(self._enabled))
+ try:
+ self._logger.debug("YHBoxQt: adding child %s stretch=%s weight=%s", child.widgetClass(), stretch, weight)
+ except Exception:
+ pass
+ layout.addWidget(widget, stretch=stretch)
+ try:
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception:
+ pass
+
+ def _set_backend_enabled(self, enabled):
+ """Enable/disable the HBox container and propagate to children."""
+ try:
+ if getattr(self, "_backend_widget", None) is not None:
+ try:
+ self._backend_widget.setEnabled(bool(enabled))
+ except Exception:
+ pass
+ except Exception:
+ pass
+ try:
+ for c in list(getattr(self, "_children", []) or []):
+ try:
+ c.setEnabled(enabled)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def addChild(self, child):
+ """Attach child's backend widget to this HBox when added at runtime."""
+ super().addChild(child)
+ try:
+ if getattr(self, "_backend_widget", None) is None:
+ return
+
+ def _deferred_attach():
+ try:
+ widget = child.get_backend_widget()
+ try:
+ weight = int(child.weight(YUIDimension.YD_HORIZ) or 0)
+ except Exception:
+ weight = 0
+ # If dynamic addition, use explicit weight when >0, otherwise
+ # fall back to stretchable flag (equal-share represented by 1).
+ stretch = weight if weight > 0 else (1 if child.stretchable(YUIDimension.YD_HORIZ) else 0)
+ try:
+ if stretch > 0:
+ sp = widget.sizePolicy()
+ try:
+ sp.setHorizontalPolicy(QtWidgets.QSizePolicy.Policy.Expanding)
+ except Exception:
+ try:
+ sp.setHorizontalPolicy(QtWidgets.QSizePolicy.Expanding)
+ except Exception:
+ pass
+ widget.setSizePolicy(sp)
+ except Exception:
+ pass
+ try:
+ lay = self._backend_widget.layout()
+ if lay is None:
+ lay = QtWidgets.QHBoxLayout(self._backend_widget)
+ self._backend_widget.setLayout(lay)
+ lay.addWidget(widget, stretch=stretch)
+ try:
+ widget.show()
+ except Exception:
+ pass
+ try:
+ widget.updateGeometry()
+ except Exception:
+ pass
+ except Exception:
+ try:
+ self._logger.exception("YHBoxQt.addChild: failed to attach child")
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ try:
+ QtCore.QTimer.singleShot(0, _deferred_attach)
+ except Exception:
+ _deferred_attach()
+ except Exception:
+ pass
diff --git a/manatools/aui/backends/qt/imageqt.py b/manatools/aui/backends/qt/imageqt.py
new file mode 100644
index 0000000..e96a94c
--- /dev/null
+++ b/manatools/aui/backends/qt/imageqt.py
@@ -0,0 +1,323 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+"""
+Python manatools.aui.backends.qt contains Qt backend for YImage
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.qt
+"""
+from PySide6 import QtWidgets, QtGui, QtCore
+import logging
+import os
+from ...yui_common import *
+from .commonqt import _resolve_icon as _qt_resolve_icon
+
+
+class YImageQt(YWidget):
+ def __init__(self, parent=None, imageFileName=""):
+ super().__init__(parent)
+ self._imageFileName = imageFileName
+ self._auto_scale = False
+ self._zero_size = {YUIDimension.YD_HORIZ: False, YUIDimension.YD_VERT: False}
+ self._pixmap = None
+ self._qicon = None
+ # minimal visible size guard
+ self._min_w = 64
+ self._min_h = 64
+ # aspect ratio tracking (w/h). default 1.0
+ self._aspect_ratio = 1.0
+ self._logger = logging.getLogger(f"manatools.aui.qt.{self.__class__.__name__}")
+ self._logger.debug("%s.__init__ file=%s", self.__class__.__name__, imageFileName)
+
+ def widgetClass(self):
+ return "YImage"
+
+ def imageFileName(self):
+ return self._imageFileName
+
+ def setImage(self, imageFileName):
+ try:
+ self._imageFileName = imageFileName
+ if getattr(self, '_backend_widget', None) is not None:
+ # Try resolving via common Qt helper (theme icon or filesystem)
+ try:
+ ico = _qt_resolve_icon(imageFileName)
+ except Exception:
+ ico = None
+ if ico is not None:
+ try:
+ self._qicon = ico
+ self._pixmap = None
+ # update aspect ratio from icon's largest available size
+ try:
+ sizes = ico.availableSizes()
+ if sizes:
+ s = max(sizes, key=lambda sz: sz.width()*sz.height())
+ self._aspect_ratio = max(0.0001, float(s.width()) / float(s.height() or 1))
+ else:
+ self._aspect_ratio = 1.0
+ except Exception:
+ self._aspect_ratio = 1.0
+ # re-apply constraints and redraw
+ self._apply_size_policy()
+ self._apply_pixmap()
+ return
+ except Exception:
+ pass
+
+ # Fallback: try loading as pixmap from filesystem
+ if os.path.exists(imageFileName):
+ try:
+ self._pixmap = QtGui.QPixmap(imageFileName)
+ # update aspect ratio from pixmap
+ try:
+ w = self._pixmap.width()
+ h = self._pixmap.height()
+ if w > 0 and h > 0:
+ self._aspect_ratio = max(0.0001, float(w) / float(h))
+ except Exception:
+ pass
+ # re-apply constraints and redraw
+ self._apply_size_policy()
+ self._apply_pixmap()
+ except Exception:
+ self._logger.exception("setImage: failed to load QPixmap %s", imageFileName)
+ else:
+ self._logger.error("setImage: file not found: %s", imageFileName)
+ except Exception:
+ self._logger.exception("setImage failed")
+
+ def autoScale(self):
+ return bool(self._auto_scale)
+
+ def setAutoScale(self, on=True):
+ try:
+ self._auto_scale = bool(on)
+ # If autoscale is enabled, let Qt expand the widget when stretchable
+ self._apply_size_policy()
+ self._apply_pixmap()
+ except Exception:
+ self._logger.exception("setAutoScale failed")
+
+ def hasZeroSize(self, dim):
+ return bool(self._zero_size.get(dim, False))
+
+ def setZeroSize(self, dim, zeroSize=True):
+ self._zero_size[dim] = bool(zeroSize)
+ try:
+ self._apply_size_policy()
+ except Exception:
+ pass
+
+ def _create_backend_widget(self):
+ try:
+ # Use a QLabel subclass with height-for-width to keep aspect ratio.
+ class _ImageLabel(QtWidgets.QLabel):
+ def __init__(self, owner, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self._owner = owner
+
+ #def hasHeightForWidth(self):
+ # # Enable height-for-width only when autoscale is ON
+ # try:
+ # return bool(getattr(self._owner, "_auto_scale", False))
+ # except Exception:
+ # return False
+#
+ #def heightForWidth(self, w):
+ # try:
+ # ratio = max(0.0001, float(getattr(self._owner, "_aspect_ratio", 1.0)))
+ # h = int(max(self._owner._min_h, float(w) / ratio))
+ # return h
+ # except Exception:
+ # return super().heightForWidth(w)
+#
+ def resizeEvent(self, ev):
+ super().resizeEvent(ev)
+ try:
+ self._owner._apply_pixmap()
+ except Exception:
+ pass
+
+ def sizeHint(self) -> QtCore.QSize:
+ """
+ Return the recommended size for the widget.
+
+ This considers zero-size settings - if a dimension has zero size
+ enabled, it returns 0 for that dimension.
+ """
+ if self._owner._auto_scale:
+ # When auto-scaling, size hint depends on parent
+ return super().sizeHint()
+
+ base_hint = super().sizeHint()
+
+ width = 0 if self._owner.stretchable(YUIDimension.YD_HORIZ) else base_hint.width()
+ height = 0 if self._owner.stretchable(YUIDimension.YD_VERT) else base_hint.height()
+ return QtCore.QSize(width, height)
+
+ def minimumSizeHint(self) -> QtCore.QSize:
+ """
+ Return the minimum recommended size for the widget.
+
+ This considers zero-size settings.
+ """
+ if self._owner._auto_scale:
+ # When auto-scaling, can shrink to 0
+ return QtCore.QSize(0, 0)
+
+ base_hint = super().minimumSizeHint()
+
+ width = 0 if self._owner.stretchable(YUIDimension.YD_HORIZ) else base_hint.width()
+ height = 0 if self._owner.stretchable(YUIDimension.YD_VERT) else base_hint.height()
+
+ return QtCore.QSize(width, height)
+
+ self._backend_widget = _ImageLabel(self)
+ self._backend_widget.setAlignment(QtCore.Qt.AlignCenter)
+ # enforce a small minimum so it never vanishes
+ try:
+ self._backend_widget.setMinimumSize(self._min_w, self._min_h)
+ except Exception:
+ pass
+ if self._imageFileName:
+ try:
+ self.setImage(self._imageFileName)
+ except Exception:
+ # Fallback: try direct filesystem load
+ try:
+ if os.path.exists(self._imageFileName):
+ self._pixmap = QtGui.QPixmap(self._imageFileName)
+ self._apply_size_policy()
+ self._apply_pixmap()
+ except Exception:
+ pass
+ self._apply_size_policy()
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception:
+ self._logger.exception("_create_backend_widget failed")
+
+ def _apply_size_policy(self):
+ try:
+ if getattr(self, '_backend_widget', None) is None:
+ return
+
+ if self._auto_scale:
+ self._backend_widget.setScaledContents(False)
+ # When auto-scaling, maintain aspect ratio by default
+ self._backend_widget.setSizePolicy(QtWidgets.QSizePolicy.Policy.Expanding,
+ QtWidgets.QSizePolicy.Policy.Expanding)
+ else:
+ self._backend_widget.setScaledContents(True)
+ # Respect stretch flags; in autoscale allow height-for-width by not fixing vertical
+ horiz = QtWidgets.QSizePolicy.Expanding if self.stretchable(YUIDimension.YD_HORIZ) else QtWidgets.QSizePolicy.Fixed
+ vert = QtWidgets.QSizePolicy.Expanding if self.stretchable(YUIDimension.YD_VERT) else QtWidgets.QSizePolicy.Fixed
+ try:
+ sp = self._backend_widget.sizePolicy()
+ sp.setHorizontalPolicy(horiz)
+ sp.setVerticalPolicy(vert)
+ self._backend_widget.setSizePolicy(sp)
+ except Exception:
+ self._logger.exception("_apply_size_policy: setSizePolicy failed")
+
+ # Reset caps: in autoscale mode allow full growth in both axes.
+ try:
+ QWIDGETSIZE_MAX = getattr(QtWidgets, "QWIDGETSIZE_MAX", 16777215)
+ if self._auto_scale:
+ self._backend_widget.setMaximumWidth(QWIDGETSIZE_MAX)
+ self._backend_widget.setMaximumHeight(QWIDGETSIZE_MAX)
+ else:
+ # Non-autoscale: cap non-stretch axes to source size or a sane default
+ pm_w = pm_h = None
+ if isinstance(getattr(self, "_pixmap", None), QtGui.QPixmap) and not self._pixmap.isNull():
+ pm_w, pm_h = self._pixmap.width(), self._pixmap.height()
+ elif getattr(self, "_qicon", None) is not None:
+ pm_w, pm_h = 64, 64
+ if self.stretchable(YUIDimension.YD_HORIZ):
+ self._backend_widget.setMaximumWidth(QWIDGETSIZE_MAX)
+ else:
+ if pm_w:
+ self._backend_widget.setMaximumWidth(max(self._backend_widget.maximumWidth(), pm_w))
+ if self.stretchable(YUIDimension.YD_VERT):
+ self._backend_widget.setMaximumHeight(QWIDGETSIZE_MAX)
+ else:
+ if pm_h:
+ self._backend_widget.setMaximumHeight(max(self._backend_widget.maximumHeight(), pm_h))
+ except Exception:
+ self._logger.exception("_apply_size_policy: max size tuning failed")
+
+ # Keep a small minimum visible size
+ try:
+ self._backend_widget.setMinimumSize(self._min_w, self._min_h)
+ except Exception:
+ pass
+ except Exception:
+ self._logger.exception("_apply_size_policy failed")
+
+ def _apply_pixmap(self):
+ try:
+ if getattr(self, '_backend_widget', None) is None:
+ return
+ size = self._backend_widget.size()
+ if not size.isValid():
+ size = QtCore.QSize(self._min_w, self._min_h)
+
+ src_icon = self._qicon
+ src_pm = self._pixmap if isinstance(self._pixmap, QtGui.QPixmap) and not self._pixmap.isNull() else None
+
+ # AutoScale ON => keep aspect ratio to widget size (height-for-width will grow height as width grows)
+ if self._auto_scale:
+ # compute target size from current width and aspect ratio; clamp to widget size
+ try:
+ ratio = max(0.0001, float(self._aspect_ratio))
+ except Exception:
+ ratio = 1.0
+ target_w = max(1, size.width())
+ target_h = int(max(self._min_h, target_w / ratio))
+ target_h = min(target_h, size.height())
+
+ if src_icon is not None:
+ pm = src_icon.pixmap(QtCore.QSize(target_w, target_h))
+ if pm and not pm.isNull():
+ self._backend_widget.setPixmap(pm)
+ return
+ if src_pm is not None:
+ scaled = src_pm.scaled(QtCore.QSize(target_w, target_h), QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation)
+ self._backend_widget.setPixmap(scaled)
+ else:
+ self._backend_widget.clear()
+ return
+
+ # AutoScale OFF => stretch along selected dimensions, allow deformation
+ target_w = size.width() if self.stretchable(YUIDimension.YD_HORIZ) else (src_pm.width() if src_pm else self._min_w)
+ target_h = size.height() if self.stretchable(YUIDimension.YD_VERT) else (src_pm.height() if src_pm else self._min_h)
+ target_w = max(1, target_w)
+ target_h = max(1, target_h)
+
+ if src_icon is not None:
+ pm = src_icon.pixmap(QtCore.QSize(target_w, target_h))
+ if pm and not pm.isNull():
+ self._backend_widget.setPixmap(pm)
+ return
+
+ if src_pm is not None:
+ scaled = src_pm.scaled(target_w, target_h, QtCore.Qt.IgnoreAspectRatio, QtCore.Qt.SmoothTransformation)
+ self._backend_widget.setPixmap(scaled)
+ else:
+ self._backend_widget.clear()
+ except Exception:
+ self._logger.exception("_apply_pixmap failed")
+
+ def setStretchable(self, dim, new_stretch):
+ try:
+ super().setStretchable(dim, new_stretch)
+ except Exception:
+ pass
+ try:
+ self._apply_size_policy()
+ self._apply_pixmap()
+ except Exception:
+ pass
diff --git a/manatools/aui/backends/qt/inputfieldqt.py b/manatools/aui/backends/qt/inputfieldqt.py
new file mode 100644
index 0000000..efc9e6b
--- /dev/null
+++ b/manatools/aui/backends/qt/inputfieldqt.py
@@ -0,0 +1,173 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.qt contains all Qt backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.qt
+'''
+from PySide6 import QtWidgets, QtCore
+import logging
+from ...yui_common import *
+
+class YInputFieldQt(YWidget):
+ def __init__(self, parent=None, label="", password_mode=False):
+ super().__init__(parent)
+ self._label = label
+ self._value = ""
+ self._password_mode = password_mode
+ self._logger = logging.getLogger(f"manatools.aui.qt.{self.__class__.__name__}")
+
+ def widgetClass(self):
+ return "YInputField"
+
+ def value(self):
+ return self._value
+
+ def setValue(self, text):
+ self._value = text
+ if hasattr(self, '_entry_widget') and self._entry_widget:
+ self._entry_widget.setText(text)
+
+ def label(self):
+ return self._label
+
+ def _create_backend_widget(self):
+ container = QtWidgets.QWidget()
+ layout = QtWidgets.QVBoxLayout(container)
+ layout.setContentsMargins(0, 0, 0, 0)
+
+ self._qlbl = QtWidgets.QLabel(self._label)
+ layout.addWidget(self._qlbl)
+ if not self._label:
+ self._qlbl.hide()
+
+ entry = QtWidgets.QLineEdit()
+ if self._password_mode:
+ entry.setEchoMode(QtWidgets.QLineEdit.Password)
+
+ entry.setText(self._value)
+ entry.textChanged.connect(self._on_text_changed)
+ layout.addWidget(entry)
+
+ self._backend_widget = container
+ self._entry_widget = entry
+ self._backend_widget.setEnabled(bool(self._enabled))
+
+ # Apply initial stretch policy
+ try:
+ self._apply_stretch_policy()
+ except Exception:
+ pass
+ try:
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception:
+ pass
+
+ def _set_backend_enabled(self, enabled):
+ """Enable/disable the input field: entry and container."""
+ try:
+ if getattr(self, "_entry_widget", None) is not None:
+ try:
+ self._entry_widget.setEnabled(bool(enabled))
+ except Exception:
+ pass
+ except Exception:
+ pass
+ try:
+ if getattr(self, "_backend_widget", None) is not None:
+ try:
+ self._backend_widget.setEnabled(bool(enabled))
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def _on_text_changed(self, text):
+ self._value = text
+ # Post ValueChanged when notify is enabled
+ try:
+ dlg = self.findDialog()
+ if dlg is not None and self.notify():
+ dlg._post_event(YWidgetEvent(self, YEventReason.ValueChanged))
+ except Exception:
+ pass
+
+ def setLabel(self, label):
+ self._label = label
+ try:
+ if hasattr(self, '_qlbl') and self._qlbl is not None:
+ self._qlbl.setText(str(label))
+ if not label:
+ self._qlbl.hide()
+ else:
+ self._qlbl.show()
+ except Exception:
+ pass
+
+ def setStretchable(self, dim, new_stretch):
+ try:
+ super().setStretchable(dim, new_stretch)
+ except Exception:
+ pass
+ try:
+ self._apply_stretch_policy()
+ except Exception:
+ pass
+
+ def _apply_stretch_policy(self):
+ """Apply independent horizontal/vertical stretch policies for single-line input."""
+ if not hasattr(self, '_backend_widget') or self._backend_widget is None:
+ return
+
+ horiz = bool(self.stretchable(YUIDimension.YD_HORIZ))
+ vert = bool(self.stretchable(YUIDimension.YD_VERT))
+
+ # Compute approximate metrics
+ try:
+ fm = self._entry_widget.fontMetrics() if hasattr(self, '_entry_widget') else None
+ char_w = fm.horizontalAdvance('M') if fm is not None else 8
+ line_h = fm.lineSpacing() if fm is not None else 18
+ except Exception:
+ char_w, line_h = 8, 18
+
+ desired_chars = 20
+ try:
+ qlabel_h = self._qlbl.sizeHint().height() if hasattr(self, '_qlbl') and self._qlbl is not None else 0
+ except Exception:
+ qlabel_h = 0
+
+ w_px = int(char_w * desired_chars) + 12
+ h_px = int(line_h) + qlabel_h + 8
+
+ # Policy per axis
+ try:
+ self._backend_widget.setSizePolicy(
+ QtWidgets.QSizePolicy.Expanding if horiz else QtWidgets.QSizePolicy.Fixed,
+ QtWidgets.QSizePolicy.Expanding if vert else QtWidgets.QSizePolicy.Fixed,
+ )
+ except Exception:
+ pass
+
+ # Width constraint
+ try:
+ if not horiz:
+ self._backend_widget.setFixedWidth(w_px)
+ else:
+ self._backend_widget.setMinimumWidth(w_px)
+ self._backend_widget.setMaximumWidth(16777215)
+ except Exception:
+ pass
+
+ # Height constraint
+ try:
+ if not vert:
+ self._backend_widget.setFixedHeight(h_px)
+ else:
+ self._backend_widget.setMinimumHeight(0)
+ self._backend_widget.setMaximumHeight(16777215)
+ except Exception:
+ pass
diff --git a/manatools/aui/backends/qt/intfieldqt.py b/manatools/aui/backends/qt/intfieldqt.py
new file mode 100644
index 0000000..7acb81f
--- /dev/null
+++ b/manatools/aui/backends/qt/intfieldqt.py
@@ -0,0 +1,254 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.qt contains all Qt backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.qt
+'''
+from PySide6 import QtWidgets, QtCore
+import logging
+from ...yui_common import *
+
+
+class YIntFieldQt(YWidget):
+ def __init__(self, parent=None, label="", minValue=0, maxValue=100, initialValue=0):
+ super().__init__(parent)
+ self._label = label
+ self._min = int(minValue)
+ self._max = int(maxValue)
+ self._value = int(initialValue)
+ self._logger = logging.getLogger(f"manatools.aui.qt.{self.__class__.__name__}")
+
+ def widgetClass(self):
+ return "YIntField"
+
+ def value(self):
+ return int(self._value)
+
+ def setValue(self, val):
+ try:
+ v = int(val)
+ except Exception:
+ try:
+ self._logger.debug("setValue: invalid int %r", val)
+ except Exception:
+ pass
+ return
+ if v < self._min:
+ v = self._min
+ if v > self._max:
+ v = self._max
+ self._value = v
+ try:
+ if getattr(self, '_spinbox', None) is not None:
+ try:
+ self._spinbox.setValue(self._value)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def minValue(self):
+ return int(self._min)
+
+ def maxValue(self):
+ return int(self._max)
+
+ def setMinValue(self, val):
+ try:
+ self._min = int(val)
+ except Exception:
+ return
+ try:
+ if getattr(self, '_spinbox', None) is not None:
+ try:
+ self._spinbox.setMinimum(self._min)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def setMaxValue(self, val):
+ try:
+ self._max = int(val)
+ except Exception:
+ return
+ try:
+ if getattr(self, '_spinbox', None) is not None:
+ try:
+ self._spinbox.setMaximum(self._max)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def label(self):
+ return self._label
+
+ def _create_backend_widget(self):
+ try:
+ container = QtWidgets.QWidget()
+ layout = QtWidgets.QVBoxLayout(container)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(2)
+ if self._label:
+ lbl = QtWidgets.QLabel(self._label)
+ try:
+ lbl.setAlignment(QtCore.Qt.AlignmentFlag.AlignLeft)
+ except Exception:
+ try:
+ lbl.setAlignment(QtCore.Qt.AlignLeft)
+ except Exception:
+ pass
+ # keep label from expanding vertically
+ try:
+ sp_lbl = lbl.sizePolicy()
+ try:
+ sp_lbl.setHorizontalPolicy(QtWidgets.QSizePolicy.Policy.Preferred)
+ sp_lbl.setVerticalPolicy(QtWidgets.QSizePolicy.Policy.Fixed)
+ except Exception:
+ try:
+ sp_lbl.setHorizontalPolicy(QtWidgets.QSizePolicy.Preferred)
+ sp_lbl.setVerticalPolicy(QtWidgets.QSizePolicy.Fixed)
+ except Exception:
+ pass
+ lbl.setSizePolicy(sp_lbl)
+ except Exception:
+ pass
+ layout.addWidget(lbl)
+ self._label_widget = lbl
+
+ spin = QtWidgets.QSpinBox()
+ try:
+ spin.setRange(self._min, self._max)
+ spin.setValue(self._value)
+ except Exception:
+ pass
+ try:
+ spin.valueChanged.connect(self._on_value_changed)
+ except Exception:
+ pass
+ layout.addWidget(spin)
+
+ self._backend_widget = container
+ self._spinbox = spin
+ try:
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception:
+ pass
+ # apply initial size policy from stretchable hints
+ try:
+ self._apply_size_policy()
+ except Exception:
+ pass
+ except Exception as e:
+ try:
+ logging.getLogger("manatools.aui.qt.intfield").exception("Error creating Qt IntField backend: %s", e)
+ except Exception:
+ pass
+
+ def _set_backend_enabled(self, enabled):
+ try:
+ if getattr(self, '_spinbox', None) is not None:
+ try:
+ self._spinbox.setEnabled(bool(enabled))
+ except Exception:
+ pass
+ except Exception:
+ pass
+ try:
+ if getattr(self, '_label_widget', None) is not None:
+ try:
+ self._label_widget.setEnabled(bool(enabled))
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def _on_value_changed(self, val):
+ try:
+ self._value = int(val)
+ except Exception:
+ return
+ # If notify is enabled, post a ValueChanged widget event
+ try:
+ if self.notify():
+ dlg = self.findDialog()
+ if dlg is not None:
+ try:
+ dlg._post_event(YWidgetEvent(self, YEventReason.ValueChanged))
+ except Exception:
+ try:
+ self._logger.debug("Failed to post ValueChanged event")
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def _apply_size_policy(self):
+ """Apply size policy to container and spinbox according to stretchable flags."""
+ try:
+ # horizontal policy
+ try:
+ horiz = QtWidgets.QSizePolicy.Policy.Expanding if self.stretchable(YUIDimension.YD_HORIZ) else QtWidgets.QSizePolicy.Policy.Fixed
+ except Exception:
+ self._logger.debug("Failed to get horizontal stretchable policy")
+ try:
+ horiz = QtWidgets.QSizePolicy.Expanding if self.stretchable(YUIDimension.YD_HORIZ) else QtWidgets.QSizePolicy.Fixed
+ except Exception:
+ self._logger.debug("Failed to get horizontal stretchable policy fallback")
+ horiz = QtWidgets.QSizePolicy.Preferred
+ # vertical policy
+ try:
+ vert = QtWidgets.QSizePolicy.Policy.Expanding if self.stretchable(YUIDimension.YD_VERT) else QtWidgets.QSizePolicy.Policy.Fixed
+ except Exception:
+ try:
+ vert = QtWidgets.QSizePolicy.Expanding if self.stretchable(YUIDimension.YD_VERT) else QtWidgets.QSizePolicy.Fixed
+ except Exception:
+ vert = QtWidgets.QSizePolicy.Preferred
+
+ if getattr(self, '_backend_widget', None) is not None:
+ try:
+ sp = self._backend_widget.sizePolicy()
+ try:
+ sp.setHorizontalPolicy(horiz)
+ sp.setVerticalPolicy(vert)
+ except Exception:
+ pass
+ try:
+ self._backend_widget.setSizePolicy(sp)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ if getattr(self, '_spinbox', None) is not None:
+ try:
+ sp2 = self._spinbox.sizePolicy()
+ try:
+ sp2.setHorizontalPolicy(horiz)
+ sp2.setVerticalPolicy(vert)
+ except Exception:
+ pass
+ try:
+ self._spinbox.setSizePolicy(sp2)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def setStretchable(self, dim, new_stretch):
+ try:
+ super().setStretchable(dim, new_stretch)
+ except Exception:
+ pass
+ try:
+ self._apply_size_policy()
+ except Exception:
+ pass
diff --git a/manatools/aui/backends/qt/labelqt.py b/manatools/aui/backends/qt/labelqt.py
new file mode 100644
index 0000000..9bc2e2e
--- /dev/null
+++ b/manatools/aui/backends/qt/labelqt.py
@@ -0,0 +1,178 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.qt contains all Qt backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.qt
+'''
+from PySide6 import QtWidgets, QtCore
+import logging
+from ...yui_common import *
+
+class YLabelQt(YWidget):
+ def __init__(self, parent=None, text="", isHeading=False, isOutputField=False):
+ super().__init__(parent)
+ self._text = text
+ self._is_heading = isHeading
+ self._is_output_field = isOutputField
+ self._auto_wrap = False
+ self._logger = logging.getLogger(f"manatools.aui.qt.{self.__class__.__name__}")
+
+ def widgetClass(self):
+ return "YLabel"
+
+ def text(self):
+ return self._text
+
+ def isHeading(self) -> bool:
+ return bool(self._is_heading)
+
+ def isOutputField(self) -> bool:
+ return bool(self._is_output_field)
+
+ def label(self):
+ return self.text()
+
+ def value(self):
+ return self.text()
+
+ def setText(self, new_text):
+ self._text = new_text
+ if self._backend_widget:
+ self._backend_widget.setText(new_text)
+
+ def setValue(self, newValue):
+ self.setText(newValue)
+
+ def setLabel(self, newLabel):
+ self.setText(newLabel)
+
+ def autoWrap(self) -> bool:
+ return bool(self._auto_wrap)
+
+ def setAutoWrap(self, on: bool = True):
+ self._auto_wrap = bool(on)
+ try:
+ if getattr(self, "_backend_widget", None) is not None:
+ self._backend_widget.setWordWrap(self._auto_wrap)
+ except Exception:
+ pass
+
+ def _create_backend_widget(self):
+ self._backend_widget = QtWidgets.QLabel(self._text)
+ try:
+ self._backend_widget.setWordWrap(bool(self._auto_wrap))
+ except Exception:
+ pass
+ # Output field: allow text selection like a read-only input
+ try:
+ if self._is_output_field:
+ flags = (
+ QtCore.Qt.TextSelectableByMouse |
+ QtCore.Qt.TextSelectableByKeyboard
+ )
+ self._backend_widget.setTextInteractionFlags(flags)
+ # Focus policy to allow keyboard selection
+ self._backend_widget.setFocusPolicy(QtCore.Qt.StrongFocus)
+ else:
+ self._backend_widget.setTextInteractionFlags(QtCore.Qt.NoTextInteraction)
+ except Exception:
+ pass
+ if self._is_heading:
+ font = self._backend_widget.font()
+ font.setBold(True)
+ font.setPointSize(font.pointSize() + 2)
+ self._backend_widget.setFont(font)
+ self._backend_widget.setEnabled(bool(self._enabled))
+ try:
+ if self._help_text:
+ self._backend_widget.setToolTip(self._help_text)
+ except Exception:
+ self._logger.exception("Failed to set tooltip text", exc_info=True)
+ try:
+ self._backend_widget.setVisible(self.visible())
+ except Exception:
+ self._logger.exception("Failed to set widget visibility", exc_info=True)
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+
+ try:
+ # apply initial size policy according to any stretch hints
+ self._apply_size_policy()
+ except Exception:
+ pass
+
+ def _set_backend_enabled(self, enabled):
+ """Enable/disable the QLabel backend."""
+ try:
+ if getattr(self, "_backend_widget", None) is not None:
+ try:
+ self._backend_widget.setEnabled(bool(enabled))
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def _apply_size_policy(self):
+ """Apply Qt size policy based on `stretchable` hints."""
+ try:
+ try:
+ horiz = QtWidgets.QSizePolicy.Policy.Expanding if self.stretchable(YUIDimension.YD_HORIZ) else QtWidgets.QSizePolicy.Policy.Preferred
+ except Exception:
+ try:
+ horiz = QtWidgets.QSizePolicy.Expanding if self.stretchable(YUIDimension.YD_HORIZ) else QtWidgets.QSizePolicy.Preferred
+ except Exception:
+ horiz = QtWidgets.QSizePolicy.Preferred
+ try:
+ vert = QtWidgets.QSizePolicy.Policy.Expanding if self.stretchable(YUIDimension.YD_VERT) else QtWidgets.QSizePolicy.Policy.Preferred
+ except Exception:
+ try:
+ vert = QtWidgets.QSizePolicy.Expanding if self.stretchable(YUIDimension.YD_VERT) else QtWidgets.QSizePolicy.Preferred
+ except Exception:
+ vert = QtWidgets.QSizePolicy.Preferred
+
+ if getattr(self, '_backend_widget', None) is not None:
+ try:
+ sp = self._backend_widget.sizePolicy()
+ try:
+ sp.setHorizontalPolicy(horiz)
+ sp.setVerticalPolicy(vert)
+ except Exception:
+ pass
+ try:
+ self._backend_widget.setSizePolicy(sp)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def setStretchable(self, dim, new_stretch):
+ try:
+ super().setStretchable(dim, new_stretch)
+ except Exception:
+ pass
+ try:
+ self._apply_size_policy()
+ except Exception:
+ pass
+
+ def setVisible(self, visible=True):
+ super().setVisible(visible)
+ try:
+ if getattr(self, "_backend_widget", None) is not None:
+ self._backend_widget.setVisible(visible)
+ except Exception:
+ self._logger.exception("setVisible failed", exc_info=True)
+
+ def setHelpText(self, help_text: str):
+ super().setHelpText(help_text)
+ try:
+ if getattr(self, "_backend_widget", None) is not None:
+ self._backend_widget.setToolTip(help_text)
+ except Exception:
+ self._logger.exception("setHelpText failed", exc_info=True)
diff --git a/manatools/aui/backends/qt/logviewqt.py b/manatools/aui/backends/qt/logviewqt.py
new file mode 100644
index 0000000..bd2af44
--- /dev/null
+++ b/manatools/aui/backends/qt/logviewqt.py
@@ -0,0 +1,167 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.qt contains all Qt backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.qt
+'''
+from PySide6 import QtWidgets, QtCore
+import logging
+from ...yui_common import *
+
+
+class YLogViewQt(YWidget):
+ """Qt backend for YLogView using QPlainTextEdit in a container with an optional QLabel.
+ - Stores log lines with optional max retention (storedLines==0 means unlimited).
+ - Stretchable horizontally and vertically.
+ - Public API mirrors libyui's YLogView: label, visibleLines, maxLines, appendLines, clearText, etc.
+ """
+ def __init__(self, parent=None, label: str = "", visibleLines: int = 10, storedLines: int = 0):
+ super().__init__(parent)
+ self._logger = logging.getLogger(f"manatools.aui.qt.{self.__class__.__name__}")
+ self._label = label or ""
+ self._visible = max(1, int(visibleLines or 10))
+ self._max_lines = max(0, int(storedLines or 0))
+ self._lines = []
+ try:
+ self.setStretchable(YUIDimension.YD_HORIZ, True)
+ self.setStretchable(YUIDimension.YD_VERT, True)
+ except Exception:
+ pass
+
+ def widgetClass(self):
+ return "YLogView"
+
+ # API
+ def label(self) -> str:
+ return self._label
+
+ def setLabel(self, label: str):
+ self._label = label or ""
+ try:
+ if getattr(self, "_label_widget", None) is not None:
+ self._label_widget.setText(self._label)
+ except Exception:
+ self._logger.exception("setLabel failed")
+
+ def visibleLines(self) -> int:
+ return int(self._visible)
+
+ def setVisibleLines(self, newVisibleLines: int):
+ self._visible = max(1, int(newVisibleLines or 1))
+ try:
+ self._apply_preferred_height()
+ except Exception:
+ self._logger.exception("setVisibleLines apply height failed")
+
+ def maxLines(self) -> int:
+ return int(self._max_lines)
+
+ def setMaxLines(self, newMaxLines: int):
+ self._max_lines = max(0, int(newMaxLines or 0))
+ self._trim_if_needed()
+ self._update_display()
+
+ def logText(self) -> str:
+ return "\n".join(self._lines)
+
+ def setLogText(self, text: str):
+ try:
+ raw = [] if text is None else str(text).splitlines()
+ self._lines = raw
+ self._trim_if_needed()
+ self._update_display()
+ except Exception:
+ self._logger.exception("setLogText failed")
+
+ def lastLine(self) -> str:
+ return self._lines[-1] if self._lines else ""
+
+ def appendLines(self, text: str):
+ try:
+ if text is None:
+ return
+ for ln in str(text).splitlines():
+ self._lines.append(ln)
+ self._trim_if_needed()
+ self._update_display(scroll_end=True)
+ except Exception:
+ self._logger.exception("appendLines failed")
+
+ def clearText(self):
+ self._lines = []
+ self._update_display()
+
+ def lines(self) -> int:
+ return len(self._lines)
+
+ # Internals
+ def _trim_if_needed(self):
+ try:
+ if self._max_lines > 0 and len(self._lines) > self._max_lines:
+ self._lines = self._lines[-self._max_lines:]
+ except Exception:
+ self._logger.exception("trim failed")
+
+ def _apply_preferred_height(self):
+ try:
+ if getattr(self, "_text", None) is not None:
+ fm = self._text.fontMetrics()
+ h = fm.lineSpacing() * (self._visible + 1)
+ self._text.setMinimumHeight(h)
+ except Exception:
+ pass
+
+ def _update_display(self, scroll_end: bool = False):
+ try:
+ if getattr(self, "_text", None) is not None:
+ self._text.setPlainText("\n".join(self._lines))
+ if scroll_end:
+ try:
+ self._text.moveCursor(self._text.textCursor().End)
+ except Exception:
+ pass
+ except Exception:
+ self._logger.exception("update_display failed")
+
+ def _create_backend_widget(self):
+ container = QtWidgets.QWidget()
+ lay = QtWidgets.QVBoxLayout(container)
+ lay.setContentsMargins(0, 0, 0, 0)
+ if self._label:
+ lbl = QtWidgets.QLabel(self._label)
+ self._label_widget = lbl
+ lay.addWidget(lbl)
+ txt = QtWidgets.QPlainTextEdit()
+ txt.setReadOnly(True)
+ try:
+ txt.setLineWrapMode(QtWidgets.QPlainTextEdit.NoWrap)
+ except Exception:
+ pass
+ sp = txt.sizePolicy()
+ try:
+ sp.setHorizontalPolicy(QtWidgets.QSizePolicy.Expanding)
+ sp.setVerticalPolicy(QtWidgets.QSizePolicy.Expanding)
+ except Exception:
+ pass
+ txt.setSizePolicy(sp)
+ lay.addWidget(txt)
+ self._text = txt
+ self._apply_preferred_height()
+ self._update_display()
+ self._backend_widget = container
+ try:
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception:
+ pass
+
+ def _set_backend_enabled(self, enabled):
+ try:
+ if getattr(self, "_backend_widget", None) is not None:
+ self._backend_widget.setEnabled(bool(enabled))
+ except Exception:
+ pass
diff --git a/manatools/aui/backends/qt/menubarqt.py b/manatools/aui/backends/qt/menubarqt.py
new file mode 100644
index 0000000..5e43396
--- /dev/null
+++ b/manatools/aui/backends/qt/menubarqt.py
@@ -0,0 +1,335 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Qt backend: YMenuBar implementation using QMenuBar.
+'''
+from PySide6 import QtWidgets, QtCore, QtGui
+import logging
+from ...yui_common import *
+from .commonqt import _resolve_icon
+
+
+class YMenuBarQt(YWidget):
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self._logger = logging.getLogger(f"manatools.aui.qt.{self.__class__.__name__}")
+ self._menus = [] # list of YMenuItem (is_menu=True)
+ self._menu_to_qmenu = {}
+ self._item_to_qaction = {}
+ # Default stretch: horizontal True, vertical False
+ try:
+ self.setStretchable(YUIDimension.YD_HORIZ, True)
+ self.setStretchable(YUIDimension.YD_VERT, False)
+ except Exception:
+ pass
+
+ def widgetClass(self):
+ return "YMenuBar"
+
+ def addMenu(self, label: str="", icon_name: str = "", menu: YMenuItem = None) -> YMenuItem:
+ """Add a menu by label or by an existing YMenuItem (is_menu=True) to the menubar."""
+ m = None
+ if menu is not None:
+ if not menu.isMenu():
+ raise ValueError("Provided YMenuItem is not a menu (is_menu=True)")
+ m = menu
+ else:
+ if not label:
+ raise ValueError("Menu label must be provided when no YMenuItem is given")
+ m = YMenuItem(label, icon_name, enabled=True, is_menu=True)
+ self._menus.append(m)
+ if self._backend_widget:
+ self._ensure_menu_rendered(m)
+ return m
+
+ def addItem(self, menu: YMenuItem, label: str, icon_name: str = "", enabled: bool = True) -> YMenuItem:
+ item = menu.addItem(label, icon_name)
+ item.setEnabled(enabled)
+ if self._backend_widget:
+ self._ensure_item_rendered(menu, item)
+ return item
+
+ def setItemEnabled(self, item: YMenuItem, on: bool = True):
+ item.setEnabled(on)
+ act = self._item_to_qaction.get(item)
+ if act is not None:
+ try:
+ act.setEnabled(bool(on))
+ except Exception:
+ pass
+
+ def setItemVisible(self, item: YMenuItem, visible: bool = True):
+ item.setVisible(visible)
+ self.rebuildMenus()
+
+ def _path_for_item(self, item: YMenuItem) -> str:
+ labels = []
+ cur = item
+ while cur is not None:
+ labels.append(cur.label())
+ cur = getattr(cur, "_parent", None)
+ return "/".join(reversed(labels))
+
+ def _emit_activation(self, item: YMenuItem):
+ try:
+ dlg = self.findDialog()
+ if dlg and self.notify():
+ dlg._post_event(YMenuEvent(item=item, id=self._path_for_item(item)))
+ except Exception:
+ pass
+
+ def _ensure_menu_rendered(self, menu: YMenuItem):
+ if self._backend_widget is None:
+ return
+ # skip invisible top-level menus
+ if not menu.visible():
+ return
+
+ qmenu = self._menu_to_qmenu.get(menu)
+ if qmenu is None:
+ qmenu = QtWidgets.QMenu(menu.label(), self._backend_widget)
+ # icon via theme
+ if menu.iconName():
+ try:
+ icon = _resolve_icon(menu.iconName())
+ except Exception:
+ icon = QtGui.QIcon.fromTheme(menu.iconName())
+ if icon and not icon.isNull():
+ qmenu.setIcon(icon)
+ self._backend_widget.addMenu(qmenu)
+ self._menu_to_qmenu[menu] = qmenu
+ # render children recursively
+ self._render_menu_children(menu)
+ # ensure the top-level action visibility matches model
+ try:
+ act = qmenu.menuAction()
+ try:
+ act.setVisible(bool(menu.visible()))
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def _render_menu_children(self, menu: YMenuItem):
+ """Render all children (items and submenus) of a menu recursively."""
+ parent_qmenu = self._menu_to_qmenu.get(menu)
+ if parent_qmenu is None:
+ return
+ for child in list(menu._children):
+ try:
+ if not child.visible():
+ continue
+ except Exception:
+ pass
+ if child.isMenu():
+ sub_qmenu = self._menu_to_qmenu.get(child)
+ if sub_qmenu is None:
+ sub_qmenu = parent_qmenu.addMenu(child.label())
+ # icon for submenu
+ if child.iconName():
+ try:
+ icon = _resolve_icon(child.iconName())
+ except Exception:
+ icon = QtGui.QIcon.fromTheme(child.iconName())
+ if icon and not icon.isNull():
+ sub_qmenu.setIcon(icon)
+ self._menu_to_qmenu[child] = sub_qmenu
+ # ensure submenu action visibility
+ try:
+ sa = sub_qmenu.menuAction()
+ try:
+ sa.setVisible(bool(child.visible()))
+ except Exception:
+ pass
+ except Exception:
+ pass
+ # recurse into submenu
+ self._render_menu_children(child)
+ else:
+ self._ensure_item_rendered(menu, child)
+
+ def _ensure_item_rendered(self, menu: YMenuItem, item: YMenuItem):
+ qmenu = self._menu_to_qmenu.get(menu)
+ if qmenu is None:
+ self._ensure_menu_rendered(menu)
+ qmenu = self._menu_to_qmenu.get(menu)
+ if not item.visible():
+ return
+ if item.isSeparator():
+ qmenu.addSeparator()
+ return
+ act = self._item_to_qaction.get(item)
+ if act is None:
+ # Resolve icon using common helper (theme or path)
+ icon = QtGui.QIcon()
+ if item.iconName():
+ try:
+ icon = _resolve_icon(item.iconName()) or QtGui.QIcon()
+ except Exception:
+ icon = QtGui.QIcon.fromTheme(item.iconName())
+ act = QtGui.QAction(icon, item.label(), self._backend_widget)
+ act.setEnabled(item.enabled())
+ try:
+ act.setVisible(bool(item.visible()))
+ except Exception:
+ pass
+ def on_triggered():
+ self._emit_activation(item)
+ act.triggered.connect(on_triggered)
+ qmenu.addAction(act)
+ self._item_to_qaction[item] = act
+ self._logger.debug("Rendered menu item: %s", self._path_for_item(item))
+
+ def _create_backend_widget(self):
+ mb = QtWidgets.QMenuBar()
+ self._backend_widget = mb
+ try:
+ # Size policies based on current stretchable flags
+ sp = mb.sizePolicy()
+ # Vertical: default Fixed unless user made it stretchable
+ try:
+ v_stretch = bool(self.stretchable(YUIDimension.YD_VERT))
+ except Exception:
+ v_stretch = False
+ try:
+ sp.setVerticalPolicy(QtWidgets.QSizePolicy.Expanding if v_stretch else QtWidgets.QSizePolicy.Fixed)
+ except Exception:
+ try:
+ sp.setVerticalPolicy(QtWidgets.QSizePolicy.Policy.Expanding if v_stretch else QtWidgets.QSizePolicy.Policy.Fixed)
+ except Exception:
+ pass
+ # Horizontal: default Expanding unless user disabled stretchable
+ try:
+ h_stretch = bool(self.stretchable(YUIDimension.YD_HORIZ))
+ except Exception:
+ h_stretch = True
+ try:
+ sp.setHorizontalPolicy(QtWidgets.QSizePolicy.Expanding if h_stretch else QtWidgets.QSizePolicy.Fixed)
+ except Exception:
+ try:
+ sp.setHorizontalPolicy(QtWidgets.QSizePolicy.Policy.Expanding if h_stretch else QtWidgets.QSizePolicy.Policy.Fixed)
+ except Exception:
+ pass
+ mb.setSizePolicy(sp)
+ # Prevent vertical stretching by bounding height when not stretchable
+ try:
+ if not v_stretch:
+ h = mb.sizeHint().height()
+ if h and h > 0:
+ mb.setMinimumHeight(h)
+ mb.setMaximumHeight(h)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ # render any menus added before creation
+ for m in self._menus:
+ self._ensure_menu_rendered(m)
+ try:
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception:
+ pass
+
+ def _set_backend_enabled(self, enabled):
+ try:
+ if getattr(self, "_backend_widget", None) is not None:
+ self._backend_widget.setEnabled(bool(enabled))
+ except Exception:
+ pass
+
+ def rebuildMenus(self):
+ """Rebuild all the menus.
+
+ Useful when menu model changes at runtime.
+
+ This action must be performed to reflect any direct changes to
+ YMenuItem data (e.g., label, enabled, visible) without passing
+ through the menubar.
+ """
+ # Rebuild all menus: clear existing QMenu/QAction structures
+ try:
+ if getattr(self, "_backend_widget", None) is not None:
+ try:
+ # remove all actions from the menubar
+ for a in list(self._backend_widget.actions()):
+ try:
+ self._backend_widget.removeAction(a)
+ except Exception:
+ self._logger.exception("Failed removing action from menubar")
+ try:
+ a.setVisible(False)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ # clear internal mappings so we rebuild from scratch
+ try:
+ self._menu_to_qmenu.clear()
+ except Exception:
+ self._menu_to_qmenu = {}
+ try:
+ self._item_to_qaction.clear()
+ except Exception:
+ self._item_to_qaction = {}
+
+ # recreate menus as done in _create_backend_widget
+ for m in self._menus:
+ try:
+ self._ensure_menu_rendered(m)
+ except Exception:
+ self._logger.exception("Failed ensuring menu rendered for '%s'", getattr(m, 'label', lambda: 'unknown')())
+ try:
+ self._logger.debug("rebuildMenus: recreated menubar <%s>", self.debugLabel())
+ except Exception:
+ pass
+
+ def deleteMenus(self):
+ """Remove all menus/items and their backend QMenu/QAction mappings.
+
+ After clearing model and mappings, call `rebuildMenus()` so the
+ backend menubar is emptied.
+ """
+ try:
+ # clear the model (top-level menus)
+ try:
+ self._menus.clear()
+ except Exception:
+ self._menus = []
+
+ # remove all actions from the backend widget
+ try:
+ if getattr(self, "_backend_widget", None) is not None:
+ try:
+ for a in list(self._backend_widget.actions()):
+ try:
+ self._backend_widget.removeAction(a)
+ except Exception:
+ try:
+ a.setVisible(False)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ # clear internal mappings
+ try:
+ self._menu_to_qmenu.clear()
+ except Exception:
+ self._menu_to_qmenu = {}
+ try:
+ self._item_to_qaction.clear()
+ except Exception:
+ self._item_to_qaction = {}
+
+ # Ensure UI reflects cleared state
+ try:
+ self.rebuildMenus()
+ except Exception:
+ pass
+ except Exception:
+ self._logger.exception("deleteMenus failed")
diff --git a/manatools/aui/backends/qt/multilineeditqt.py b/manatools/aui/backends/qt/multilineeditqt.py
new file mode 100644
index 0000000..e029e60
--- /dev/null
+++ b/manatools/aui/backends/qt/multilineeditqt.py
@@ -0,0 +1,287 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.qt contains Qt backend for YMultiLineEdit
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.qt
+'''
+import logging
+from ...yui_common import *
+try:
+ from PySide6 import QtWidgets, QtCore
+except Exception:
+ QtWidgets = None
+ QtCore = None
+
+_mod_logger = logging.getLogger("manatools.aui.qt.multiline.module")
+if not logging.getLogger().handlers:
+ _h = logging.StreamHandler()
+ _h.setFormatter(logging.Formatter("%(asctime)s %(name)s %(levelname)s: %(message)s"))
+ _mod_logger.addHandler(_h)
+ _mod_logger.setLevel(logging.INFO)
+
+
+class YMultiLineEditQt(YWidget):
+ def __init__(self, parent=None, label=""):
+ super().__init__(parent)
+ self._label = label
+ self._value = ""
+ # default visible content lines (consistent across backends)
+ self._default_visible_lines = 3
+ # -1 means no input length limit
+ self._input_max_length = -1
+ # reported minimal height: content lines + label row (if present)
+ self._height = self._default_visible_lines + (1 if bool(self._label) else 0)
+ self._logger = logging.getLogger(f"manatools.aui.qt.{self.__class__.__name__}")
+ if not self._logger.handlers and _mod_logger.handlers:
+ for h in _mod_logger.handlers:
+ self._logger.addHandler(h)
+
+ self._qwidget = None
+
+ def widgetClass(self):
+ return "YMultiLineEdit"
+
+ def value(self):
+ return str(self._value)
+
+ def inputMaxLength(self):
+ return int(getattr(self, '_input_max_length', -1))
+
+ def setInputMaxLength(self, numberOfChars):
+ try:
+ self._input_max_length = int(numberOfChars)
+ except Exception:
+ self._input_max_length = -1
+ # enforce immediately on widget
+ try:
+ if self._qwidget is not None and self._input_max_length >= 0:
+ txt = self._qtext.toPlainText()
+ if len(txt) > self._input_max_length:
+ try:
+ self._qtext.blockSignals(True)
+ self._qtext.setPlainText(txt[:self._input_max_length])
+ self._qtext.blockSignals(False)
+ self._value = self._qtext.toPlainText()
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def setValue(self, text):
+ try:
+ s = str(text) if text is not None else ""
+ except Exception:
+ s = ""
+ # enforce input max length if set
+ try:
+ if getattr(self, '_input_max_length', -1) >= 0 and len(s) > self._input_max_length:
+ s = s[: self._input_max_length]
+ except Exception:
+ pass
+ self._value = s
+ try:
+ if self._qwidget is not None:
+ try:
+ self._qtext.setPlainText(self._value)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def defaultVisibleLines(self):
+ return int(getattr(self, '_default_visible_lines', 3))
+
+ def setDefaultVisibleLines(self, newVisibleLines):
+ try:
+ self._default_visible_lines = int(newVisibleLines)
+ self._height = self._default_visible_lines + (1 if bool(self._label) else 0)
+ except Exception:
+ pass
+
+ def label(self):
+ return self._label
+
+ def setLabel(self, label):
+ try:
+ self._label = label
+ if self._qwidget is not None:
+ try:
+ self._qlbl.setText(str(label))
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def setStretchable(self, dim, new_stretch):
+ try:
+ super().setStretchable(dim, new_stretch)
+ except Exception:
+ pass
+ # Re-apply policy so changes take effect immediately
+ try:
+ self._apply_stretch_policy()
+ except Exception:
+ pass
+
+ def _apply_stretch_policy(self):
+ """Apply current stretchable flags per axis without locking both dimensions."""
+ if self._qwidget is None:
+ return
+
+ # Current flags
+ horiz = bool(self.stretchable(YUIDimension.YD_HORIZ))
+ vert = bool(self.stretchable(YUIDimension.YD_VERT))
+
+ # Compute approximate pixel sizes (fallback to constants)
+ try:
+ fm = self._qtext.fontMetrics() if hasattr(self, '_qtext') and self._qtext is not None else None
+ char_w = fm.horizontalAdvance('M') if fm is not None else 8
+ line_h = fm.lineSpacing() if fm is not None else 16
+ except Exception:
+ char_w = 8
+ line_h = 16
+
+ desired_chars = 20
+ try:
+ qlabel_h = self._qlbl.sizeHint().height() if hasattr(self, '_qlbl') and self._qlbl is not None else 0
+ except Exception:
+ qlabel_h = 0
+
+ w_px = int(char_w * desired_chars) + 12
+ h_px = int(line_h * max(1, self._default_visible_lines)) + qlabel_h + 8
+
+ # Set per-axis size policy
+ try:
+ self._qwidget.setSizePolicy(
+ QtWidgets.QSizePolicy.Expanding if horiz else QtWidgets.QSizePolicy.Fixed,
+ QtWidgets.QSizePolicy.Expanding if vert else QtWidgets.QSizePolicy.Fixed,
+ )
+ except Exception:
+ pass
+
+ # Apply horizontal constraint independently
+ try:
+ if not horiz:
+ try:
+ self._qwidget.setFixedWidth(w_px)
+ except Exception:
+ try:
+ self._qwidget.setMaximumWidth(w_px)
+ except Exception:
+ pass
+ else:
+ try:
+ self._qwidget.setMinimumWidth(0)
+ self._qwidget.setMaximumWidth(16777215)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ # Apply vertical constraint independently
+ try:
+ if not vert:
+ try:
+ self._qwidget.setFixedHeight(h_px)
+ except Exception:
+ try:
+ self._qwidget.setMaximumHeight(h_px)
+ except Exception:
+ pass
+ try:
+ if hasattr(self, '_qtext') and self._qtext is not None:
+ self._qtext.setFixedHeight(int(line_h * max(1, self._default_visible_lines)))
+ except Exception:
+ pass
+ else:
+ try:
+ self._qwidget.setMinimumHeight(0)
+ self._qwidget.setMaximumHeight(16777215)
+ if hasattr(self, '_qtext') and self._qtext is not None:
+ self._qtext.setMinimumHeight(0)
+ self._qtext.setMaximumHeight(16777215)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def _create_backend_widget(self):
+ try:
+ if QtWidgets is None:
+ raise ImportError("PySide6 not available")
+ self._qwidget = QtWidgets.QWidget()
+ layout = QtWidgets.QVBoxLayout(self._qwidget)
+ layout.setContentsMargins(0, 0, 0, 0)
+ self._qlbl = QtWidgets.QLabel(str(self._label))
+ self._qtext = QtWidgets.QPlainTextEdit()
+ self._qtext.setPlainText(self._value)
+ layout.addWidget(self._qlbl)
+ layout.addWidget(self._qtext)
+ # apply current stretchable policy state
+ try:
+ self._apply_stretch_policy()
+ except Exception:
+ pass
+ try:
+ self._qtext.textChanged.connect(self._on_text_changed)
+ except Exception:
+ pass
+ self._backend_widget = self._qwidget
+ except Exception as e:
+ try:
+ self._logger.exception("Error creating Qt MultiLineEdit backend: %s", e)
+ except Exception:
+ pass
+
+ def _set_backend_enabled(self, enabled):
+ try:
+ if self._qwidget is not None:
+ try:
+ self._qtext.setReadOnly(not enabled)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def _on_text_changed(self):
+ try:
+ text = self._qtext.toPlainText()
+ # enforce input max length if set
+ try:
+ if getattr(self, '_input_max_length', -1) >= 0 and len(text) > self._input_max_length:
+ # truncate and restore cursor
+ cur = self._qtext.textCursor()
+ pos = cur.position()
+ self._qtext.blockSignals(True)
+ self._qtext.setPlainText(text[:self._input_max_length])
+ self._qtext.blockSignals(False)
+ self._value = self._qtext.toPlainText()
+ try:
+ cur.setPosition(min(pos, self._input_max_length))
+ self._qtext.setTextCursor(cur)
+ except Exception:
+ pass
+ else:
+ self._value = text
+ except Exception:
+ self._value = text
+ dlg = self.findDialog()
+ if dlg is not None:
+ try:
+ if self.notify():
+ dlg._post_event(YWidgetEvent(self, YEventReason.ValueChanged))
+ except Exception:
+ try:
+ self._logger.exception("Failed to post ValueChanged event")
+ except Exception:
+ pass
+ except Exception:
+ try:
+ self._logger.exception("_on_text_changed error")
+ except Exception:
+ pass
diff --git a/manatools/aui/backends/qt/panedqt.py b/manatools/aui/backends/qt/panedqt.py
new file mode 100644
index 0000000..97008cc
--- /dev/null
+++ b/manatools/aui/backends/qt/panedqt.py
@@ -0,0 +1,79 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.qt contains all Qt backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.qt
+'''
+
+"""
+YPanedQt: Qt6 Paned widget wrapper.
+
+- Wraps QSplitter with horizontal or vertical orientation.
+- Children are added in order, up to two for parity with Gtk Paned.
+"""
+
+import logging
+from ...yui_common import YWidget, YUIDimension
+
+try:
+ from PySide6.QtCore import Qt
+ from PySide6.QtWidgets import QSplitter
+except Exception as e:
+ QSplitter = None
+ Qt = None
+ logging.getLogger("manatools.aui.qt.paned").error("Failed to import Qt6: %s", e, exc_info=True)
+
+
+class YPanedQt(YWidget):
+ """
+ Qt6 implementation of YPaned using QSplitter.
+ """
+
+ def __init__(self, parent=None, dimension: YUIDimension = YUIDimension.YD_HORIZ):
+ super().__init__(parent)
+ self._logger = logging.getLogger("manatools.aui.qt.YPanedQt")
+ self._orientation = dimension
+ self._backend_widget = None
+ self._children = []
+
+ def widgetClass(self):
+ return "YPaned"
+
+ def _create_backend_widget(self):
+ """
+ Create the underlying QSplitter with the chosen orientation.
+ """
+ if QSplitter is None or Qt is None:
+ raise RuntimeError("Qt6 is not available")
+ orient = Qt.Horizontal if self._orientation == YUIDimension.YD_HORIZ else Qt.Vertical
+ self._backend_widget = QSplitter(orient)
+ self._logger.debug("Created QSplitter orientation=%s", "H" if orient == Qt.Horizontal else "V")
+ for idx, child in enumerate(self._children):
+ widget = child.get_backend_widget()
+ if widget is not None:
+ self._backend_widget.addWidget(widget)
+ self._logger.debug("Added existing child %d to splitter during creation", idx)
+
+ def addChild(self, child):
+ """
+ Add a child to the splitter, limiting to two children for consistency.
+ """
+ super().addChild(child)
+ if getattr(self, "_backend_widget", None) is None:
+ return
+
+ try:
+ if len(self._children) >= 2:
+ self._logger.warning("YPanedQt can only manage two children; ignoring extra child")
+ return
+ if getattr(child, "_backend_widget", None) is not None:
+ self._backend_widget.addWidget(child._backend_widget)
+ self._children.append(child)
+ self._logger.debug("Added child to splitter: %s", getattr(child, "debugLabel", lambda: repr(child))())
+ except Exception as e:
+ self._logger.error("addChild error: %s", e, exc_info=True)
diff --git a/manatools/aui/backends/qt/progressbarqt.py b/manatools/aui/backends/qt/progressbarqt.py
new file mode 100644
index 0000000..f32f404
--- /dev/null
+++ b/manatools/aui/backends/qt/progressbarqt.py
@@ -0,0 +1,221 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.qt contains all Qt backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.qt
+'''
+from PySide6 import QtWidgets
+import logging
+from ...yui_common import *
+
+class YProgressBarQt(YWidget):
+ """Qt6 (PySide6) progress bar with optional label above it.
+ Provides value/max management and respects visibility and tooltip (help text).
+ """
+
+ def __init__(self, parent=None, label="", maxValue=100):
+ super().__init__(parent)
+ self._label = label
+ self._max_value = int(maxValue) if maxValue is not None else 100
+ self._value = 0
+ self._backend_widget = None
+ self._label_widget = None
+ self._progress_widget = None
+ self._logger = logging.getLogger(f"manatools.aui.qt.{self.__class__.__name__}")
+
+ def widgetClass(self):
+ return "YProgressBar"
+
+ def label(self):
+ return self._label
+
+ def setLabel(self, newLabel):
+ try:
+ self._label = str(newLabel) if isinstance(newLabel, str) else newLabel
+ if getattr(self, "_label_widget", None) is not None:
+ try:
+ is_valid = isinstance(self._label, str) and bool(self._label.strip())
+ if is_valid:
+ self._label_widget.setText(self._label)
+ self._label_widget.setVisible(True)
+ else:
+ # hide the label if not a valid string
+ self._label_widget.setVisible(False)
+ except Exception:
+ self._logger.exception("setLabel: failed to update QLabel")
+ except Exception:
+ self._logger.exception("setLabel: unexpected error")
+
+ def maxValue(self):
+ return int(self._max_value)
+
+ def value(self):
+ return int(self._value)
+
+ def setValue(self, newValue):
+ try:
+ v = int(newValue)
+ if v < 0:
+ v = 0
+ if v > self._max_value:
+ v = self._max_value
+ self._value = v
+ if getattr(self, "_progress_widget", None) is not None:
+ try:
+ self._progress_widget.setValue(self._value)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def _create_backend_widget(self):
+ try:
+ container = QtWidgets.QWidget()
+ layout = QtWidgets.QVBoxLayout(container)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(0)
+
+ # container vertical stretching is allowed only if widget is stretchable vertically
+ h_policy = QtWidgets.QSizePolicy.Expanding if self.stretchable(YUIDimension.YD_HORIZ) else QtWidgets.QSizePolicy.Preferred
+ v_policy = QtWidgets.QSizePolicy.Expanding if self.stretchable(YUIDimension.YD_VERT) else QtWidgets.QSizePolicy.Fixed
+ container.setSizePolicy(h_policy, v_policy)
+
+ # Place label above the progress bar; always create then show/hide based on validity
+ lbl = QtWidgets.QLabel()
+ lbl.setSizePolicy(QtWidgets.QSizePolicy.Preferred, QtWidgets.QSizePolicy.Fixed)
+ layout.addWidget(lbl)
+ try:
+ is_valid = isinstance(self._label, str) and bool(self._label.strip())
+ if is_valid:
+ lbl.setText(self._label)
+ lbl.setVisible(True)
+ else:
+ lbl.setVisible(False)
+ except Exception:
+ # be safe: hide if anything goes wrong
+ lbl.setVisible(False)
+
+ prog = QtWidgets.QProgressBar()
+ prog.setRange(0, max(1, int(self._max_value)))
+ prog.setValue(int(self._value))
+ prog.setTextVisible(True)
+ # progress bar horizontal expand; vertical policy mirrors container vertical policy
+ prog.setSizePolicy(QtWidgets.QSizePolicy.Expanding,
+ QtWidgets.QSizePolicy.Expanding if self.stretchable(YUIDimension.YD_VERT) else QtWidgets.QSizePolicy.Fixed)
+ layout.addWidget(prog)
+
+ self._backend_widget = container
+ self._label_widget = lbl
+ self._progress_widget = prog
+ self._backend_widget.setEnabled(bool(self._enabled))
+ # Apply initial tooltip and visibility like other Qt widgets (e.g., combobox)
+ try:
+ if getattr(self, "_help_text", None):
+ try:
+ self._backend_widget.setToolTip(self._help_text)
+ except Exception:
+ self._logger.exception("Failed to set tooltip on progressbar")
+ except Exception:
+ self._logger.exception("Tooltip setup error on progressbar")
+ try:
+ self._backend_widget.setVisible(self.visible())
+ except Exception:
+ self._logger.exception("Failed to set initial visibility on progressbar")
+ try:
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception:
+ pass
+ except Exception:
+ self._backend_widget = None
+ self._label_widget = None
+ self._progress_widget = None
+ self._logger.exception("_create_backend_widget failed")
+
+ def _set_backend_enabled(self, enabled):
+ try:
+ if getattr(self, "_backend_widget", None) is not None:
+ try:
+ self._backend_widget.setEnabled(bool(enabled))
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def setProperty(self, propertyName, val):
+ try:
+ if propertyName == "label":
+ self.setLabel(str(val))
+ return True
+ if propertyName == "value":
+ try:
+ self.setValue(int(val))
+ except Exception:
+ # if val is YPropertyValue
+ try:
+ self.setValue(int(val.integerVal()))
+ except Exception:
+ pass
+ return True
+ except Exception:
+ pass
+ return False
+
+ def getProperty(self, propertyName):
+ try:
+ if propertyName == "label":
+ return self.label()
+ if propertyName == "value":
+ return self.value()
+ if propertyName == "maxValue":
+ return self.maxValue()
+ except Exception:
+ pass
+ return None
+
+ def propertySet(self):
+ try:
+ props = YPropertySet()
+ try:
+ props.add(YProperty("label", YPropertyType.YStringProperty))
+ props.add(YProperty("value", YPropertyType.YIntegerProperty))
+ props.add(YProperty("maxValue", YPropertyType.YIntegerProperty))
+ except Exception:
+ pass
+ return props
+ except Exception:
+ return None
+
+ def stretchable(self, dim: YUIDimension):
+ # Progress bars usually expand horizontally but not vertically
+ try:
+ if dim == YUIDimension.YD_HORIZ:
+ return True
+ return False
+ except Exception:
+ return False
+
+ def setVisible(self, visible=True):
+ """Set widget visibility and propagate it to the Qt backend widget."""
+ super().setVisible(visible)
+ try:
+ if getattr(self, "_backend_widget", None) is not None:
+ self._backend_widget.setVisible(bool(visible))
+ except Exception:
+ self._logger.exception("setVisible failed")
+
+ def setHelpText(self, help_text: str):
+ """Set help text (tooltip) and propagate it to the Qt backend widget."""
+ super().setHelpText(help_text)
+ try:
+ if getattr(self, "_backend_widget", None) is not None:
+ try:
+ self._backend_widget.setToolTip(help_text)
+ except Exception:
+ self._logger.exception("Failed to apply tooltip to backend widget")
+ except Exception:
+ self._logger.exception("setHelpText failed")
diff --git a/manatools/aui/backends/qt/pushbuttonqt.py b/manatools/aui/backends/qt/pushbuttonqt.py
new file mode 100644
index 0000000..fe440e6
--- /dev/null
+++ b/manatools/aui/backends/qt/pushbuttonqt.py
@@ -0,0 +1,248 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.qt contains all Qt backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.qt
+'''
+from PySide6 import QtWidgets, QtGui, QtCore
+import logging
+from typing import Optional
+from ...yui_common import *
+from .commonqt import _resolve_icon
+
+
+class YPushButtonQt(YWidget):
+ """Qt6 push button wrapper honoring icons, help text, and default state."""
+ def __init__(self, parent=None, label: str="", icon_name: Optional[str]=None, icon_only: Optional[bool]=False):
+ super().__init__(parent)
+ self._label = label
+ self._icon_name = icon_name
+ self._icon_only = bool(icon_only)
+ self._is_default = False
+ self._default_shortcuts = []
+ self._logger = logging.getLogger(f"manatools.aui.qt.{self.__class__.__name__}")
+
+ def widgetClass(self):
+ return "YPushButton"
+
+ def label(self):
+ return self._label
+
+ def setLabel(self, label):
+ self._label = label
+ if self._backend_widget and self._icon_only is False:
+ self._backend_widget.setText(label)
+
+ def setDefault(self, default: bool):
+ """Mark/unmark this push button as the dialog default."""
+ self._apply_default_state(bool(default), notify_dialog=True)
+
+ def default(self) -> bool:
+ """Return True if this button is currently the default."""
+ return bool(getattr(self, "_is_default", False))
+
+ def _create_backend_widget(self):
+ if self._icon_only:
+ self._backend_widget = QtWidgets.QPushButton()
+ else:
+ self._backend_widget = QtWidgets.QPushButton(self._label)
+ if self._help_text:
+ self._backend_widget.setToolTip(self._help_text)
+ if self.visible():
+ self._backend_widget.show()
+ else:
+ self._backend_widget.hide()
+ # apply icon if previously set
+ try:
+ if getattr(self, "_icon_name", None):
+ ico = _resolve_icon(self._icon_name)
+ if ico is not None and not ico.isNull():
+ try:
+ self._backend_widget.setIcon(ico)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ # Set size policy to prevent unwanted expansion
+ try:
+ try:
+ sp = self._backend_widget.sizePolicy()
+ # PySide6 may expect enum class; try both styles defensively
+ try:
+ sp.setHorizontalPolicy(QtWidgets.QSizePolicy.Policy.Minimum if self.stretchable(YUIDimension.YD_HORIZ) else QtWidgets.QSizePolicy.Policy.Fixed)
+ sp.setVerticalPolicy(QtWidgets.QSizePolicy.Policy.Minimum if self.stretchable(YUIDimension.YD_VERT) else QtWidgets.QSizePolicy.Policy.Fixed)
+ except Exception:
+ try:
+ sp.setHorizontalPolicy(QtWidgets.QSizePolicy.Minimum)
+ except Exception:
+ pass
+ self._backend_widget.setSizePolicy(sp)
+ except Exception:
+ try:
+ # fallback: set using convenience form (two args)
+ self._backend_widget.setSizePolicy(QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Preferred)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ self._backend_widget.setEnabled(bool(self._enabled))
+ self._backend_widget.clicked.connect(self._on_clicked)
+ self._sync_default_visual()
+ self._refresh_default_shortcuts()
+ try:
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception:
+ pass
+
+ def _set_backend_enabled(self, enabled):
+ """Enable/disable the QPushButton backend."""
+ try:
+ if getattr(self, "_backend_widget", None) is not None:
+ try:
+ self._backend_widget.setEnabled(bool(enabled))
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def _on_clicked(self):
+ # Post a YWidgetEvent to the containing dialog (walk parents)
+ dlg = self.findDialog()
+ if dlg is not None:
+ dlg._post_event(YWidgetEvent(self, YEventReason.Activated))
+ else:
+ # fallback logging for now
+ self._logger.warning("Button clicked (no dialog found): %s", self._label)
+
+ def setIcon(self, icon_name: str):
+ """Set/clear the icon for this pushbutton (icon_name may be theme name or path)."""
+ try:
+ self._icon_name = icon_name
+ if getattr(self, "_backend_widget", None) is None:
+ return
+ ico = _resolve_icon(icon_name)
+ if ico is not None and not ico.isNull():
+ try:
+ self._backend_widget.setIcon(ico)
+ return
+ except Exception:
+ pass
+ # Clear icon if resolution failed
+ try:
+ self._backend_widget.setIcon(QtGui.QIcon())
+ except Exception:
+ pass
+ except Exception:
+ try:
+ self._logger.exception("setIcon failed")
+ except Exception:
+ pass
+
+ def setVisible(self, visible=True):
+ super().setVisible(visible)
+ try:
+ if getattr(self, "_backend_widget", None) is not None:
+ self._backend_widget.setVisible(visible)
+ except Exception:
+ self._logger.exception("setVisible failed")
+
+ def setHelpText(self, help_text: str):
+ super().setHelpText(help_text)
+ try:
+ if getattr(self, "_backend_widget", None) is not None:
+ self._backend_widget.setToolTip(help_text)
+ except Exception:
+ self._logger.exception("setHelpText failed")
+
+ def _apply_default_state(self, state: bool, notify_dialog: bool):
+ """Persist default flag and coordinate with owning dialog."""
+ desired = bool(state)
+ if getattr(self, "_is_default", False) == desired and not notify_dialog:
+ return
+ dlg = self.findDialog() if notify_dialog else None
+ if notify_dialog and dlg is not None:
+ try:
+ if desired:
+ dlg._register_default_button(self)
+ else:
+ dlg._unregister_default_button(self)
+ except Exception:
+ self._logger.exception("Failed to sync default state with dialog")
+ self._is_default = desired
+ self._sync_default_visual()
+ self._refresh_default_shortcuts()
+
+ def _sync_default_visual(self):
+ """Apply Qt default/auto-default flags on the backend widget."""
+ widget = getattr(self, "_backend_widget", None)
+ if widget is None:
+ return
+ try:
+ widget.setDefault(bool(self._is_default))
+ except Exception:
+ self._logger.exception("Failed to set Qt default flag")
+ try:
+ widget.setAutoDefault(bool(self._is_default))
+ except Exception:
+ # Some styles/widgets may not expose auto-default; ignore.
+ pass
+
+ def _refresh_default_shortcuts(self):
+ """Create or remove shortcuts that emulate Qt default button behavior."""
+ self._clear_default_shortcuts()
+ if not self._is_default:
+ return
+ dlg = self.findDialog()
+ backend_window = getattr(dlg, "_qwidget", None) if dlg else None
+ backend_button = getattr(self, "_backend_widget", None)
+ if backend_window is None or backend_button is None:
+ return
+ try:
+ keys = [QtCore.Qt.Key_Return, QtCore.Qt.Key_Enter, QtCore.Qt.Key_Space]
+ for key in keys:
+ shortcut = QtGui.QShortcut(QtGui.QKeySequence(key), backend_window)
+ shortcut.setContext(QtCore.Qt.WidgetWithChildrenShortcut)
+ shortcut.activated.connect(lambda key_code=key: self._shortcut_activate(key_code))
+ self._default_shortcuts.append(shortcut)
+ except Exception:
+ self._logger.exception("Failed to install default button shortcuts")
+
+ def _clear_default_shortcuts(self):
+ """Dispose shortcut objects previously created for default activation."""
+ for shortcut in self._default_shortcuts:
+ try:
+ shortcut.deleteLater()
+ except Exception:
+ pass
+ self._default_shortcuts = []
+
+ def _shortcut_activate(self, key_code):
+ """Slot executed when a default shortcut fires (Enter/Space)."""
+ if not self._is_default:
+ return
+ if not self.isEnabled() or not self.visible():
+ return
+ dlg = self.findDialog()
+ backend_window = getattr(dlg, "_qwidget", None) if dlg else None
+ backend = getattr(self, "_backend_widget", None)
+ if backend is None:
+ return
+ if key_code == QtCore.Qt.Key_Space and backend_window is not None:
+ try:
+ focus_widget = backend_window.focusWidget()
+ if isinstance(focus_widget, (QtWidgets.QLineEdit, QtWidgets.QTextEdit, QtWidgets.QPlainTextEdit)):
+ return
+ except Exception:
+ pass
+ try:
+ backend.animateClick(0)
+ except Exception:
+ try:
+ backend.click()
+ except Exception:
+ self._logger.exception("Default shortcut could not activate button")
diff --git a/manatools/aui/backends/qt/radiobuttonqt.py b/manatools/aui/backends/qt/radiobuttonqt.py
new file mode 100644
index 0000000..72973ee
--- /dev/null
+++ b/manatools/aui/backends/qt/radiobuttonqt.py
@@ -0,0 +1,108 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.qt contains all Qt backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.qt
+'''
+from PySide6 import QtWidgets
+import logging
+from ...yui_common import *
+
+class YRadioButtonQt(YWidget):
+ def __init__(self, parent=None, label="", isChecked=False):
+ super().__init__(parent)
+ self._label = label
+ self._is_checked = bool(isChecked)
+ self._backend_widget = None
+ self._logger = logging.getLogger(f"manatools.aui.qt.{self.__class__.__name__}")
+
+ def widgetClass(self):
+ return "YRadioButton"
+
+ def label(self):
+ return self._label
+
+ def setLabel(self, newLabel):
+ try:
+ self._label = str(newLabel)
+ if getattr(self, "_backend_widget", None) is not None:
+ try:
+ self._backend_widget.setText(self._label)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def isChecked(self):
+ return bool(self._is_checked)
+
+ def setChecked(self, checked):
+ try:
+ self._is_checked = bool(checked)
+ if getattr(self, "_backend_widget", None) is not None:
+ try:
+ # avoid emitting signals while programmatically changing state
+ self._backend_widget.blockSignals(True)
+ self._backend_widget.setChecked(self._is_checked)
+ finally:
+ try:
+ self._backend_widget.blockSignals(False)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ # Compatibility with other widgets: provide value()/setValue()
+ def value(self):
+ return self.isChecked()
+
+ def setValue(self, checked):
+ return self.setChecked(checked)
+
+ def _create_backend_widget(self):
+ try:
+ self._backend_widget = QtWidgets.QRadioButton(self._label)
+ self._backend_widget.setChecked(self._is_checked)
+ self._backend_widget.toggled.connect(self._on_toggled)
+ try:
+ self._backend_widget.setEnabled(bool(self._enabled))
+ except Exception:
+ pass
+ except Exception:
+ self._backend_widget = None
+ try:
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception:
+ pass
+
+ def _set_backend_enabled(self, enabled):
+ try:
+ if getattr(self, "_backend_widget", None) is not None:
+ try:
+ self._backend_widget.setEnabled(bool(enabled))
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def _on_toggled(self, checked):
+ try:
+ self._is_checked = bool(checked)
+ except Exception:
+ self._is_checked = False
+
+ if self.notify():
+ dlg = self.findDialog()
+ if dlg is not None:
+ dlg._post_event(YWidgetEvent(self, YEventReason.ValueChanged))
+ else:
+ # best-effort debug output when no dialog
+ try:
+ self._logger.warning("RadioButton toggled on None dialog: %s = %s", self._label, self._is_checked)
+ except Exception:
+ pass
\ No newline at end of file
diff --git a/manatools/aui/backends/qt/replacepointqt.py b/manatools/aui/backends/qt/replacepointqt.py
new file mode 100644
index 0000000..5317b0b
--- /dev/null
+++ b/manatools/aui/backends/qt/replacepointqt.py
@@ -0,0 +1,410 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.qt contains all Qt backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.qt
+'''
+from PySide6 import QtWidgets, QtCore
+import logging
+from ...yui_common import *
+
+class YReplacePointQt(YSingleChildContainerWidget):
+ """
+ Qt backend implementation of YReplacePoint.
+
+ A placeholder container that hosts exactly one child. The child can be
+ removed and replaced at runtime, and showChild() should be called after
+ creating/adding a new child to attach and present it in the backend view.
+ """
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self._backend_widget = None
+ self._layout = None
+ self._logger = logging.getLogger(f"manatools.aui.qt.{self.__class__.__name__}")
+ try:
+ self._logger.debug("%s.__init__", self.__class__.__name__)
+ except Exception:
+ pass
+
+ def widgetClass(self):
+ return "YReplacePoint"
+
+ def _create_backend_widget(self):
+ """Create a QWidget container with a vertical layout to host the single child."""
+ try:
+ container = QtWidgets.QWidget()
+ # Use a QStackedLayout so the single active child is shown reliably
+ layout = QtWidgets.QStackedLayout()
+ layout.setContentsMargins(10, 10, 10, 10)
+ layout.setSpacing(5)
+ container.setLayout(layout)
+ self._backend_widget = container
+ self._layout = layout
+ # Default to expanding so parents like YFrame can allocate space
+ try:
+ sp = container.sizePolicy()
+ try:
+ sp.setHorizontalPolicy(QtWidgets.QSizePolicy.Policy.Expanding)
+ sp.setVerticalPolicy(QtWidgets.QSizePolicy.Policy.Expanding)
+ except Exception:
+ try:
+ sp.setHorizontalPolicy(QtWidgets.QSizePolicy.Expanding)
+ sp.setVerticalPolicy(QtWidgets.QSizePolicy.Expanding)
+ except Exception:
+ pass
+ container.setSizePolicy(sp)
+ except Exception:
+ pass
+ self._backend_widget.setEnabled(bool(self._enabled))
+ # If a child already exists, attach it now
+ try:
+ if self.child():
+ cw = self.child().get_backend_widget()
+ self._logger.debug("Attaching existing child %s", self.child().widgetClass())
+ if cw:
+ try:
+ cw.setParent(self._backend_widget)
+ except Exception:
+ pass
+ try:
+ self._layout.addWidget(cw)
+ try:
+ self._layout.setCurrentWidget(cw)
+ except Exception:
+ pass
+ except Exception:
+ try:
+ self._layout.addWidget(cw)
+ except Exception:
+ pass
+ else:
+ self._logger.debug("No existing child to attach")
+ except Exception as e:
+ try:
+ self._logger.error("_create_backend_widget attach child error: %s", e, exc_info=True)
+ except Exception:
+ pass
+ try:
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception:
+ pass
+ except Exception as e:
+ try:
+ self._logger.error("_create_backend_widget error: %s", e, exc_info=True)
+ except Exception:
+ pass
+ self._backend_widget = None
+ self._layout = None
+
+ def _set_backend_enabled(self, enabled):
+ """Enable/disable the container and propagate to its logical child."""
+ try:
+ if getattr(self, "_backend_widget", None) is not None:
+ self._backend_widget.setEnabled(bool(enabled))
+ except Exception:
+ pass
+ try:
+ ch = self.child()
+ if ch is not None:
+ ch.setEnabled(enabled)
+ except Exception:
+ pass
+
+ def stretchable(self, dim: YUIDimension):
+ """Propagate stretchability from the single child to the container."""
+ try:
+ ch = self.child()
+ if ch is None:
+ return False
+ try:
+ if bool(ch.stretchable(dim)):
+ return True
+ except Exception:
+ pass
+ try:
+ if bool(ch.weight(dim)):
+ return True
+ except Exception:
+ pass
+ except Exception:
+ pass
+ return False
+
+ def _attach_child_backend(self):
+ """Attach the current child's backend into this container layout (no redraw)."""
+ # Ensure the backend container exists
+ if not (self._backend_widget and self._layout and self.child()):
+ self._logger.debug("_attach_child_backend called but no backend or no child to attach")
+ return
+ try:
+ if self._layout.count() > 0:
+ self._logger.warning("_attach_child_backend: layout is not empty")
+ # Clear previous layout widgets
+ try:
+ while self._layout.count():
+ it = self._layout.takeAt(0)
+ w = it.widget() if it else None
+ if w:
+ w.setParent(None)
+ except Exception:
+ pass
+ # Ensure ReplacePoint expands to show content even if child isn't stretchable
+ try:
+ sp = self._backend_widget.sizePolicy()
+ try:
+ sp.setHorizontalPolicy(QtWidgets.QSizePolicy.Policy.Expanding)
+ sp.setVerticalPolicy(QtWidgets.QSizePolicy.Policy.Expanding)
+ except Exception:
+ try:
+ sp.setHorizontalPolicy(QtWidgets.QSizePolicy.Expanding)
+ sp.setVerticalPolicy(QtWidgets.QSizePolicy.Expanding)
+ except Exception:
+ pass
+ self._backend_widget.setSizePolicy(sp)
+ except Exception:
+ self._logger.exception("_attach_child_backend: sizePolicy failed")
+ pass
+ ch = self.child()
+ cw = ch.get_backend_widget()
+ if cw:
+ try:
+ self._logger.debug("_attach_child_backend: layout.count before add = %s", self._layout.count())
+ except Exception:
+ pass
+ try:
+ # Debug info to help diagnose invisible children
+ try:
+ self._logger.debug("_attach_child_backend: attaching widget type=%s parent=%s visible=%s sizeHint=%s",
+ type(cw), getattr(cw, 'parent', lambda: None)(), getattr(cw, 'isVisible', lambda: False)(), getattr(cw, 'sizeHint', lambda: None)())
+ except Exception:
+ pass
+ except Exception:
+ pass
+ try:
+ # Ensure the widget is parented to our container before adding
+ try:
+ cw.setParent(self._backend_widget)
+ except Exception:
+ pass
+ self._layout.addWidget(cw)
+ try:
+ # If using QStackedLayout, show the new widget as current
+ try:
+ self._layout.setCurrentWidget(cw)
+ except Exception:
+ try:
+ # fallback to setCurrentIndex if available
+ idx = self._layout.indexOf(cw)
+ if idx is not None and idx >= 0:
+ try:
+ self._layout.setCurrentIndex(idx)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ except Exception:
+ pass
+ except Exception:
+ # fallback: try remove any previous parent then add
+ try:
+ try:
+ cw.setParent(None)
+ except Exception:
+ pass
+ self._layout.addWidget(cw)
+ except Exception:
+ self._logger.exception("_attach_child_backend: addWidget fallback failed")
+ # Encourage the child to expand so content becomes visible
+ # Encourage the child to expand so content becomes visible
+ try:
+ sp_cw = cw.sizePolicy()
+ try:
+ sp_cw.setHorizontalPolicy(QtWidgets.QSizePolicy.Policy.Expanding)
+ sp_cw.setVerticalPolicy(QtWidgets.QSizePolicy.Policy.Expanding)
+ except Exception:
+ try:
+ sp_cw.setHorizontalPolicy(QtWidgets.QSizePolicy.Expanding)
+ sp_cw.setVerticalPolicy(QtWidgets.QSizePolicy.Expanding)
+ except Exception:
+ pass
+ cw.setSizePolicy(sp_cw)
+ except Exception:
+ pass
+ try:
+ # Ensure the single child gets stretch in the layout (stacked ignores stretch but keep for safety)
+ try:
+ self._layout.setStretch(0, 1)
+ except Exception:
+ pass
+ try:
+ self._logger.debug("_attach_child_backend: layout.count after add = %s currentWidget=%s", self._layout.count(), getattr(self._layout, 'currentWidget', lambda: None)())
+ except Exception:
+ pass
+ except Exception:
+ pass
+ try:
+ cw.show()
+ try:
+ cw.raise_()
+ except Exception:
+ pass
+ try:
+ cw.updateGeometry()
+ except Exception:
+ pass
+ # If sizeHint is empty, give a small visible minimum so layout doesn't collapse
+ try:
+ sh = cw.sizeHint()
+ if sh is not None and (sh.width() == 0 or sh.height() == 0):
+ try:
+ mh = max(24, sh.height()) if hasattr(sh, 'height') else 24
+ mw = max(24, sh.width()) if hasattr(sh, 'width') else 24
+ cw.setMinimumSize(mw, mh)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ except Exception:
+ pass
+ except Exception:
+ self._logger.exception("_attach_child_backend failed")
+ pass
+
+ def addChild(self, child):
+ """Add logical child and attach backend if possible (no forced redraw)."""
+ super().addChild(child)
+ self._logger.debug("addChild: %s", child.debugLabel())
+ self._attach_child_backend()
+
+ def showChild(self):
+ """
+ Attach and show the newly added child in the backend view.
+ This removes any previous widget from the layout and inserts
+ the current child's backend widget.
+ """
+ if not (self._backend_widget and self._layout and self.child()):
+ self._logger.debug("showChild called but no backend or no child to attach")
+ return
+ try:
+ # Reuse the attach helper
+ # Force a dialog layout recalculation and redraw, similar to libyui's recalcLayout.
+ try:
+ dlg = self.findDialog()
+ if dlg is not None:
+ qwin = getattr(dlg, "_qwidget", None)
+ if qwin:
+ # Trigger local layout updates first
+ try:
+ if self._layout is not None:
+ self._layout.invalidate()
+ try:
+ self._layout.activate()
+ except Exception:
+ pass
+ if self._backend_widget is not None:
+ self._backend_widget.updateGeometry()
+ self._backend_widget.setVisible(True)
+ self._backend_widget.update()
+ except Exception:
+ pass
+ try:
+ qwin.update()
+ try:
+ ch = self.child()
+ if ch is not None:
+ cw = ch.get_backend_widget()
+ if cw:
+ cw.show()
+ except Exception:
+ pass
+
+ #qwin.repaint()
+ except Exception:
+ self._logger.exception("showChild: repaint failed")
+ pass
+ # Activate the dialog's layout if present
+ try:
+ lay = qwin.layout()
+ if lay is not None:
+ try:
+ lay.invalidate()
+ except Exception:
+ pass
+ try:
+ lay.activate()
+ except Exception:
+ pass
+ except Exception:
+ pass
+ try:
+ qwin.adjustSize()
+ except Exception:
+ self._logger.exception("showChild: adjustSize failed")
+ pass
+ try:
+ app = QtWidgets.QApplication.instance()
+ if app:
+ try:
+ app.processEvents(QtCore.QEventLoop.AllEvents)
+ except Exception:
+ app.processEvents()
+ except Exception:
+ self._logger.exception("showChild: processEvents failed")
+ pass
+ else:
+ self._logger.debug("showChild: dialog has no _qwidget")
+ except Exception:
+ pass
+ except Exception as e:
+ try:
+ self._logger.error("showChild error: %s", e, exc_info=True)
+ except Exception:
+ pass
+
+ def deleteChildren(self):
+ """
+ Remove the logical child and clear the backend layout so new children
+ can be added and shown afterwards.
+ """
+ try:
+ # Clear backend layout
+ if self._layout is not None:
+ try:
+ # Properly remove and hide widgets so layout recalculates
+ while self._layout.count():
+ it = self._layout.takeAt(0)
+ w = it.widget() if it else None
+ if w:
+ try:
+ try:
+ self._layout.removeWidget(w)
+ except Exception:
+ pass
+ try:
+ w.hide()
+ except Exception:
+ pass
+ try:
+ w.setParent(None)
+ except Exception:
+ pass
+ try:
+ w.update()
+ except Exception:
+ pass
+ except Exception:
+ pass
+ except Exception:
+ pass
+ # Clear model children
+ super().deleteChildren()
+ except Exception as e:
+ try:
+ self._logger.error("deleteChildren error: %s", e, exc_info=True)
+ except Exception:
+ pass
diff --git a/manatools/aui/backends/qt/richtextqt.py b/manatools/aui/backends/qt/richtextqt.py
new file mode 100644
index 0000000..0a2f248
--- /dev/null
+++ b/manatools/aui/backends/qt/richtextqt.py
@@ -0,0 +1,207 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.qt contains all Qt backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.qt
+'''
+from PySide6 import QtWidgets, QtCore, QtGui
+import logging
+from ...yui_common import *
+
+class YRichTextQt(YWidget):
+ """
+ Rich text widget using Qt's QTextBrowser.
+ - Supports plain text and rich HTML-like content.
+ - Emits an Activated event on link clicks without navigating.
+ - Optional auto-scroll on updates.
+ """
+ def __init__(self, parent=None, text: str = "", plainTextMode: bool = False):
+ super().__init__(parent)
+ self._text = text or ""
+ self._plain = bool(plainTextMode)
+ self._auto_scroll = False
+ self._last_url = None
+ self._logger = logging.getLogger(f"manatools.aui.qt.{self.__class__.__name__}")
+ # Default: richtext is stretchable in both dimensions
+ try:
+ self.setStretchable(YUIDimension.YD_HORIZ, True)
+ self.setStretchable(YUIDimension.YD_VERT, True)
+ except Exception:
+ pass
+
+ def widgetClass(self):
+ return "YRichText"
+
+ # Value API
+ def setValue(self, newValue: str):
+ try:
+ self._text = newValue or ""
+ except Exception:
+ self._text = ""
+ try:
+ if getattr(self, "_backend_widget", None) is not None:
+ if self._plain:
+ try:
+ self._backend_widget.setPlainText(self._text)
+ except Exception:
+ self._backend_widget.setText(self._text)
+ else:
+ try:
+ self._backend_widget.setHtml(self._text)
+ except Exception:
+ self._backend_widget.setText(self._text)
+ if self._auto_scroll:
+ try:
+ cursor = self._backend_widget.textCursor()
+ cursor.movePosition(QtGui.QTextCursor.End)
+ self._backend_widget.setTextCursor(cursor)
+ self._backend_widget.ensureCursorVisible()
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def value(self) -> str:
+ return self._text
+
+ # Plain text mode
+ def plainTextMode(self) -> bool:
+ return bool(self._plain)
+
+ def setPlainTextMode(self, on: bool = True):
+ self._plain = bool(on)
+ # refresh backend content to reflect mode
+ self.setValue(self._text)
+
+ # Auto scroll
+ def autoScrollDown(self) -> bool:
+ return bool(self._auto_scroll)
+
+ def setAutoScrollDown(self, on: bool = True):
+ self._auto_scroll = bool(on)
+ # optional immediate effect
+ if self._auto_scroll and getattr(self, "_backend_widget", None) is not None:
+ try:
+ cursor = self._backend_widget.textCursor()
+ cursor.movePosition(QtGui.QTextCursor.End)
+ self._backend_widget.setTextCursor(cursor)
+ self._backend_widget.ensureCursorVisible()
+ except Exception:
+ pass
+
+ # Link activation info
+ def lastActivatedUrl(self):
+ return self._last_url
+
+ def _create_backend_widget(self):
+ tb = QtWidgets.QTextBrowser()
+ # Prevent navigation; we only emit events on link clicks
+ try:
+ tb.setOpenExternalLinks(False)
+ except Exception:
+ pass
+ try:
+ tb.setOpenLinks(False)
+ except Exception:
+ pass
+ try:
+ tb.setReadOnly(True)
+ except Exception:
+ pass
+ # set initial content
+ try:
+ if self._plain:
+ tb.setPlainText(self._text)
+ else:
+ tb.setHtml(self._text)
+ except Exception:
+ try:
+ tb.setText(self._text)
+ except Exception:
+ pass
+ # connect link activation
+ def _on_anchor_clicked(url: QtCore.QUrl):
+ try:
+ self._last_url = url.toString()
+ except Exception:
+ self._last_url = None
+ try:
+ dlg = self.findDialog()
+ if dlg and self.notify():
+ dlg._post_event(YMenuEvent( id=url.toString()) )
+ self._logger.debug("Link activated: %s", url.toString())
+ except Exception:
+ self._logger.error("Posting Link activated: %s", url.toString())
+ pass
+ try:
+ tb.anchorClicked.connect(_on_anchor_clicked)
+ except Exception:
+ # fallback: try signal on QLabel-like
+ try:
+ tb.linkActivated.connect(lambda _u: _on_anchor_clicked(QtCore.QUrl(str(_u))))
+ except Exception:
+ pass
+ # Encourage expansion when placed in layouts
+ try:
+ sp = tb.sizePolicy()
+ try:
+ weight_v = int(self.weight(YUIDimension.YD_VERT))
+ weight_h = int(self.weight(YUIDimension.YD_HORIZ))
+ horiz = QtWidgets.QSizePolicy.Expanding if bool(self.stretchable(YUIDimension.YD_HORIZ) or weight_h) else QtWidgets.QSizePolicy.Fixed
+ vert = QtWidgets.QSizePolicy.Expanding if bool(self.stretchable(YUIDimension.YD_VERT) or weight_v) else QtWidgets.QSizePolicy.Fixed
+ sp.setHorizontalPolicy(horiz)
+ sp.setVerticalPolicy(vert)
+ except Exception:
+ self._logger.exception("Error setting size policy weights", exc_info=True)
+ try:
+ sp.setHorizontalPolicy(QtWidgets.QSizePolicy.Expanding)
+ sp.setVerticalPolicy(QtWidgets.QSizePolicy.Expanding)
+ except Exception:
+ pass
+ tb.setSizePolicy(sp)
+ except Exception:
+ pass
+ self._backend_widget = tb
+ # respect initial enabled state
+ try:
+ self._backend_widget.setEnabled(bool(self._enabled))
+ except Exception:
+ self._logger.exception("Failed to set enabled state", exc_info=True)
+ try:
+ if self._help_text:
+ self._backend_widget.setToolTip(self._help_text)
+ except Exception:
+ self._logger.exception("Failed to set tooltip text", exc_info=True)
+ try:
+ self._backend_widget.setVisible(self.visible())
+ except Exception:
+ self._logger.exception("Failed to set widget visibility", exc_info=True)
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+
+ def _set_backend_enabled(self, enabled):
+ try:
+ if getattr(self, "_backend_widget", None) is not None:
+ self._backend_widget.setEnabled(bool(enabled))
+ except Exception:
+ self._logger.exception("Failed to set enabled state", exc_info=True)
+
+ def setVisible(self, visible=True):
+ super().setVisible(visible)
+ try:
+ if getattr(self, "_backend_widget", None) is not None:
+ self._backend_widget.setVisible(visible)
+ except Exception:
+ self._logger.exception("setVisible failed", exc_info=True)
+
+ def setHelpText(self, help_text: str):
+ super().setHelpText(help_text)
+ try:
+ if getattr(self, "_backend_widget", None) is not None:
+ self._backend_widget.setToolTip(help_text)
+ except Exception:
+ self._logger.exception("setHelpText failed", exc_info=True)
\ No newline at end of file
diff --git a/manatools/aui/backends/qt/selectionboxqt.py b/manatools/aui/backends/qt/selectionboxqt.py
new file mode 100644
index 0000000..753e70f
--- /dev/null
+++ b/manatools/aui/backends/qt/selectionboxqt.py
@@ -0,0 +1,353 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.qt contains all Qt backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.qt
+'''
+from PySide6 import QtWidgets
+import logging
+import os
+from typing import Optional
+from ...yui_common import *
+from .commonqt import _resolve_icon
+
+class YSelectionBoxQt(YSelectionWidget):
+ def __init__(self, parent=None, label="", multi_selection: Optional[bool] = False):
+ super().__init__(parent)
+ self._label = label
+ self._value = ""
+ self._selected_items = []
+ self._multi_selection = multi_selection
+ self.setStretchable(YUIDimension.YD_HORIZ, True)
+ self.setStretchable(YUIDimension.YD_VERT, True)
+ self._list_widget = None
+ self._logger = logging.getLogger(f"manatools.aui.qt.{self.__class__.__name__}")
+
+ def widgetClass(self):
+ return "YSelectionBox"
+
+ def value(self):
+ return self._value
+
+ def label(self):
+ return self._label
+
+ def selectedItems(self):
+ """Get list of selected items"""
+ return self._selected_items
+
+ def selectItem(self, item, selected=True):
+ """Select or deselect a specific item"""
+ if hasattr(self, '_list_widget') and self._list_widget:
+ for idx, it in enumerate(self._items):
+ if it is item:
+ if self.multiSelection():
+ if selected:
+ if item not in self._selected_items:
+ self._selected_items.append(item)
+ else:
+ if item in self._selected_items:
+ self._selected_items.remove(item)
+ else:
+ old_selected = self._selected_items[0] if self._selected_items else None
+ self._list_widget.clearSelection()
+ if selected:
+ if old_selected is not None:
+ old_selected.setSelected(False)
+ self._selected_items = [item]
+ else:
+ self._selected_items = []
+ #fix item selection in the view and YItem
+ if selected:
+ self._list_widget.setCurrentItem(self._list_widget.item(idx))
+ self._list_widget.item(idx).setSelected(bool(selected))
+ item.setSelected(bool(selected))
+ self._value = self._selected_items[0].label() if self._selected_items else ""
+ break
+
+ def setMultiSelection(self, enabled):
+ """Enable or disable multi-selection."""
+ self._multi_selection = bool(enabled)
+ if hasattr(self, '_list_widget') and self._list_widget:
+ mode = QtWidgets.QAbstractItemView.MultiSelection if self._multi_selection else QtWidgets.QAbstractItemView.SingleSelection
+ self._list_widget.setSelectionMode(mode)
+ # if disabling multi-selection, collapse to the first selected item
+ if not self._multi_selection:
+ selected = self._list_widget.selectedItems()
+ if len(selected) > 1:
+ first = selected[0]
+ self._list_widget.clearSelection()
+ first.setSelected(True)
+ self._list_widget.setCurrentItem(first)
+ selected_indices = [index.row() for index in self._list_widget.selectedIndexes()]
+ new_selected = []
+ for idx, it in enumerate(self._items):
+ if idx in selected_indices:
+ it.setSelected(True)
+ new_selected.append(it)
+ else:
+ it.setSelected(False)
+ self._selected_items = new_selected
+ self._value = self._selected_items[0].label() if self._selected_items else ""
+
+ def multiSelection(self):
+ """Return whether multi-selection is enabled."""
+ return bool(self._multi_selection)
+
+ def _create_backend_widget(self):
+ container = QtWidgets.QWidget()
+ layout = QtWidgets.QVBoxLayout(container)
+ layout.setContentsMargins(0, 0, 0, 0)
+
+ if self._label:
+ label = QtWidgets.QLabel(self._label)
+ layout.addWidget(label)
+
+ list_widget = QtWidgets.QListWidget()
+ mode = QtWidgets.QAbstractItemView.MultiSelection if self._multi_selection else QtWidgets.QAbstractItemView.SingleSelection
+ list_widget.setSelectionMode(mode)
+
+ # Add items to list widget (use QListWidgetItem to support icons)
+ for item in self._items:
+ try:
+ text = item.label() if hasattr(item, 'label') else str(item)
+ except Exception:
+ try:
+ text = str(item)
+ except Exception:
+ text = ""
+ try:
+ li = QtWidgets.QListWidgetItem(text)
+ icon_name = None
+ try:
+ fn = getattr(item, 'iconName', None)
+ if callable(fn):
+ icon_name = fn()
+ else:
+ icon_name = fn
+ except Exception:
+ icon_name = None
+ icon = _resolve_icon(icon_name)
+ if icon is not None:
+ try:
+ li.setIcon(icon)
+ except Exception:
+ pass
+ list_widget.addItem(li)
+ except Exception:
+ try:
+ list_widget.addItem(item.label())
+ except Exception:
+ try:
+ list_widget.addItem(str(item))
+ except Exception:
+ pass
+
+ # Reflect model's selected flags into the view.
+ # If multi-selection is enabled, select all items flagged selected.
+ # If single-selection, only the last item with selected()==True should be selected.
+ try:
+ if self._multi_selection:
+ for idx, item in enumerate(self._items):
+ try:
+ if item.selected():
+ li = list_widget.item(idx)
+ if li is not None:
+ li.setSelected(True)
+ if item not in self._selected_items:
+ self._selected_items.append(item)
+ except Exception:
+ pass
+ else:
+ last_selected_idx = None
+ for idx, item in enumerate(self._items):
+ try:
+ if item.selected():
+ if last_selected_idx is not None:
+ # clear previous selection in model
+ self._items[last_selected_idx].setSelected(False)
+ last_selected_idx = idx
+ except Exception:
+ pass
+ if last_selected_idx is not None:
+ li = list_widget.item(last_selected_idx)
+ if li is not None:
+ list_widget.setCurrentItem(li)
+ li.setSelected(True)
+ # update model internal selection list and value
+ self._selected_items = [self._items[last_selected_idx]]
+ except Exception:
+ pass
+
+ list_widget.itemSelectionChanged.connect(self._on_selection_changed)
+ layout.addWidget(list_widget)
+
+ self._backend_widget = container
+ self._backend_widget.setEnabled(bool(self._enabled))
+ self._list_widget = list_widget
+ self._value = self._selected_items[0].label() if self._selected_items else ""
+ try:
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception:
+ pass
+
+ def _set_backend_enabled(self, enabled):
+ """Enable/disable the selection box and its list widget; propagate where applicable."""
+ try:
+ if getattr(self, "_list_widget", None) is not None:
+ try:
+ self._list_widget.setEnabled(bool(enabled))
+ except Exception:
+ pass
+ except Exception:
+ pass
+ try:
+ if getattr(self, "_backend_widget", None) is not None:
+ try:
+ self._backend_widget.setEnabled(bool(enabled))
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def addItem(self, item):
+ """Add a single item to the selection box (model + Qt view)."""
+ # Let base class normalize strings to YItem and append to _items
+ super().addItem(item)
+
+ # The newly added item is the last in the model list
+ try:
+ new_item = self._items[-1]
+ except Exception:
+ return
+
+ # Ensure index is set
+ try:
+ new_item.setIndex(len(self._items) - 1)
+ except Exception:
+ pass
+
+ # If the backend list widget exists, append a visual entry
+ try:
+ if getattr(self, '_list_widget', None) is not None:
+ try:
+ try:
+ text = new_item.label() if hasattr(new_item, 'label') else str(new_item)
+ except Exception:
+ text = str(new_item)
+ try:
+ li = QtWidgets.QListWidgetItem(text)
+ icon_name = None
+ try:
+ fn = getattr(new_item, 'iconName', None)
+ if callable(fn):
+ icon_name = fn()
+ else:
+ icon_name = fn
+ except Exception:
+ icon_name = None
+ icon = _resolve_icon(icon_name)
+ if icon is not None:
+ try:
+ li.setIcon(icon)
+ except Exception:
+ pass
+ self._list_widget.addItem(li)
+ except Exception:
+ try:
+ self._list_widget.addItem(new_item.label())
+ except Exception:
+ try:
+ self._list_widget.addItem(str(new_item))
+ except Exception:
+ pass
+ # If the item is marked selected in the model, reflect it.
+ if new_item.selected():
+ idx = self._list_widget.count() - 1
+ list_item = self._list_widget.item(idx)
+ if list_item is not None:
+ # For single-selection, clear previous selections so
+ # only the newly-added selected item remains selected.
+ if not self._multi_selection:
+ try:
+ self._list_widget.clearSelection()
+ except Exception:
+ pass
+ # Also clear model-side selected flags for other items
+ for it in self._items[:-1]:
+ try:
+ it.setSelected(False)
+ except Exception:
+ pass
+ self._selected_items = []
+
+ list_item.setSelected(True)
+ if new_item not in self._selected_items:
+ self._selected_items.append(new_item)
+ # Update value to the newly selected item (single or last)
+ try:
+ self._value = new_item.label()
+ except Exception:
+ pass
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def _on_selection_changed(self):
+ """Handle selection change in the list widget"""
+ if hasattr(self, '_list_widget') and self._list_widget:
+ # Update model.selected flags and selected items list
+ selected_indices = [index.row() for index in self._list_widget.selectedIndexes()]
+ new_selected = []
+ for idx, it in enumerate(self._items):
+ if idx in selected_indices:
+ it.setSelected(True)
+ new_selected.append(it)
+ else:
+ it.setSelected(False)
+
+ # In single-selection mode ensure only one model item remains selected
+ if not self._multi_selection and len(new_selected) > 1:
+ # Keep only the last selected (mimic addItem logic and selection expectations)
+ last = new_selected[-1]
+ for it in list(new_selected)[:-1]:
+ it.setSelected(False)
+ new_selected = [last]
+
+ self._selected_items = new_selected
+
+ # Update value to first selected item
+ if self._selected_items:
+ self._value = self._selected_items[0].label()
+ else:
+ self._value = ""
+
+ # Post selection-changed event to containing dialog
+ if self.notify():
+ dlg = self.findDialog()
+ if dlg is not None:
+ dlg._post_event(YWidgetEvent(self, YEventReason.SelectionChanged))
+
+ def deleteAllItems(self):
+ """Remove all items from the selection box, both in the model and the Qt view."""
+ # Clear internal model state
+ super().deleteAllItems()
+ self._value = ""
+ self._selected_items = []
+
+ # Clear Qt list widget if present
+ try:
+ if getattr(self, '_list_widget', None) is not None:
+ try:
+ self._list_widget.clear()
+ except Exception:
+ pass
+ except Exception:
+ pass
+
diff --git a/manatools/aui/backends/qt/sliderqt.py b/manatools/aui/backends/qt/sliderqt.py
new file mode 100644
index 0000000..f7be6aa
--- /dev/null
+++ b/manatools/aui/backends/qt/sliderqt.py
@@ -0,0 +1,140 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.qt contains all Qt backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.qt
+'''
+"""
+Qt backend Slider widget.
+- Horizontal slider synchronized with a spin box.
+- Emits ValueChanged on changes and Activated on user release.
+- Default stretchable horizontally.
+"""
+from PySide6 import QtWidgets, QtCore
+import logging
+from ...yui_common import *
+
+class YSliderQt(YWidget):
+ def __init__(self, parent=None, label: str = "", minVal: int = 0, maxVal: int = 100, initialVal: int = 0):
+ super().__init__(parent)
+ self._logger = logging.getLogger(f"manatools.aui.qt.{self.__class__.__name__}")
+ self._label_text = str(label) if label else ""
+ self._min = int(minVal)
+ self._max = int(maxVal)
+ if self._min > self._max:
+ self._min, self._max = self._max, self._min
+ self._value = max(self._min, min(self._max, int(initialVal)))
+ # stretchable horizontally by default
+ self.setStretchable(YUIDimension.YD_HORIZ, True)
+ self.setStretchable(YUIDimension.YD_VERT, False)
+
+ def widgetClass(self):
+ return "YSlider"
+
+ def value(self) -> int:
+ return int(self._value)
+
+ def setValue(self, v: int):
+ prev = self._value
+ self._value = max(self._min, min(self._max, int(v)))
+ try:
+ if getattr(self, "_slider", None) is not None:
+ self._slider.setValue(self._value)
+ if getattr(self, "_spin", None) is not None:
+ self._spin.setValue(self._value)
+ except Exception:
+ pass
+ if self._value != prev and self.notify():
+ dlg = self.findDialog()
+ if dlg is not None:
+ dlg._post_event(YWidgetEvent(self, YEventReason.ValueChanged))
+
+ def _create_backend_widget(self):
+ try:
+ root = QtWidgets.QWidget()
+ lay = QtWidgets.QHBoxLayout(root)
+ lay.setContentsMargins(10, 10, 10, 10)
+ lay.setSpacing(8)
+
+ if self._label_text:
+ lbl = QtWidgets.QLabel(self._label_text)
+ lay.addWidget(lbl)
+
+ slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal)
+ slider.setRange(self._min, self._max)
+ slider.setValue(self._value)
+ slider.setTracking(True)
+
+ spin = QtWidgets.QSpinBox()
+ spin.setRange(self._min, self._max)
+ spin.setValue(self._value)
+
+ # expand policy for slider so it takes available space
+ try:
+ sp = slider.sizePolicy()
+ sp.setHorizontalPolicy(QtWidgets.QSizePolicy.Policy.Expanding)
+ slider.setSizePolicy(sp)
+ except Exception:
+ pass
+
+ lay.addWidget(slider, stretch=1)
+ lay.addWidget(spin)
+
+ # wire signals
+ def _on_slider_changed(val):
+ try:
+ spin.setValue(val)
+ except Exception:
+ pass
+ old = self._value
+ self._value = int(val)
+ if self.notify() and old != self._value:
+ dlg = self.findDialog()
+ if dlg is not None:
+ dlg._post_event(YWidgetEvent(self, YEventReason.ValueChanged))
+ def _on_slider_released():
+ if self.notify():
+ dlg = self.findDialog()
+ if dlg is not None:
+ dlg._post_event(YWidgetEvent(self, YEventReason.Activated))
+ def _on_spin_changed(val):
+ try:
+ slider.setValue(int(val))
+ except Exception:
+ pass
+ old = self._value
+ self._value = int(val)
+ if self.notify() and old != self._value:
+ dlg = self.findDialog()
+ if dlg is not None:
+ dlg._post_event(YWidgetEvent(self, YEventReason.ValueChanged))
+
+ slider.valueChanged.connect(_on_slider_changed)
+ slider.sliderReleased.connect(_on_slider_released)
+ spin.valueChanged.connect(_on_spin_changed)
+
+ self._slider = slider
+ self._spin = spin
+ self._backend_widget = root
+ self._backend_widget.setEnabled(bool(self._enabled))
+ try:
+ self._logger.debug("_create_backend_widget: <%s> range=[%d,%d] value=%d", self.debugLabel(), self._min, self._max, self._value)
+ except Exception:
+ pass
+ except Exception:
+ try:
+ self._logger.exception("YSliderQt _create_backend_widget failed")
+ except Exception:
+ pass
+
+ def _set_backend_enabled(self, enabled):
+ try:
+ if getattr(self, "_backend_widget", None) is not None:
+ self._backend_widget.setEnabled(bool(enabled))
+ except Exception:
+ pass
diff --git a/manatools/aui/backends/qt/spacingqt.py b/manatools/aui/backends/qt/spacingqt.py
new file mode 100644
index 0000000..1225ff8
--- /dev/null
+++ b/manatools/aui/backends/qt/spacingqt.py
@@ -0,0 +1,115 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Qt backend: YSpacing implementation using a lightweight QWidget.
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.qt
+'''
+from PySide6 import QtWidgets, QtCore
+import logging
+from ...yui_common import *
+from .commonqt import _resolve_icon
+
+
+class YSpacingQt(YWidget):
+ """Spacing/Stretch widget for Qt.
+
+ Constructor arguments:
+ - parent: containing widget
+ - dim: `YUIDimension` — primary dimension where spacing applies
+ - stretchable: bool — if True, the spacing expands in its primary dimension
+ - size: float — spacing size in pixels (device units). When stretchable is True,
+ this acts as a minimum size in the primary dimension. When False, the size is
+ fixed in the primary dimension.
+
+ Notes:
+ - This widget is visually empty; it only reserves space.
+ - Pixels are used as the unit to avoid ambiguity. Other backends convert as
+ appropriate (e.g., curses maps pixels to character cells using a fixed ratio).
+ """
+ def __init__(self, parent=None, dim: YUIDimension = YUIDimension.YD_HORIZ, stretchable: bool = False, size_px: int = 0):
+ super().__init__(parent)
+ self._dim = dim
+ self._stretchable = bool(stretchable)
+ try:
+ # integer pixel size; minimum granularity is 1 px when non-zero
+ spx = int(size_px)
+ self._size_px = 0 if spx <= 0 else max(1, spx)
+ except Exception:
+ self._size_px = 0
+ self._logger = logging.getLogger(f"manatools.aui.qt.{self.__class__.__name__}")
+ # Sync base stretch flags so containers can query stretchable()
+ try:
+ self.setStretchable(self._dim, self._stretchable)
+ except Exception:
+ pass
+ try:
+ self._logger.debug("%s.__init__(dim=%s, stretchable=%s, size_px=%d)", self.__class__.__name__, self._dim, self._stretchable, self._size_px)
+ except Exception:
+ pass
+
+ def widgetClass(self):
+ return "YSpacing"
+
+ def dimension(self):
+ return self._dim
+
+ def size(self):
+ return self._size_px
+
+ def sizeDim(self, dim: YUIDimension):
+ return self._size_px if dim == self._dim else 0
+
+ def _create_backend_widget(self):
+ try:
+ w = QtWidgets.QWidget()
+ sp = w.sizePolicy()
+ if self._dim == YUIDimension.YD_HORIZ:
+ # horizontal spacing
+ try:
+ if self._stretchable:
+ sp.setHorizontalPolicy(QtWidgets.QSizePolicy.Policy.MinimumExpanding)
+ w.setMinimumWidth(self._size_px)
+ else:
+ sp.setHorizontalPolicy(QtWidgets.QSizePolicy.Policy.Fixed)
+ w.setFixedWidth(self._size_px)
+ # vertical should not force expansion
+ sp.setVerticalPolicy(QtWidgets.QSizePolicy.Policy.Preferred)
+ except Exception:
+ pass
+ else:
+ # vertical spacing
+ try:
+ if self._stretchable:
+ sp.setVerticalPolicy(QtWidgets.QSizePolicy.Policy.MinimumExpanding)
+ w.setMinimumHeight(self._size_px)
+ else:
+ sp.setVerticalPolicy(QtWidgets.QSizePolicy.Policy.Fixed)
+ w.setFixedHeight(self._size_px)
+ sp.setHorizontalPolicy(QtWidgets.QSizePolicy.Policy.Preferred)
+ except Exception:
+ pass
+ try:
+ w.setSizePolicy(sp)
+ except Exception:
+ pass
+ self._backend_widget = w
+ self._backend_widget.setEnabled(bool(self._enabled))
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception:
+ try:
+ self._logger.exception("Failed to create YSpacingQt backend")
+ except Exception:
+ pass
+ self._backend_widget = QtWidgets.QWidget()
+
+ def _set_backend_enabled(self, enabled):
+ try:
+ if self._backend_widget is not None:
+ self._backend_widget.setEnabled(bool(enabled))
+ except Exception:
+ pass
diff --git a/manatools/aui/backends/qt/tableqt.py b/manatools/aui/backends/qt/tableqt.py
new file mode 100644
index 0000000..2fb3f85
--- /dev/null
+++ b/manatools/aui/backends/qt/tableqt.py
@@ -0,0 +1,617 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+"""
+Qt backend for YTable using QTableWidget.
+
+Supports multi-column rows via `YTableItem`/`YTableCell` from `yui_common`.
+Cells that have a checkbox (their `checked` attribute is not None) are
+rendered as checkboxes and the table forces single-selection mode in that
+case (to keep curses/simple-mode compatibility).
+"""
+from PySide6 import QtWidgets, QtCore, QtGui
+from functools import partial
+import logging
+from ...yui_common import *
+
+
+class YTableWidgetItem(QtWidgets.QTableWidgetItem):
+ """QTableWidgetItem subclass that prefers a stored sort key (UserRole)
+ when comparing for sorting. Falls back to the default behaviour.
+ """
+ def __lt__(self, other):
+ try:
+ my_sort = self.data(QtCore.Qt.UserRole)
+ other_sort = other.data(QtCore.Qt.UserRole)
+ if my_sort is not None and other_sort is not None:
+ return str(my_sort) < str(other_sort)
+ except Exception:
+ pass
+ return super(YTableWidgetItem, self).__lt__(other)
+
+
+class YTableQt(YSelectionWidget):
+ def __init__(self, parent, header: YTableHeader, multiSelection=False):
+ super().__init__(parent)
+ self._header = header
+ self._multi = bool(multiSelection)
+ # force single-selection if any checkbox cells present
+ if self._header is not None:
+ try:
+ for c_idx in range(self._header.columns()):
+ if self._header.isCheckboxColumn(c_idx):
+ self._multi = False
+ break
+ except Exception:
+ pass
+ else:
+ raise ValueError("YTableQt requires a YTableHeader")
+
+ self._table = None
+ self._row_to_item = {}
+ self._item_to_row = {}
+ self._suppress_selection_handler = False
+ self._suppress_item_change = False
+ self._logger = logging.getLogger(f"manatools.aui.qt.{self.__class__.__name__}")
+ self._changed_item = None
+
+ def widgetClass(self):
+ return "YTable"
+
+ def _header_is_checkbox(self, col):
+ try:
+ if getattr(self, '_header', None) is None:
+ return False
+ if hasattr(self._header, 'isCheckboxColumn'):
+ return bool(self._header.isCheckboxColumn(col))
+ return False
+ except Exception:
+ return False
+
+ def _create_backend_widget(self):
+ tbl = QtWidgets.QTableWidget()
+ tbl.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows)
+ mode = QtWidgets.QAbstractItemView.MultiSelection if self._multi else QtWidgets.QAbstractItemView.SingleSelection
+ tbl.setSelectionMode(mode)
+ tbl.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers)
+ tbl.itemSelectionChanged.connect(self._on_selection_changed)
+ #tbl.itemChanged.connect(self._on_item_changed)
+ self._table = tbl
+ self._backend_widget = tbl
+ # respect initial enabled state
+ try:
+ self._table.setEnabled(bool(getattr(self, "_enabled", True)))
+ except Exception:
+ pass
+ # Apply help text (tooltip) and initial visibility if provided
+ try:
+ if getattr(self, "_help_text", None):
+ try:
+ self._backend_widget.setToolTip(self._help_text)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ try:
+ self._backend_widget.setVisible(self.visible())
+ except Exception:
+ pass
+ # populate if items already present
+ try:
+ self.rebuildTable()
+ except Exception:
+ self._logger.exception("rebuildTable failed during _create_backend_widget")
+
+ def _detect_columns_and_checkboxes(self):
+ # compute max columns and whether any checkbox cells present
+ max_cols = 0
+ any_checkbox = False
+ for it in list(getattr(self, "_items", []) or []):
+ try:
+ cnt = it.cellCount() if hasattr(it, 'cellCount') else 0
+ max_cols = max(max_cols, cnt)
+ for c in it.cellsBegin() if hasattr(it, 'cellsBegin') else []:
+ try:
+ if getattr(c, '_checked', None) is not None:
+ any_checkbox = True
+ break
+ except Exception:
+ pass
+ except Exception:
+ pass
+ return max_cols, any_checkbox
+
+ def rebuildTable(self):
+ self._logger.debug("rebuildTable: rebuilding table with %d items", len(self._items) if self._items else 0)
+ if self._table is None:
+ self._create_backend_widget()
+ # determine columns and checkbox usage
+ cols = 0
+ any_checkbox = False
+ if getattr(self, '_header', None) is not None and hasattr(self._header, 'columns'):
+ try:
+ cols = int(self._header.columns())
+ except Exception:
+ cols = 0
+ # any_checkbox if header declares any checkbox column
+ try:
+ for c_idx in range(cols):
+ if self._header_is_checkbox(c_idx):
+ any_checkbox = True
+ break
+ except Exception:
+ any_checkbox = False
+ if cols <= 0:
+ cols, any_checkbox = self._detect_columns_and_checkboxes()
+ if cols <= 0:
+ cols = 1
+ # enforce single-selection if checkbox columns used
+ if any_checkbox:
+ self._multi = False
+ mode = QtWidgets.QAbstractItemView.MultiSelection if self._multi else QtWidgets.QAbstractItemView.SingleSelection
+ try:
+ self._table.setSelectionMode(mode)
+ except Exception:
+ pass
+
+ # set column headers if available
+ try:
+ headers = []
+ for c in range(cols):
+ if getattr(self, '_header', None) is not None and hasattr(self._header, 'hasColumn') and self._header.hasColumn(c):
+ headers.append(self._header.header(c))
+ else:
+ headers.append("")
+ try:
+ self._table.setColumnCount(cols)
+ self._table.setHorizontalHeaderLabels(headers)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ # clear existing
+ self._row_to_item.clear()
+ self._item_to_row.clear()
+ # clear contents only to preserve header labels
+ self._table.clearContents()
+ self._table.setRowCount(0)
+ # ensure column count already set above
+
+ # build rows (suppress item change notifications while programmatically populating)
+ try:
+ self._suppress_item_change = True
+ for row_idx, it in enumerate(list(getattr(self, '_items', []) or [])):
+ try:
+ self._table.insertRow(row_idx)
+ self._row_to_item[row_idx] = it
+ self._item_to_row[it] = row_idx
+ # populate cells: only up to 'cols' columns defined by header/detection
+ for col in range(cols):
+ cell = None
+ try:
+ cell = it.cell(col) if hasattr(it, 'cell') else None
+ except Exception:
+ cell = None
+
+ text = ""
+ sort_key = None
+ if cell is not None:
+ try:
+ text = cell.label()
+ except Exception:
+ text = ""
+ try:
+ if hasattr(cell, 'hasSortKey') and cell.hasSortKey():
+ sort_key = cell.sortKey()
+ except Exception:
+ sort_key = None
+
+ # create item that supports sortKey via UserRole
+ qit = YTableWidgetItem(text)
+ if sort_key is not None:
+ try:
+ qit.setData(QtCore.Qt.UserRole, sort_key)
+ except Exception:
+ pass
+
+ # determine if this column is a checkbox column according to header
+ is_checkbox_col = False
+ try:
+ if getattr(self, '_header', None) is not None:
+ is_checkbox_col = self._header_is_checkbox(col)
+ else:
+ # fallback: treat as checkbox if cell explicitly has _checked
+ is_checkbox_col = (getattr(cell, '_checked', None) is not None)
+ except Exception:
+ is_checkbox_col = (getattr(cell, '_checked', None) is not None)
+
+ # apply flags and, for checkbox columns, prefer a centered checkbox widget
+ try:
+ flags = qit.flags() | QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled
+ if is_checkbox_col:
+ # keep item non-user-checkable; we'll install a centered QCheckBox widget
+ qit.setFlags(flags)
+ # set sortable value if no explicit sort key
+ if sort_key is None:
+ try:
+ qit.setData(QtCore.Qt.UserRole, 1 if (cell is not None and cell.checked()) else 0)
+ except Exception:
+ pass
+ else:
+ qit.setFlags(flags)
+ except Exception:
+ pass
+
+ # alignment according to header
+ try:
+ if getattr(self, '_header', None) is not None and hasattr(self._header, 'alignment') and self._header.hasColumn(col):
+ align = self._header.alignment(col)
+ if align == YAlignmentType.YAlignCenter:
+ qit.setTextAlignment(QtCore.Qt.AlignCenter)
+ elif align == YAlignmentType.YAlignEnd:
+ qit.setTextAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter)
+ else:
+ qit.setTextAlignment(QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter)
+ except Exception:
+ pass
+
+ try:
+ self._table.setItem(row_idx, col, qit)
+ # for checkbox columns, install a checkbox widget honoring header alignment and connect
+ if is_checkbox_col:
+ try:
+ chk = QtWidgets.QCheckBox()
+ try:
+ chk.setChecked(cell.checked() if cell is not None else False)
+ except Exception:
+ chk.setChecked(False)
+ chk.setFocusPolicy(QtCore.Qt.NoFocus)
+ container = QtWidgets.QWidget()
+ lay = QtWidgets.QHBoxLayout(container)
+ lay.setContentsMargins(0, 0, 0, 0)
+ # determine alignment from header
+ try:
+ align_type = self._header.alignment(col)
+ except Exception:
+ align_type = YAlignmentType.YAlignBegin
+ if align_type == YAlignmentType.YAlignCenter:
+ lay.addStretch(1)
+ lay.addWidget(chk, alignment=QtCore.Qt.AlignCenter)
+ lay.addStretch(1)
+ elif align_type == YAlignmentType.YAlignEnd:
+ lay.addStretch(1)
+ lay.addWidget(chk, alignment=QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter)
+ else:
+ lay.addWidget(chk, alignment=QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter)
+ lay.addStretch(1)
+ self._table.setCellWidget(row_idx, col, container)
+ chk.toggled.connect(partial(self._on_checkbox_toggled, row_idx, col))
+ except Exception:
+ pass
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ finally:
+ self._suppress_item_change = False
+
+ # apply selection from YTableItem.selected flags
+ desired_rows = []
+ try:
+ for it, row in list(self._item_to_row.items()):
+ try:
+ sel = False
+ if hasattr(it, 'selected') and callable(getattr(it, 'selected')):
+ sel = it.selected()
+ else:
+ sel = bool(getattr(it, '_selected', False))
+ if sel:
+ desired_rows.append(row)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ # set selection programmatically
+ try:
+ self._suppress_selection_handler = True
+ try:
+ self._table.clearSelection()
+ except Exception:
+ pass
+ if desired_rows:
+ if self._multi:
+ for r in desired_rows:
+ try:
+ self._table.selectRow(r)
+ except Exception:
+ pass
+ else:
+ # pick first desired row
+ try:
+ self._table.selectRow(desired_rows[0])
+ except Exception:
+ pass
+ # update internal selected list
+ new_selected = []
+ for r in desired_rows:
+ it = self._row_to_item.get(r, None)
+ if it is not None:
+ try:
+ it.setSelected(True)
+ except Exception:
+ pass
+ new_selected.append(it)
+ self._selected_items = new_selected
+ finally:
+ self._suppress_selection_handler = False
+
+ def _on_selection_changed(self):
+ if self._suppress_selection_handler:
+ return
+ self._logger.debug("_on_selection_changed")
+ try:
+ sel_ranges = self._table.selectionModel().selectedRows()
+ new_selected = []
+ for idx in sel_ranges:
+ try:
+ row = idx.row()
+ it = self._row_to_item.get(row, None)
+ if it is not None:
+ new_selected.append(it)
+ except Exception:
+ pass
+
+ # clear all selected flags then set for new_selected
+ try:
+ for it in list(getattr(self, '_items', []) or []):
+ try:
+ it.setSelected(False)
+ except Exception:
+ pass
+ for it in new_selected:
+ try:
+ it.setSelected(True)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ # enforce single-selection semantics
+ if not self._multi and len(new_selected) > 1:
+ new_selected = [new_selected[-1]]
+
+ self._selected_items = new_selected
+
+ # notify immediate mode
+ try:
+ if self.notify():
+ dlg = self.findDialog()
+ if dlg is not None:
+ dlg._post_event(YWidgetEvent(self, YEventReason.SelectionChanged))
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def _on_item_changed(self, qitem: QtWidgets.QTableWidgetItem):
+ # handle checkbox toggles
+ if self._suppress_item_change:
+ return
+ self._logger.debug("_on_item_changed: row=%d col=%d", qitem.row(), qitem.column())
+ try:
+ # Only treat as checkbox change if header declares this column as checkbox
+ col = qitem.column()
+ is_checkbox_col = False
+ try:
+ is_checkbox_col = self._header_is_checkbox(col)
+ except Exception:
+ is_checkbox_col = bool(qitem.flags() & QtCore.Qt.ItemIsUserCheckable)
+ if not is_checkbox_col:
+ return
+ row = qitem.row()
+ it = self._row_to_item.get(row, None)
+ if it is None:
+ return
+ cell = it.cell(col)
+ if cell is None:
+ return
+ # update model checkbox
+ try:
+ checked = (qitem.checkState() == QtCore.Qt.Checked)
+ cell.setChecked(checked)
+ self._changed_item = it
+ except Exception:
+ pass
+ # when checkbox is used, assume this is a value change
+ try:
+ dlg = self.findDialog()
+ if dlg is not None and self.notify():
+ dlg._post_event(YWidgetEvent(self, YEventReason.ValueChanged))
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def _on_checkbox_toggled(self, row, col, checked):
+ # update model and sort role when the embedded checkbox widget toggles
+ self._logger.debug("_on_checkbox_toggled: row=%d col=%d checked=%s", row, col, checked)
+ try:
+ it = self._row_to_item.get(row, None)
+ if it is None:
+ return
+ cell = it.cell(col)
+ if cell is None:
+ return
+ try:
+ cell.setChecked(bool(checked))
+ self._changed_item = it
+ except Exception:
+ pass
+ # keep sorting role consistent if no explicit sort key
+ try:
+ qit = self._table.item(row, col)
+ if qit is not None and (cell is not None) and not (hasattr(cell, 'hasSortKey') and cell.hasSortKey()):
+ qit.setData(QtCore.Qt.UserRole, 1 if checked else 0)
+ except Exception:
+ pass
+ # notify value change
+ try:
+ dlg = self.findDialog()
+ if dlg is not None and self.notify():
+ dlg._post_event(YWidgetEvent(self, YEventReason.ValueChanged))
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def addItem(self, item):
+ if isinstance(item, str):
+ item = YTableItem(item)
+ super().addItem(item)
+ elif isinstance(item, YTableItem):
+ super().addItem(item)
+ else:
+ self._logger.error("YTable.addItem: invalid item type %s", type(item))
+ raise TypeError("YTable.addItem expects a YTableItem or string label")
+
+ item.setIndex(len(self._items) - 1)
+ if getattr(self, '_table', None) is not None:
+ self.rebuildTable()
+
+ def addItems(self, items):
+ '''add multiple items to the table. This is more efficient than calling addItem repeatedly.'''
+ for item in items:
+ if isinstance(item, str):
+ item = YTableItem(item)
+ super().addItem(item)
+ elif isinstance(item, YTableItem):
+ super().addItem(item)
+ else:
+ self._logger.error("YTable.addItem: invalid item type %s", type(item))
+ raise TypeError("YTable.addItem expects a YTableItem or string label")
+ item.setIndex(len(self._items) - 1)
+ if getattr(self, '_table', None) is not None:
+ self.rebuildTable()
+
+ def selectItem(self, item, selected=True):
+ # update model and view
+ try:
+ try:
+ item.setSelected(bool(selected))
+ except Exception:
+ pass
+ if getattr(self, '_table', None) is None:
+ # just update model
+ if selected:
+ if item not in self._selected_items:
+ if not self._multi:
+ self._selected_items = [item]
+ else:
+ self._selected_items.append(item)
+ else:
+ try:
+ if item in self._selected_items:
+ self._selected_items.remove(item)
+ except Exception:
+ pass
+ return
+
+ row = self._item_to_row.get(item, None)
+ if row is None:
+ try:
+ self.rebuildTable()
+ row = self._item_to_row.get(item, None)
+ except Exception:
+ row = None
+ if row is None:
+ return
+
+ try:
+ self._suppress_selection_handler = True
+ if not self._multi and selected:
+ try:
+ self._table.clearSelection()
+ except Exception:
+ pass
+ if selected:
+ self._table.selectRow(row)
+ else:
+ try:
+ # deselect programmatically
+ sel_model = self._table.selectionModel()
+ idx = sel_model.model().index(row, 0)
+ sel_model.select(idx, QtCore.QItemSelectionModel.Deselect | QtCore.QItemSelectionModel.Rows)
+ except Exception:
+ pass
+ finally:
+ self._suppress_selection_handler = False
+
+ # update internal list
+ try:
+ new_selected = []
+ for idx in self._table.selectionModel().selectedRows():
+ it2 = self._row_to_item.get(idx.row(), None)
+ if it2 is not None:
+ new_selected.append(it2)
+ if not self._multi and len(new_selected) > 1:
+ new_selected = [new_selected[-1]]
+ self._selected_items = new_selected
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def deleteAllItems(self):
+ try:
+ super().deleteAllItems()
+ self._changed_item = None
+ except Exception:
+ self._items = []
+ self._selected_items = []
+ try:
+ self._row_to_item.clear()
+ except Exception:
+ pass
+ try:
+ self._item_to_row.clear()
+ except Exception:
+ pass
+ try:
+ if getattr(self, '_table', None) is not None:
+ self._table.setRowCount(0)
+ # keep header labels intact
+ self._table.clearContents()
+ except Exception:
+ pass
+
+ def changedItem(self):
+ return self._changed_item
+
+ def _set_backend_enabled(self, enabled):
+ """Enable/disable the Qt table at runtime."""
+ try:
+ if getattr(self, "_table", None) is not None:
+ self._table.setEnabled(bool(enabled))
+ except Exception:
+ pass
+
+ def setVisible(self, visible: bool = True):
+ super().setVisible(visible)
+ try:
+ if getattr(self, "_backend_widget", None) is not None:
+ self._backend_widget.setVisible(bool(visible))
+ except Exception:
+ self._logger.exception("setVisible failed", exc_info=True)
+
+ def setHelpText(self, help_text: str):
+ super().setHelpText(help_text)
+ try:
+ if getattr(self, "_backend_widget", None) is not None:
+ try:
+ self._backend_widget.setToolTip(help_text)
+ except Exception:
+ pass
+ except Exception:
+ self._logger.exception("setHelpText failed", exc_info=True)
diff --git a/manatools/aui/backends/qt/timefieldqt.py b/manatools/aui/backends/qt/timefieldqt.py
new file mode 100644
index 0000000..e0049d3
--- /dev/null
+++ b/manatools/aui/backends/qt/timefieldqt.py
@@ -0,0 +1,126 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.qt contains all Qt backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.qt
+'''
+from PySide6 import QtWidgets, QtCore
+import logging
+import datetime
+from ...yui_common import *
+
+
+class YTimeFieldQt(YWidget):
+ """Qt backend YTimeField implementation using QTimeEdit.
+ value()/setValue() use HH:MM:SS. No change events posted.
+ """
+ def __init__(self, parent=None, label: str = ""):
+ super().__init__(parent)
+ self._label = label or ""
+ self._logger = logging.getLogger(f"manatools.aui.qt.{self.__class__.__name__}")
+ self._time = datetime.time(0, 0, 0)
+ self.setStretchable(YUIDimension.YD_HORIZ, False)
+ self.setStretchable(YUIDimension.YD_VERT, False)
+
+ def widgetClass(self):
+ return "YTimeField"
+
+ def value(self) -> str:
+ try:
+ t = getattr(self, '_time', None)
+ if t is None:
+ return ''
+ return f"{t.hour:02d}:{t.minute:02d}:{t.second:02d}"
+ except Exception:
+ return ""
+
+ def setValue(self, timestr: str):
+ try:
+ parts = str(timestr).split(':')
+ if len(parts) != 3:
+ return
+ h, m, s = [int(p) for p in parts]
+ h = max(0, min(23, h))
+ m = max(0, min(59, m))
+ s = max(0, min(59, s))
+ self._time = datetime.time(h, m, s)
+ if getattr(self, '_edit', None) is not None:
+ try:
+ self._edit.setTime(QtCore.QTime(h, m, s))
+ except Exception:
+ pass
+ except Exception as e:
+ self._logger.exception("setValue failed: %s", e)
+
+ def _create_backend_widget(self):
+ cont = QtWidgets.QWidget()
+ lay = QtWidgets.QVBoxLayout(cont)
+ lay.setContentsMargins(0, 0, 0, 0)
+ lay.setSpacing(2)
+ if self._label:
+ lbl = QtWidgets.QLabel(self._label)
+ lay.addWidget(lbl)
+ self._label_widget = lbl
+ edit = QtWidgets.QTimeEdit()
+ try:
+ edit.setDisplayFormat("HH:mm:ss")
+ edit.setTime(QtCore.QTime(self._time.hour, self._time.minute, self._time.second))
+ except Exception:
+ self._logger.exception("_create_backend_widget: couldn't set time edit format or time")
+
+ def _on_time_changed(qt: QtCore.QTime):
+ try:
+ self._time = datetime.time(qt.hour(), qt.minute(), qt.second())
+ except Exception:
+ pass
+ try:
+ edit.timeChanged.connect(_on_time_changed)
+ except Exception:
+ pass
+ # Apply size policy based on stretchable hints to both the time edit and its container
+ try:
+ try:
+ horiz_policy = QtWidgets.QSizePolicy.Policy.Expanding if self.stretchable(YUIDimension.YD_HORIZ) else QtWidgets.QSizePolicy.Policy.Fixed
+ vert_policy = QtWidgets.QSizePolicy.Policy.Expanding if self.stretchable(YUIDimension.YD_VERT) else QtWidgets.QSizePolicy.Policy.Fixed
+ except Exception:
+ horiz_policy = QtWidgets.QSizePolicy.Expanding if self.stretchable(YUIDimension.YD_HORIZ) else QtWidgets.QSizePolicy.Fixed
+ vert_policy = QtWidgets.QSizePolicy.Expanding if self.stretchable(YUIDimension.YD_VERT) else QtWidgets.QSizePolicy.Fixed
+
+ try:
+ sp_edit = edit.sizePolicy()
+ sp_edit.setHorizontalPolicy(horiz_policy)
+ sp_edit.setVerticalPolicy(vert_policy)
+ edit.setSizePolicy(sp_edit)
+ except Exception:
+ pass
+
+ try:
+ sp_cont = cont.sizePolicy()
+ sp_cont.setHorizontalPolicy(horiz_policy)
+ sp_cont.setVerticalPolicy(vert_policy)
+ cont.setSizePolicy(sp_cont)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ lay.addWidget(edit)
+ self._edit = edit
+ self._backend_widget = cont
+ try:
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception:
+ pass
+
+ def _set_backend_enabled(self, enabled):
+ try:
+ if getattr(self, '_edit', None) is not None:
+ self._edit.setEnabled(bool(enabled))
+ if getattr(self, '_label_widget', None) is not None:
+ self._label_widget.setEnabled(bool(enabled))
+ except Exception:
+ pass
diff --git a/manatools/aui/backends/qt/treeqt.py b/manatools/aui/backends/qt/treeqt.py
new file mode 100644
index 0000000..11bc5a6
--- /dev/null
+++ b/manatools/aui/backends/qt/treeqt.py
@@ -0,0 +1,711 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.qt contains all Qt backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.qt
+'''
+from PySide6 import QtWidgets, QtGui
+import logging
+from ...yui_common import *
+from .commonqt import _resolve_icon
+
+class YTreeQt(YSelectionWidget):
+ """
+ Qt backend for YTree (based on YTree.h semantics).
+ - Supports multiSelection and immediateMode.
+ - Rebuild tree from internal items with rebuildTree().
+ - currentItem() returns the YTreeItem wrapper for the focused/selected QTreeWidgetItem.
+ - activate() simulates user activation of the current item (posts an Activated event).
+ - recursiveSelection if it should select children recursively
+ """
+ def __init__(self, parent=None, label="", multiSelection=False, recursiveSelection=False):
+ super().__init__(parent)
+ self._label = label
+ self._multi = bool(multiSelection)
+ self._recursive = bool(recursiveSelection)
+ if self._recursive:
+ self._multi = True # recursive selection implies multi-selection
+ self._immediate = self.notify()
+ self._backend_widget = None
+ self._tree_widget = None
+ # mappings between QTreeWidgetItem and logical YTreeItem (python objects in self._items)
+ self._qitem_to_item = {}
+ self._item_to_qitem = {}
+ # guard to avoid recursion when programmatically changing selection
+ self._suppress_selection_handler = False
+ # remember last selected QTreeWidgetItem set to detect added/removed selections
+ self._last_selected_qitems = set()
+ # track logical selected item ids to preserve across rebuilds/swaps
+ self._last_selected_ids = set()
+ self._logger = logging.getLogger(f"manatools.aui.qt.{self.__class__.__name__}")
+
+ def widgetClass(self):
+ return "YTree"
+
+ def _create_backend_widget(self):
+ container = QtWidgets.QWidget()
+ layout = QtWidgets.QVBoxLayout(container)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(0)
+
+ if self._label:
+ lbl = QtWidgets.QLabel(self._label)
+ layout.addWidget(lbl)
+
+ tree = QtWidgets.QTreeWidget()
+ tree.setHeaderHidden(True)
+ mode = QtWidgets.QAbstractItemView.MultiSelection if self._multi else QtWidgets.QAbstractItemView.SingleSelection
+ tree.setSelectionMode(mode)
+ tree.itemSelectionChanged.connect(self._on_selection_changed)
+ tree.itemActivated.connect(self._on_item_activated)
+
+ layout.addWidget(tree)
+ self._backend_widget = container
+ self._tree_widget = tree
+ self._backend_widget.setEnabled(bool(self._enabled))
+ # populate if items already present
+ try:
+ self._rebuildTree()
+ except Exception:
+ self._logger.error("rebuildTree failed during _create_backend_widget", exc_info=True)
+
+ def rebuildTree(self):
+ """RebuildTree to maintain compatibility."""
+ self._logger.warning("rebuildTree is deprecated and should not be needed anymore")
+ self._rebuildTree()
+
+ def _rebuildTree(self):
+ """Rebuild the QTreeWidget from self._items (calls helper recursively)."""
+ self._logger.debug("rebuildTree: rebuilding tree with %d items", len(self._items) if self._items else 0)
+ self._suppress_selection_handler = True
+ if self._tree_widget is None:
+ # ensure backend exists
+ self._create_backend_widget()
+ # Ensure ancestors of any selected items are opened in the model so they will be expanded in the view
+ try:
+ self._logger.debug("rebuildTree: opening ancestors of selected items")
+
+ def _all_nodes(nodes):
+ out = []
+ for n in nodes:
+ out.append(n)
+ try:
+ chs = callable(getattr(n, "children", None)) and n.children() or getattr(n, "_children", []) or []
+ except Exception:
+ chs = getattr(n, "_children", []) or []
+ for c in chs:
+ out.extend(_all_nodes([c]))
+ return out
+ roots = list(getattr(self, "_items", []) or [])
+ for it in _all_nodes(roots):
+ try:
+ is_sel = it.selected()
+ if is_sel:
+ self._logger.debug("rebuildTree: opening ancestors of selected item %s", str(it.label()))
+ # open parent chain
+ parent = it.parentItem()
+ while parent:
+ parent.setOpen(True)
+ parent = parent.parentItem()
+ except Exception:
+ pass
+ except Exception:
+ pass
+ # clear existing
+ self._qitem_to_item.clear()
+ self._item_to_qitem.clear()
+ self._tree_widget.clear()
+ # collect selected candidates while building so we can apply single-selection rules
+ selected_candidates = []
+
+ def _add_recursive(parent_qitem, item):
+ # item expected to provide label() and possibly children() iterable
+ text = ""
+ try:
+ text = item.label()
+ except Exception:
+ try:
+ text = str(item)
+ except Exception:
+ text = ""
+ qitem = QtWidgets.QTreeWidgetItem([text])
+ # preserve mapping
+ self._qitem_to_item[qitem] = item
+ self._item_to_qitem[item] = qitem
+ # set icon for this item if provided
+ try:
+ icon_name = None
+ fn = getattr(item, 'iconName', None)
+ if callable(fn):
+ icon_name = fn()
+ else:
+ icon_name = fn
+ if icon_name:
+ ico = _resolve_icon(icon_name)
+ if ico is not None:
+ try:
+ #self._logger.debug("Column count for item %d", qitem.columnCount())
+ qitem.setIcon(0, ico)
+ except Exception:
+ pass
+ except Exception:
+ self._logger.error("Error setting icon for tree item %s", text, exc_info=True)
+ pass
+ # attach to parent or top-level
+ if parent_qitem is None:
+ self._tree_widget.addTopLevelItem(qitem)
+ else:
+ parent_qitem.addChild(qitem)
+
+ # set expanded state according to the logical item's _is_open flag
+ try:
+ is_open = bool(getattr(item, "_is_open", False))
+ # setExpanded ensures the node shows as expanded/collapsed
+ qitem.setExpanded(is_open)
+ except Exception:
+ pass
+
+ # remember selection candidates
+ try:
+ if getattr(item, 'selected', None) and callable(getattr(item, 'selected')):
+ if item.selected():
+ selected_candidates.append((qitem, item))
+ except Exception:
+ pass
+
+ # recurse on children if available
+ try:
+ children = getattr(item, "children", None)
+ if callable(children):
+ childs = children()
+ else:
+ childs = children or []
+ except Exception:
+ childs = []
+ # many YTreeItem implementations may expose _children or similar; try common patterns
+ if not childs:
+ try:
+ childs = getattr(item, "_children", []) or []
+ except Exception:
+ childs = []
+
+ for c in childs:
+ _add_recursive(qitem, c)
+
+ return qitem
+
+ for it in list(getattr(self, "_items", []) or []):
+ try:
+ _add_recursive(None, it)
+ except Exception:
+ pass
+ # Apply selection state strictly from items' selected() flags (YTreeItem wins)
+ try:
+ # build desired_ids by scanning items' selected flags only
+ roots = list(getattr(self, "_items", []) or [])
+ def _all_nodes(nodes):
+ out = []
+ for n in nodes:
+ out.append(n)
+ try:
+ chs = callable(getattr(n, "children", None)) and n.children() or getattr(n, "_children", []) or []
+ except Exception:
+ chs = getattr(n, "_children", []) or []
+ if chs:
+ out.extend(_all_nodes(chs))
+ return out
+ desired_ids = set()
+ for it in _all_nodes(roots):
+ try:
+ sel = False
+ if hasattr(it, 'selected') and callable(getattr(it, 'selected')):
+ sel = it.selected()
+ else:
+ sel = bool(getattr(it, '_selected', False))
+ if sel:
+ desired_ids.add(id(it))
+ except Exception:
+ pass
+
+ # map items to qitems for selection application
+ self._suppress_selection_handler = True
+ try:
+ self._tree_widget.clearSelection()
+ except Exception:
+ pass
+ self._selected_items = []
+ if desired_ids:
+ if self._multi:
+ for it, qit in list(self._item_to_qitem.items()):
+ try:
+ if id(it) in desired_ids:
+ qit.setSelected(True)
+ self._selected_items.append(it)
+ try:
+ it.setSelected(True)
+ except Exception:
+ pass
+ # expand parents in view
+ try:
+ pq = qit.parent()
+ while pq is not None:
+ pq.setExpanded(True)
+ pq = pq.parent()
+ except Exception:
+ pass
+ except Exception:
+ pass
+ else:
+ # choose one: prefer a visible item present in the view
+ chosen_it = None
+ chosen_q = None
+ for it, qit in list(self._item_to_qitem.items()):
+ if id(it) in desired_ids:
+ chosen_it = it
+ chosen_q = qit
+ break
+ if chosen_q is None:
+ # fallback to last candidate if any
+ if selected_candidates:
+ chosen_q, chosen_it = selected_candidates[-1]
+ if chosen_q is not None and chosen_it is not None:
+ try:
+ chosen_q.setSelected(True)
+ chosen_it.setSelected(True)
+ self._selected_items = [chosen_it]
+ # expand parents
+ try:
+ pq = chosen_q.parent()
+ while pq is not None:
+ pq.setExpanded(True)
+ pq = pq.parent()
+ except Exception:
+ pass
+ except Exception:
+ pass
+ # update preserved ids (not used to drive rebuild, kept for diagnostics)
+ try:
+ self._last_selected_ids = set(id(i) for i in self._selected_items)
+ except Exception:
+ self._last_selected_ids = set()
+ finally:
+ self._suppress_selection_handler = False
+
+ # do not call expandAll(); expansion is controlled per-item by _is_open
+
+ def currentItem(self):
+ """Return the logical YTreeItem corresponding to the current/focused QTreeWidgetItem."""
+ if not self._tree_widget:
+ return None
+ try:
+ qcur = self._tree_widget.currentItem()
+ if qcur is None:
+ # fallback to first selected item if current not set
+ sel = self._tree_widget.selectedItems()
+ qcur = sel[0] if sel else None
+ if qcur is None:
+ return None
+ return self._qitem_to_item.get(qcur, None)
+ except Exception:
+ return None
+
+ def activate(self):
+ """Simulate activation of the current item (post Activated event)."""
+ item = self.currentItem()
+ if item is None:
+ return False
+ try:
+ dlg = self.findDialog()
+ if dlg is not None and self.notify():
+ dlg._post_event(YWidgetEvent(self, YEventReason.Activated))
+ return True
+ except Exception:
+ return False
+
+ def hasMultiSelection(self):
+ """Return True if the tree allows selecting multiple items at once."""
+ return bool(self._multi)
+
+ def immediateMode(self):
+ return bool(self._immediate)
+
+ def setImmediateMode(self, on:bool=True):
+ self._immediate = on
+ self.setNotify(on)
+
+ def _collect_descendant_qitems(self, qitem):
+ """Return a list of qitem and all descendant QTreeWidgetItem objects."""
+ out = []
+ if qitem is None:
+ return out
+ stack = [qitem]
+ while stack:
+ cur = stack.pop()
+ out.append(cur)
+ try:
+ for i in range(cur.childCount()):
+ stack.append(cur.child(i))
+ except Exception:
+ pass
+ return out
+
+ # selection change handler
+ def _on_selection_changed(self):
+ """Update logical selection list and emit selection-changed event when needed."""
+ # Defensive guard: when we change selection programmatically we don't want to re-enter here.
+ if self._suppress_selection_handler:
+ return
+
+ self._logger.debug("_on_selection_changed: handling selection change")
+
+ try:
+ if not self._tree_widget:
+ return
+
+ sel_qitems = list(self._tree_widget.selectedItems())
+ self._logger.debug("_on_selection_changed: %d items selected", len(sel_qitems))
+ current_set = set(sel_qitems)
+
+ # If recursive selection is enabled and multi-selection is allowed,
+ # adjust selection so that selecting a parent selects all descendants
+ # and deselecting a parent deselects all descendants.
+ if self._recursive and self._multi:
+ added = current_set - self._last_selected_qitems
+ removed = self._last_selected_qitems - current_set
+
+ # start desired_set from current_set
+ desired_set = set(current_set)
+
+ # For every newly added item, ensure its descendants are selected
+ for q in list(added):
+ for dq in self._collect_descendant_qitems(q):
+ desired_set.add(dq)
+
+ # For every removed item, ensure its descendants are deselected
+ for q in list(removed):
+ for dq in self._collect_descendant_qitems(q):
+ if dq in desired_set:
+ desired_set.discard(dq)
+
+ # If desired_set differs from what's currently selected in the widget,
+ # apply the change programmatically.
+ if desired_set != current_set:
+ try:
+ self._suppress_selection_handler = True
+ self._tree_widget.clearSelection()
+ for q in desired_set:
+ try:
+ q.setSelected(True)
+ except Exception:
+ pass
+ finally:
+ self._suppress_selection_handler = False
+ # refresh sel_qitems and current_set after modification
+ sel_qitems = list(self._tree_widget.selectedItems())
+ current_set = set(sel_qitems)
+
+ # Build logical_qitems: if recursive + single select, include descendants in logical list;
+ # otherwise logical_qitems mirrors current UI selection.
+ logical_qitems = []
+ if self._recursive and not self._multi:
+ for q in sel_qitems:
+ logical_qitems.append(q)
+ for dq in self._collect_descendant_qitems(q):
+ if dq is not q:
+ logical_qitems.append(dq)
+ else:
+ logical_qitems = sel_qitems
+
+ # Map qitems -> logical YTreeItem objects
+ new_selected = []
+ for qitem in logical_qitems:
+ itm = self._qitem_to_item.get(qitem, None)
+ if itm is not None:
+ new_selected.append(itm)
+
+ # Update selected flags across the entire tree (clear all, then set for new_selected)
+ try:
+ roots = list(getattr(self, "_items", []) or [])
+ def _all_nodes(nodes):
+ out = []
+ for n in nodes:
+ out.append(n)
+ try:
+ chs = callable(getattr(n, "children", None)) and n.children() or getattr(n, "_children", []) or []
+ except Exception:
+ chs = getattr(n, "_children", []) or []
+ if chs:
+ out.extend(_all_nodes(chs))
+ return out
+ for it in _all_nodes(roots):
+ try:
+ it.setSelected(False)
+ except Exception:
+ pass
+ for it in new_selected:
+ try:
+ it.setSelected(True)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ self._selected_items = new_selected
+ self._logger.debug("_on_selection_changed: %d new selected", len(new_selected))
+ # remember last selected QTreeWidgetItem set for next invocation
+ try:
+ self._last_selected_qitems = set(self._tree_widget.selectedItems())
+ except Exception:
+ self._last_selected_qitems = set()
+
+ # preserve logical selected ids for diagnostics
+ try:
+ self._last_selected_ids = set(id(i) for i in self._selected_items)
+ except Exception:
+ self._last_selected_ids = set()
+
+ # immediate mode: notify container/dialog
+ try:
+ if self._immediate and self.notify():
+ dlg = self.findDialog()
+ if dlg is not None:
+ dlg._post_event(YWidgetEvent(self, YEventReason.SelectionChanged))
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ # item activated (double click / Enter)
+ def _on_item_activated(self, qitem, column):
+ self._logger.debug("_on_item_activated: item activated")
+ try:
+ # map to logical item
+ item = self._qitem_to_item.get(qitem, None)
+ if item is None:
+ return
+ # post activated event
+ dlg = self.findDialog()
+ if dlg is not None and self.notify():
+ dlg._post_event(YWidgetEvent(self, YEventReason.Activated))
+ except Exception:
+ pass
+
+ def addItem(self, item):
+ '''Add a YItem redefinition from YSelectionWidget to manage YTreeItems.'''
+ if isinstance(item, str):
+ item = YTreeItem(item)
+ super().addItem(item)
+ elif isinstance(item, YTreeItem):
+ super().addItem(item)
+ else:
+ self._logger.error("YTree.addItem: invalid item type %s", type(item))
+ raise TypeError("YTree.addItem expects a YTreeItem or string label")
+ # ensure index set
+ try:
+ item.setIndex(len(self._items) - 1)
+ except Exception:
+ pass
+ # if backend exists, refresh tree to reflect new item (including icon/selection)
+ try:
+ if getattr(self, '_tree_widget', None) is not None:
+ self._rebuildTree()
+ except Exception:
+ pass
+
+ def addItems(self, items):
+ '''Add multiple items to the table. This is more efficient than calling addItem repeatedly.'''
+ for item in items:
+ if isinstance(item, str):
+ item = YTreeItem(item)
+ super().addItem(item)
+ elif isinstance(item, YTreeItem):
+ super().addItem(item)
+ else:
+ self._logger.error("YTree.addItem: invalid item type %s", type(item))
+ raise TypeError("YTree.addItem expects a YTreeItem or string label")
+ # ensure index set
+ item.setIndex(len(self._items) - 1)
+ # if backend exists, refresh tree to reflect new item (including icon/selection)
+ try:
+ if getattr(self, '_tree_widget', None) is not None:
+ self._rebuildTree()
+ except Exception:
+ pass
+
+ # property API hooks (minimal implementation)
+ def setProperty(self, propertyName, val):
+ try:
+ if propertyName == "immediateMode":
+ self.setImmediateMode(bool(val))
+ return True
+ except Exception:
+ pass
+ return False
+
+ def getProperty(self, propertyName):
+ try:
+ if propertyName == "immediateMode":
+ return self.immediateMode()
+ except Exception:
+ pass
+ return None
+
+ def _set_backend_enabled(self, enabled):
+ """Enable/disable the tree widget and propagate to logical items/widgets."""
+ try:
+ if getattr(self, "_backend_widget", None) is not None:
+ try:
+ self._backend_widget.setEnabled(bool(enabled))
+ except Exception:
+ pass
+ except Exception:
+ pass
+ # logical propagation to child YWidgets (if any)
+ try:
+ for c in list(getattr(self, "_items", []) or []):
+ try:
+ if hasattr(c, "setEnabled"):
+ c.setEnabled(enabled)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def selectItem(self, item, selected=True):
+ """Select or deselect the given logical YTreeItem and reflect in the view."""
+ try:
+ # update model flag
+ try:
+ item.setSelected(bool(selected))
+ except Exception:
+ pass
+ # if no tree widget, only model is updated
+ if getattr(self, '_tree_widget', None) is None:
+ # maintain internal selected list
+ if selected:
+ if item not in self._selected_items:
+ if not self._multi:
+ self._selected_items = [item]
+ else:
+ self._selected_items.append(item)
+ else:
+ try:
+ if item in self._selected_items:
+ self._selected_items.remove(item)
+ except Exception:
+ pass
+ return
+
+ qit = self._item_to_qitem.get(item, None)
+ if qit is None:
+ # item may be newly added — rebuild tree and retry
+ try:
+ self._rebuildTree()
+ qit = self._item_to_qitem.get(item, None)
+ except Exception:
+ qit = None
+
+ if qit is None:
+ return
+
+ # apply selection in view
+ try:
+ # expand parents in model and view to ensure visibility
+ try:
+ # open parent chain in view
+ pq = qit.parent()
+ while pq is not None:
+ pq.setExpanded(True)
+ pq = pq.parent()
+ except Exception:
+ pass
+ try:
+ # also setOpen on logical parents
+ p = item.parentItem() if hasattr(item, 'parentItem') and callable(getattr(item, 'parentItem')) else getattr(item, '_parent_item', None)
+ while p is not None:
+ try:
+ p.setOpen(True)
+ except Exception:
+ try:
+ setattr(p, '_is_open', True)
+ except Exception:
+ pass
+ p = p.parentItem() if hasattr(p, 'parentItem') and callable(getattr(p, 'parentItem')) else getattr(p, '_parent_item', None)
+ except Exception:
+ pass
+ self._suppress_selection_handler = True
+ if not self._multi and selected:
+ try:
+ self._tree_widget.clearSelection()
+ except Exception:
+ pass
+ # if recursive selection is enabled, select/deselect descendants too
+ targets = [qit]
+ if selected and self._recursive:
+ targets = self._collect_descendant_qitems(qit)
+ for tq in targets:
+ try:
+ tq.setSelected(bool(selected))
+ except Exception:
+ pass
+ # done applying; release suppression
+ self._suppress_selection_handler = False
+ except Exception:
+ self._suppress_selection_handler = False
+ pass
+
+ # update internal selected items list
+ try:
+ new_selected = []
+ for q in self._tree_widget.selectedItems():
+ itm = self._qitem_to_item.get(q, None)
+ if itm is not None:
+ new_selected.append(itm)
+ # if not multi, keep only last
+ if not self._multi and len(new_selected) > 1:
+ new_selected = [new_selected[-1]]
+ self._selected_items = new_selected
+ # preserve ids for future rebuilds/swaps
+ try:
+ self._last_selected_ids = set(id(i) for i in self._selected_items)
+ except Exception:
+ self._last_selected_ids = set()
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def deleteAllItems(self):
+ """Remove all items from model and QTreeWidget view."""
+ self._suppress_selection_handler = True
+ try:
+ super().deleteAllItems()
+ except Exception:
+ self._items = []
+ self._selected_items = []
+ try:
+ self._last_selected_ids = set()
+ except Exception:
+ pass
+ try:
+ self._qitem_to_item.clear()
+ except Exception:
+ pass
+ try:
+ self._item_to_qitem.clear()
+ except Exception:
+ pass
+ try:
+ if getattr(self, '_tree_widget', None) is not None:
+ try:
+ self._tree_widget.clear()
+ except Exception:
+ pass
+ except Exception:
+ pass
+ self._suppress_selection_handler = False
diff --git a/manatools/aui/backends/qt/vboxqt.py b/manatools/aui/backends/qt/vboxqt.py
new file mode 100644
index 0000000..35e8ded
--- /dev/null
+++ b/manatools/aui/backends/qt/vboxqt.py
@@ -0,0 +1,169 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+'''
+Python manatools.aui.backends.qt contains all Qt backend classes
+
+License: LGPLv2+
+
+Author: Angelo Naselli
+
+@package manatools.aui.backends.qt
+'''
+from PySide6 import QtWidgets, QtCore
+import logging
+from ...yui_common import *
+
+class YVBoxQt(YWidget):
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self._logger = logging.getLogger(f"manatools.aui.qt.{self.__class__.__name__}")
+
+ def widgetClass(self):
+ return "YVBox"
+
+ # Returns the stretchability of the layout box:
+ # * The layout box is stretchable if one of the children is stretchable in
+ # * this dimension or if one of the child widgets has a layout weight in
+ # * this dimension.
+ def stretchable(self, dim):
+ for child in self._children:
+ widget = child.get_backend_widget()
+ expand = bool(child.stretchable(dim))
+ weight = bool(child.weight(dim))
+ if expand or weight:
+ return True
+ # No child is stretchable in this dimension
+ return False
+
+ def _create_backend_widget(self):
+ self._backend_widget = QtWidgets.QWidget()
+ layout = QtWidgets.QVBoxLayout(self._backend_widget)
+ layout.setContentsMargins(1, 1, 1, 1)
+ # Keep the layout constrained to its minimum sizeHint so children are not
+ # compressed to invisible sizes by parent layouts. This matches HBox/Frame behavior.
+ layout.setSizeConstraint(QtWidgets.QLayout.SetMinimumSize)
+ #layout.setSpacing(1)
+
+ # Map YWidget weights and stretchable flags to Qt layout stretch factors.
+ # Weight semantics: if any child has a positive weight (>0) use those
+ # weights as stretch factors; otherwise give equal stretch (1) to
+ # children that are marked stretchable. Non-stretchable children get 0.
+ try:
+ child_weights = [int(child.weight(YUIDimension.YD_VERT) or 0) for child in self._children]
+ except Exception:
+ child_weights = [0 for _ in self._children]
+ has_positive = any(w > 0 for w in child_weights)
+
+ for idx, child in enumerate(self._children):
+ widget = child.get_backend_widget()
+ weight = int(child_weights[idx]) if idx < len(child_weights) else 0
+ if has_positive:
+ stretch = weight
+ else:
+ stretch = 1 if child.stretchable(YUIDimension.YD_VERT) else 0
+
+ # If the child will receive extra space, set its QSizePolicy to Expanding
+ try:
+ if stretch > 0:
+ sp = widget.sizePolicy()
+ try:
+ sp.setVerticalPolicy(QtWidgets.QSizePolicy.Policy.Expanding)
+ except Exception:
+ try:
+ sp.setVerticalPolicy(QtWidgets.QSizePolicy.Expanding)
+ except Exception:
+ pass
+ widget.setSizePolicy(sp)
+ except Exception:
+ pass
+
+ self._backend_widget.setEnabled(bool(self._enabled))
+ try:
+ self._logger.debug("YVBoxQt: adding child %s stretch=%s weight=%s", child.widgetClass(), stretch, weight)
+ except Exception:
+ pass
+ layout.addWidget(widget, stretch=stretch)
+ try:
+ self._logger.debug("_create_backend_widget: <%s>", self.debugLabel())
+ except Exception:
+ pass
+
+ def _set_backend_enabled(self, enabled):
+ """Enable/disable the VBox container and propagate to children."""
+ try:
+ if getattr(self, "_backend_widget", None) is not None:
+ try:
+ self._backend_widget.setEnabled(bool(enabled))
+ except Exception:
+ pass
+ except Exception:
+ pass
+ try:
+ for c in list(getattr(self, "_children", []) or []):
+ try:
+ c.setEnabled(enabled)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def addChild(self, child):
+ """Attach child's backend widget to this VBox when added at runtime."""
+ super().addChild(child)
+ try:
+ if getattr(self, "_backend_widget", None) is None:
+ return
+
+ def _deferred_attach():
+ try:
+ widget = child.get_backend_widget()
+ try:
+ weight = int(child.weight(YUIDimension.YD_VERT) or 0)
+ except Exception:
+ weight = 0
+ # If dynamic addition, use explicit weight when >0, otherwise
+ # fall back to stretchable flag (equal-share represented by 1).
+ stretch = weight if weight > 0 else (1 if child.stretchable(YUIDimension.YD_VERT) else 0)
+ try:
+ if stretch > 0:
+ sp = widget.sizePolicy()
+ try:
+ sp.setVerticalPolicy(QtWidgets.QSizePolicy.Policy.Expanding)
+ except Exception:
+ try:
+ sp.setVerticalPolicy(QtWidgets.QSizePolicy.Expanding)
+ except Exception:
+ pass
+ widget.setSizePolicy(sp)
+ except Exception:
+ pass
+ try:
+ lay = self._backend_widget.layout()
+ if lay is None:
+ lay = QtWidgets.QVBoxLayout(self._backend_widget)
+ self._backend_widget.setLayout(lay)
+ lay.addWidget(widget, stretch=stretch)
+ try:
+ widget.show()
+ except Exception:
+ pass
+ try:
+ widget.updateGeometry()
+ except Exception:
+ pass
+ except Exception:
+ try:
+ self._logger.exception("YVBoxQt.addChild: failed to attach child")
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ # Defer attach to next event loop iteration so child's __init__ can complete
+ try:
+ QtCore.QTimer.singleShot(0, _deferred_attach)
+ except Exception:
+ # fallback: attach immediately
+ _deferred_attach()
+ except Exception:
+ pass
diff --git a/manatools/aui/yui.py b/manatools/aui/yui.py
new file mode 100644
index 0000000..1c7cae7
--- /dev/null
+++ b/manatools/aui/yui.py
@@ -0,0 +1,154 @@
+#!/usr/bin/env python3
+"""
+Unified YUI implementation that automatically selects the best available backend.
+Priority: Qt > GTK > NCurses
+"""
+
+import os
+import sys
+from enum import Enum
+
+class Backend(Enum):
+ QT = "qt"
+ GTK = "gtk"
+ NCURSES = "ncurses"
+
+class YUI:
+ _instance = None
+ _backend = None
+
+ @classmethod
+ def _detect_backend(cls):
+ """Detect the best available backend"""
+ # Check environment variable first
+ backend_env = os.environ.get('YUI_BACKEND', '').lower()
+ if backend_env == 'qt':
+ return Backend.QT
+ elif backend_env == 'gtk':
+ return Backend.GTK
+ elif backend_env == 'ncurses':
+ return Backend.NCURSES
+
+ # Auto-detect based on available imports
+ # Require PySide6 (Qt6)
+ try:
+ import PySide6.QtWidgets
+ return Backend.QT
+ except ImportError:
+ pass
+
+ # GTK: require GTK4
+ try:
+ import gi
+ gi.require_version('Gtk', '4.0')
+ from gi.repository import Gtk
+ return Backend.GTK
+ except (ImportError, ValueError):
+ pass
+
+ try:
+ import curses
+ return Backend.NCURSES
+ except ImportError:
+ pass
+
+ raise RuntimeError("No UI backend available. Install PySide6, PyGObject (GTK4), or curses.")
+
+ @classmethod
+ def ui(cls):
+ if cls._instance is None:
+ cls._backend = cls._detect_backend()
+ print(f"Detected backend: {cls._backend}")
+
+ if cls._backend == Backend.QT:
+ from .yui_qt import YUIQt as YUIImpl
+ elif cls._backend == Backend.GTK:
+ from .yui_gtk import YUIGtk as YUIImpl
+ elif cls._backend == Backend.NCURSES:
+ from .yui_curses import YUICurses as YUIImpl
+ else:
+ raise RuntimeError(f"Unknown backend: {cls._backend}")
+
+ cls._instance = YUIImpl()
+
+ return cls._instance
+
+ @classmethod
+ def backend(cls):
+ if cls._instance is None:
+ cls.ui() # This will detect the backend
+ return cls._backend
+
+ @classmethod
+ def widgetFactory(cls):
+ return cls.ui().widgetFactory()
+
+ @classmethod
+ def optionalWidgetFactory(cls):
+ return cls.ui().optionalWidgetFactory()
+
+ @classmethod
+ def app(cls):
+ return cls.ui().app()
+
+ @classmethod
+ def application(cls):
+ return cls.ui().application()
+
+ @classmethod
+ def yApp(cls):
+ return cls.ui().yApp()
+
+# Global functions for compatibility with libyui API
+def YUI_ui():
+ return YUI.ui()
+
+def YUI_widgetFactory():
+ return YUI.widgetFactory()
+
+def YUI_optionalWidgetFactory():
+ return YUI.optionalWidgetFactory()
+
+def YUI_app():
+ return YUI.app()
+
+def YUI_application():
+ return YUI.application()
+
+def YUI_yApp():
+ return YUI.yApp()
+
+def YUI_ensureUICreated():
+ return YUI.ui()
+
+# Import common classes that are backend-agnostic
+from .yui_common import (
+ # Enums
+ YUIDimension, YAlignmentType, YDialogType, YDialogColorMode,
+ YEventType, YEventReason, YCheckBoxState, YButtonRole,
+ # Base classes
+ YWidget, YSingleChildContainerWidget, YSelectionWidget,
+ YSimpleInputField, YItem, YTreeItem, YTableHeader, YTableItem, YTableCell,
+ # Events
+ YEvent, YWidgetEvent, YKeyEvent, YMenuEvent, YTimeoutEvent, YCancelEvent,
+ # Exceptions
+ YUIException, YUIWidgetNotFoundException, YUINoDialogException, YUIInvalidWidgetException,
+ # Menu model
+ YMenuItem,
+ # Property system
+ YPropertyType, YProperty, YPropertyValue, YPropertySet, YShortcut
+)
+
+# Re-export everything for easy importing
+__all__ = [
+ 'YUI', 'YUI_ui', 'YUI_widgetFactory', 'YUI_app', 'YUI_application', 'YUI_yApp',
+ 'YUIDimension', 'YAlignmentType', 'YDialogType', 'YDialogColorMode',
+ 'YEventType', 'YEventReason', 'YCheckBoxState', 'YButtonRole',
+ 'YWidget', 'YSingleChildContainerWidget', 'YSelectionWidget',
+ 'YSimpleInputField', 'YItem', 'YTreeItem', 'YTableHeader', 'YTableItem', 'YTableCell',
+ 'YEvent', 'YWidgetEvent', 'YKeyEvent', 'YMenuEvent', 'YTimeoutEvent', 'YCancelEvent',
+ 'YUIException', 'YUIWidgetNotFoundException', 'YUINoDialogException', 'YUIInvalidWidgetException',
+ 'YMenuItem',
+ 'YPropertyType', 'YProperty', 'YPropertyValue', 'YPropertySet', 'YShortcut',
+ 'Backend'
+]
\ No newline at end of file
diff --git a/manatools/aui/yui_common.py b/manatools/aui/yui_common.py
new file mode 100644
index 0000000..e65067e
--- /dev/null
+++ b/manatools/aui/yui_common.py
@@ -0,0 +1,830 @@
+"""
+Common base classes and definitions shared across all backends
+"""
+
+from enum import Enum
+import uuid
+from typing import Optional
+
+# Enums
+# Backwards-compatible aliases: allow using `YUIDimension.Horizontal` / `Vertical`
+# in code that expects more readable names.
+class YUIDimension(Enum):
+ YD_HORIZ = 0
+ Horizontal = 0
+ YD_VERT = 1
+ Vertical = 1
+
+class YAlignmentType(Enum):
+ YAlignUnchanged = 0
+ YAlignBegin = 1
+ YAlignEnd = 2
+ YAlignCenter = 3
+
+class YDialogType(Enum):
+ YMainDialog = 0
+ YPopupDialog = 1
+ YWizardDialog = 2
+
+class YDialogColorMode(Enum):
+ YDialogNormalColor = 0
+ YDialogInfoColor = 1
+ YDialogWarnColor = 2
+
+class YEventType(Enum):
+ NoEvent = 0
+ WidgetEvent = 1
+ MenuEvent = 2
+ KeyEvent = 3
+ CancelEvent = 4
+ TimeoutEvent = 5
+
+class YEventReason(Enum):
+ Activated = 0
+ ValueChanged = 1
+ SelectionChanged = 2
+
+class YCheckBoxState(Enum):
+ YCheckBox_dont_care = -1
+ YCheckBox_off = 0
+ YCheckBox_on = 1
+
+class YButtonRole(Enum):
+ YCustomButton = 0
+ YOKButton = 1
+ YCancelButton = 2
+ YHelpButton = 3
+
+# Exceptions
+class YUIException(Exception):
+ pass
+
+class YUIWidgetNotFoundException(YUIException):
+ pass
+
+class YUINoDialogException(YUIException):
+ pass
+
+class YUIInvalidWidgetException(YUIException):
+ pass
+
+# Events
+class YEvent:
+ def __init__(self, event_type=YEventType.NoEvent, widget=None, reason=None):
+ self._event_type = event_type
+ self._widget = widget
+ self._reason = reason
+ self._serial = 0
+
+ def eventType(self):
+ return self._event_type
+
+ def widget(self):
+ return self._widget
+
+ def reason(self):
+ return self._reason
+
+ def serial(self):
+ return self._serial
+
+class YWidgetEvent(YEvent):
+ """Event generated by widgets"""
+ def __init__(self, widget=None, reason=YEventReason.Activated, event_type=YEventType.WidgetEvent):
+ super().__init__(event_type, widget, reason)
+
+class YKeyEvent(YEvent):
+ def __init__(self, key_symbol, focus_widget=None):
+ super().__init__(YEventType.KeyEvent, focus_widget)
+ self._key_symbol = key_symbol
+
+ def keySymbol(self):
+ return self._key_symbol
+
+ def focusWidget(self):
+ return self.widget()
+
+class YMenuEvent(YEvent):
+ def __init__(self, item=None, id=None):
+ super().__init__(YEventType.MenuEvent)
+ self._item = item
+ self._id = id
+
+ def item(self):
+ return self._item
+
+ def id(self):
+ return self._id
+
+class YTimeoutEvent(YEvent):
+ """Event generated on timeout"""
+ def __init__(self):
+ super().__init__(YEventType.TimeoutEvent)
+
+class YCancelEvent(YEvent):
+ def __init__(self):
+ super().__init__(YEventType.CancelEvent)
+
+# Base Widget Class
+class YWidget:
+ _widget_counter = 0
+
+ def __init__(self, parent=None):
+ YWidget._widget_counter += 1
+ self._id = f"widget_{YWidget._widget_counter}"
+ self._parent = parent
+ self._children = []
+ self._enabled = True
+ self._visible = True
+ self._help_text = ""
+ self._backend_widget = None
+ self._stretchable_horiz = False
+ self._stretchable_vert = False
+ self._weight_horiz = 0
+ self._weight_vert = 0
+ # NOTE: Notify property should be False for back compatibility,
+ # but backends has been implemented as True
+ self._notify = True
+ self._auto_shortcut = False
+ self._function_key = 0
+
+ if parent and hasattr(parent, 'addChild'):
+ parent.addChild(self)
+
+ def widgetClass(self):
+ return self.__class__.__name__
+
+ def widgetPathName(self):
+ ''' Return the full path name of this widget in the hierarchy. '''
+ return f"{self._parent.widgetPathName()}/{self.widgetClass()}({self._id})" if self._parent else f"/{self.widgetClass()}({self._id})"
+
+ def id(self):
+ ''' Return the unique identifier of this widget. '''
+ return self._id
+
+ def debugLabel(self):
+ return f"{self.widgetClass()}({self._id})"
+
+ def helpText(self):
+ ''' Return the help text (tooltip) for this widget. '''
+ return self._help_text
+
+ def setHelpText(self, help_text):
+ ''' Set the help text (tooltip) for this widget. '''
+ self._help_text = help_text
+
+ def hasChildren(self):
+ return len(self._children) > 0
+
+ def firstChild(self):
+ return self._children[0] if self._children else None
+
+ def lastChild(self):
+ return self._children[-1] if self._children else None
+
+ def childrenBegin(self):
+ return iter(self._children)
+
+ def childrenEnd(self):
+ return iter([])
+
+ def childrenCount(self):
+ return len(self._children)
+
+ def addChild(self, child):
+ if child not in self._children:
+ self._children.append(child)
+ child._parent = self
+ if self.isEnabled() is False:
+ child._enabled = False
+
+ def removeChild(self, child):
+ if child in self._children:
+ self._children.remove(child)
+ child._parent = None
+
+ def deleteChildren(self):
+ """
+ Remove all logical children from this widget.
+
+ This clears the internal children list and detaches each child's
+ parent reference. Backend containers that render children should
+ override this method to also clear any backend layout or content
+ area. For generic widgets, this only affects the logical model.
+ """
+ try:
+ for ch in list(self._children):
+ try:
+ ch._parent = None
+ except Exception:
+ pass
+ self._children.clear()
+ except Exception:
+ # Best-effort: ignore failures and keep model consistent
+ try:
+ self._children = []
+ except Exception:
+ pass
+
+ def parent(self):
+ return self._parent
+
+ def hasParent(self):
+ return self._parent is not None
+
+ def findDialog(self):
+ """Find the parent dialog of this widget."""
+ parent = getattr(self, '_parent', None)
+ while parent is not None and parent.widgetClass() != 'YDialog':
+ parent = getattr(parent, '_parent', None)
+ return parent
+
+ def setEnabled(self, enabled=True):
+ '''
+ Enable or disable the widget. i.e. make it accept or reject user input.
+ Derived backend classes must implement _set_backend_enabled to apply
+ the change to the actual backend widget.
+ '''
+ self._enabled = enabled
+ self._set_backend_enabled(enabled)
+
+ def setDisabled(self):
+ ''' Disenable the widget. '''
+ self.setEnabled(False)
+
+ def isEnabled(self):
+ ''' Return whether the widget is enabled. '''
+ return self._enabled
+
+ def visible(self) -> bool:
+ ''' Retunr the visibility of the widget. '''
+ return bool(self._visible)
+
+ def setVisible(self, visible:bool=True):
+ ''' Set the visibility of the widget. Backend-specific implementation required. '''
+ self._visible = bool(visible)
+
+ def stretchable(self, dim):
+ if dim == YUIDimension.YD_HORIZ:
+ return self._stretchable_horiz
+ else:
+ return self._stretchable_vert
+
+ def setStretchable(self, dim, new_stretch: bool):
+ if dim == YUIDimension.YD_HORIZ:
+ self._stretchable_horiz = new_stretch
+ else:
+ self._stretchable_vert = new_stretch
+
+ def weight(self, dim):
+ """
+ The weight is used when all widgets can get their preferred size and yet space is available.
+ The remaining space will be devided between all stretchable widgets according to their weights.
+ A widget with greater weight will get more space. The default weight for all widgets is 0.
+ The weight range is 0-99.
+ """
+ if dim == YUIDimension.YD_HORIZ:
+ return self._weight_horiz
+ else:
+ return self._weight_vert
+
+ def setWeight(self, dim, weight: int):
+ w = weight % 100 if weight >= 0 else 0
+
+ if dim == YUIDimension.YD_HORIZ:
+ self._weight_horiz = w
+ else:
+ self._weight_vert = w
+
+ def setNotify(self, notify=True):
+ self._notify = notify
+
+ def notify(self):
+ return self._notify
+
+ def autoShortcut(self):
+ return self._auto_shortcut
+
+ def setAutoShortcut(self, auto_shortcut):
+ self._auto_shortcut = auto_shortcut
+
+ def functionKey(self):
+ return self._function_key
+
+ def setFunctionKey(self, fkey_no):
+ self._function_key = fkey_no
+
+ # Backend-specific methods to be implemented by concrete classes
+ def _set_backend_enabled(self, enabled):
+ pass
+
+ def _create_backend_widget(self):
+ pass
+
+ def get_backend_widget(self):
+ if self._backend_widget is None:
+ self._create_backend_widget()
+ return self._backend_widget
+
+class YSingleChildContainerWidget(YWidget):
+ def __init__(self, parent=None):
+ super().__init__(parent)
+
+ def child(self):
+ '''
+ Returns the only child of this single-child container, or None if no child is set.
+ '''
+ return self.firstChild()
+
+ def addChild(self, child):
+ if self.hasChildren():
+ raise YUIInvalidWidgetException("YSingleChildContainerWidget can only have one child")
+ super().addChild(child)
+
+class YSelectionWidget(YWidget):
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self._items = []
+ self._selected_items = []
+ self._label = ""
+ self._icon_base_path = ""
+
+ def label(self):
+ return self._label
+
+ def setLabel(self, new_label):
+ self._label = new_label
+
+ def addItem(self, item):
+ if isinstance(item, str):
+ item = YItem(item)
+ self._items.append(item)
+
+ def addItems(self, items):
+ """Add multiple items to the selection widget."""
+ for it in items:
+ self.addItem(it)
+
+ def deleteAllItems(self):
+ self._items.clear()
+ self._selected_items.clear()
+
+ def itemsBegin(self):
+ return iter(self._items)
+
+ def itemsEnd(self):
+ return iter([])
+
+ def hasItems(self):
+ return len(self._items) > 0
+
+ def itemsCount(self):
+ return len(self._items)
+
+ def selectedItem(self):
+ return self._selected_items[0] if self._selected_items else None
+
+ def selectedItems(self):
+ return self._selected_items
+
+ def hasSelectedItem(self):
+ return len(self._selected_items) > 0
+
+ def selectItem(self, item, selected=True):
+ if selected and item not in self._selected_items:
+ self._selected_items.append(item)
+ elif not selected and item in self._selected_items:
+ self._selected_items.remove(item)
+
+class YSimpleInputField(YWidget):
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self._value = ""
+ self._label = ""
+
+ def value(self):
+ return self._value
+
+ def setValue(self, text):
+ self._value = text
+
+ def label(self):
+ return self._label
+
+ def setLabel(self, label):
+ self._label = label
+
+class YItem:
+ def __init__(self, label, selected=False, icon_name=""):
+ self._label = label
+ self._selected = selected
+ self._icon_name = icon_name
+ self._index = 0
+ self._data = None
+
+ def label(self):
+ return self._label
+
+ def setLabel(self, new_label):
+ self._label = new_label
+
+ def selected(self):
+ return self._selected
+
+ def setSelected(self, selected=True):
+ self._selected = selected
+
+ def iconName(self):
+ return self._icon_name
+
+ def hasIconName(self):
+ return bool(self._icon_name)
+
+ def setIconName(self, new_icon_name):
+ self._icon_name = new_icon_name
+
+ def index(self):
+ return self._index
+
+ def setIndex(self, index):
+ self._index = index
+
+ def data(self):
+ return self._data
+
+ def setData(self, new_data):
+ self._data = new_data
+
+class YMenuItem:
+ """Lightweight menu item model for backend-agnostic use.
+
+ Supports label, icon name, enabled state and hierarchical children
+ (submenus and items). Backends may attach platform-specific references
+ via `_backend_ref` for synchronization.
+ """
+ def __init__(self, label: str, icon_name: str = "", enabled: bool = True, is_menu: bool = False, is_separator: bool = False):
+ self._is_separator = bool(is_separator)
+ self._label = "-" if self._is_separator else str(label)
+ self._icon_name = str(icon_name) if icon_name else ""
+ self._enabled = False if self._is_separator else bool(enabled)
+ self._is_menu = False if self._is_separator else bool(is_menu)
+ # visible flag (backends should respect this when rendering)
+ self._visible = True
+ self._children = [] # list of YMenuItem
+ self._parent = None
+ self._backend_ref = None # optional backend-specific handle
+
+ def label(self) -> str:
+ return self._label
+
+ def setLabel(self, new_label: str):
+ self._label = str(new_label)
+
+ def iconName(self) -> str:
+ return self._icon_name
+
+ def setIconName(self, new_icon_name: str):
+ self._icon_name = str(new_icon_name) if new_icon_name else ""
+
+ def enabled(self) -> bool:
+ return bool(self._enabled)
+
+ def setEnabled(self, on: bool = True):
+ self._enabled = bool(on)
+
+ def visible(self) -> bool:
+ return bool(self._visible)
+
+ def setVisible(self, on: bool = True):
+ self._visible = bool(on)
+ for child in self._children:
+ child.setVisible(on)
+
+ def isMenu(self) -> bool:
+ return bool(self._is_menu)
+
+ def isSeparator(self) -> bool:
+ return bool(self._is_separator)
+
+ def parentItem(self):
+ return self._parent
+
+ def hasChildren(self):
+ return len(self._children) > 0
+
+ def childrenBegin(self):
+ return iter(self._children)
+
+ def childrenEnd(self):
+ return iter([])
+
+ def hasChildren(self) -> bool:
+ return len(self._children) > 0
+
+ def addItem(self, label: str, icon_name: str = ""):
+ child = YMenuItem(label, icon_name, enabled=True, is_menu=False)
+ child._parent = self
+ child.setVisible(self.visible())
+ self._children.append(child)
+ return child
+
+ def addMenu(self, label: str, icon_name: str = ""):
+ child = YMenuItem(label, icon_name, enabled=True, is_menu=True)
+ child._parent = self
+ child.setVisible(self.visible())
+ self._children.append(child)
+ return child
+
+ def addSeparator(self):
+ # Represent separator as a disabled item with label "-"; backends can special-case it.
+ sep = YMenuItem("-", is_menu=False, enabled=False, is_separator=True)
+ sep._parent = self
+ self._children.append(sep)
+ return sep
+
+class YTreeItem(YItem):
+ def __init__(self, label: str, parent: Optional["YTreeItem"] = None, selected: Optional[bool] = False, is_open: bool = False, icon_name: str = ""):
+ ''' YTreeItem represents an item in a tree structure.
+ It can have child items and can be expanded or collapsed.'''
+ super().__init__(label, selected, icon_name)
+ self._children = []
+ self._is_open = is_open
+ self._parent_item = parent
+ if parent:
+ parent.addChild(self)
+
+ def parentItem(self):
+ return self._parent_item
+
+ def hasChildren(self):
+ return len(self._children) > 0
+
+ def childrenBegin(self):
+ return iter(self._children)
+
+ def childrenEnd(self):
+ return iter([])
+
+ def addChild(self, item):
+ if isinstance(item, str):
+ item = YTreeItem(item)
+ self._children.append(item)
+ item._parent_item = self
+ return item
+
+ def isOpen(self):
+ return self._is_open
+
+
+class YTableHeader:
+ """Helper class for table column properties.
+
+ Tracks columns' header text, alignment and whether a column is a
+ checkbox column.
+ """
+ def __init__(self):
+ self._columns = [] # list of dicts: {'header': str, 'alignment': YAlignmentType, 'checkbox': bool}
+
+ def addColumn(self, header, checkBox : Optional[bool] = False, alignment=YAlignmentType.YAlignBegin):
+ """Add a column with header text and optional alignment (no checkbox)."""
+ self._columns.append({'header': str(header), 'alignment': alignment, 'checkbox': bool(checkBox)})
+
+ def columns(self):
+ return len(self._columns)
+
+ def hasColumn(self, column):
+ return 0 <= int(column) < len(self._columns)
+
+ def header(self, column):
+ try:
+ return self._columns[int(column)]['header']
+ except Exception:
+ return ""
+
+ def isCheckboxColumn(self, column):
+ try:
+ return bool(self._columns[int(column)]['checkbox'])
+ except Exception:
+ return False
+
+ def alignment(self, column):
+ try:
+ return self._columns[int(column)]['alignment']
+ except Exception:
+ return YAlignmentType.YAlignUnchanged
+
+
+class YTableCell:
+ """One cell (one column in one row) of a YTableItem.
+
+ Supports label, optional icon name, optional sort key and an optional
+ checkbox state. Cells can be created detached or with a parent/table
+ assigned via `reparent()`.
+ """
+ def __init__(self, label: str = "", icon_name: str = "", sort_key: str = "", parent: Optional["YTableItem"] = None, column: int = -1, checked: Optional[bool] = None):
+ self._label = label
+ self._icon_name = icon_name
+ self._sort_key = sort_key
+ self._parent = parent
+ self._column = column
+ # checked: None means not-a-checkbox column; True/False represent checkbox state
+ self._checked = checked
+
+ def label(self):
+ return self._label
+
+ def setLabel(self, new_label: str):
+ self._label = new_label
+
+ def iconName(self):
+ return self._icon_name
+
+ def setIconName(self, new_icon_name: str):
+ self._icon_name = new_icon_name
+
+ def hasIconName(self):
+ return bool(self._icon_name)
+
+ def sortKey(self):
+ return self._sort_key
+
+ def hasSortKey(self):
+ return bool(self._sort_key)
+
+ def parent(self):
+ return self._parent
+
+ def column(self):
+ return self._column
+
+ def itemIndex(self):
+ return self._parent.index() if self._parent is not None else -1
+
+ def reparent(self, parent: "YTableItem", column: int):
+ if self._parent is not None:
+ raise Exception("Cell already has a parent")
+ self._parent = parent
+ self._column = column
+
+ # checkbox API
+ def setChecked(self, val: bool = True):
+ self._checked = bool(val)
+
+ def checked(self):
+ return bool(self._checked) if self._checked is not None else False
+
+
+class YTableItem(YTreeItem):
+ """Table item (one row). Each item may contain multiple `YTableCell`.
+
+ Provides convenience constructors and cell management similar to
+ the C++ `YTableItem` while also supporting checkbox cells.
+ """
+ def __init__(self, label: str = "", parent: Optional["YTreeItem"] = None, is_open: bool = False, icon_name: str = ""):
+ super().__init__(label, parent, False, is_open, icon_name)
+ self._cells = [] # list of YTableCell
+
+ def addCell(self, cell_or_label, icon_name: str = "", sort_key: str = ""):
+ """Add a cell instance or create one from label/icon/sort_key.
+ If a boolean is passed as first arg, treat it as a checkbox cell value.
+ """
+ if isinstance(cell_or_label, YTableCell):
+ cell = cell_or_label
+ else:
+ # allow boolean-only constructor for checkbox column
+ if isinstance(cell_or_label, bool):
+ cell = YTableCell("", "", "", parent=self, column=len(self._cells), checked=cell_or_label)
+ else:
+ cell = YTableCell(str(cell_or_label), icon_name, sort_key, parent=self, column=len(self._cells))
+ # set parent/column and append
+ try:
+ cell.reparent(self, len(self._cells))
+ except Exception:
+ # already parented; update column if needed
+ cell._column = len(self._cells)
+ cell._parent = self
+ self._cells.append(cell)
+
+ def addCells(self, *labels):
+ for lbl in labels:
+ self.addCell(lbl)
+
+ def deleteCells(self):
+ self._cells = []
+
+ def cellsBegin(self):
+ return iter(self._cells)
+
+ def cellsEnd(self):
+ return iter([])
+
+ def cell(self, index: int):
+ if not self.hasCell(index):
+ return None
+ try:
+ return self._cells[index]
+ except Exception:
+ return None
+
+ def cellCount(self):
+ return len(self._cells)
+
+ def hasCell(self, index: int):
+ return 0 <= index < len(self._cells)
+
+ def label(self, index: int = 0):
+ if not self.hasCell(index):
+ return ""
+ c = self.cell(index)
+ return c.label() if c is not None else ""
+
+ def checked(self, index: int = 0):
+ if not self.hasCell(index):
+ return False
+ c = self.cell(index)
+ return c.checked() if c is not None else False
+
+ def iconName(self, index: int = 0):
+ c = self.cell(index)
+ return c.iconName() if c is not None else ""
+
+ def hasIconName(self, index: int = 0):
+ c = self.cell(index)
+ return c.hasIconName() if c is not None else False
+
+ def debugLabel(self):
+ return f"{super().debugLabel()}[cells={self.cellCount()}]"
+
+# Property system
+class YPropertyType(Enum):
+ YUnknownPropertyType = 0
+ YOtherProperty = 1
+ YStringProperty = 2
+ YBoolProperty = 3
+ YIntegerProperty = 4
+
+class YProperty:
+ def __init__(self, name, prop_type, is_readonly=False):
+ self._name = name
+ self._type = prop_type
+ self._is_readonly = is_readonly
+
+ def name(self):
+ return self._name
+
+ def type(self):
+ return self._type
+
+ def isReadOnly(self):
+ return self._is_readonly
+
+class YPropertyValue:
+ def __init__(self, value=None, prop_type=YPropertyType.YUnknownPropertyType):
+ self._value = value
+ self._type = prop_type
+
+ def type(self):
+ return self._type
+
+ def stringVal(self):
+ return str(self._value) if self._value else ""
+
+ def boolVal(self):
+ return bool(self._value)
+
+ def integerVal(self):
+ return int(self._value) if self._value else 0
+
+class YPropertySet:
+ def __init__(self):
+ self._properties = {}
+
+ def add(self, prop):
+ self._properties[prop.name()] = prop
+
+ def contains(self, name):
+ return name in self._properties
+
+ def isEmpty(self):
+ return len(self._properties) == 0
+
+class YShortcut:
+ def __init__(self, widget):
+ self._widget = widget
+ self._shortcut = ''
+ self._conflict = False
+
+ def widget(self):
+ return self._widget
+
+ def shortcutString(self):
+ return self._shortcut
+
+ def setShortcut(self, new_shortcut):
+ self._shortcut = new_shortcut
+
+ def conflict(self):
+ return self._conflict
+
+ def setConflict(self, conflict=True):
+ self._conflict = conflict
diff --git a/manatools/aui/yui_curses.py b/manatools/aui/yui_curses.py
new file mode 100644
index 0000000..5fce18f
--- /dev/null
+++ b/manatools/aui/yui_curses.py
@@ -0,0 +1,690 @@
+"""
+NCurses backend implementation for YUI
+"""
+
+import curses
+import curses.ascii
+import sys
+import os
+import time
+import fnmatch
+import logging
+from .yui_common import *
+from .backends.curses import *
+
+class YUICurses:
+ def __init__(self):
+ self._widget_factory = YWidgetFactoryCurses()
+ self._optional_widget_factory = None
+ self._application = YApplicationCurses()
+ self._stdscr = None
+ self._colors_initialized = False
+ self._running = False
+
+ # Initialize curses
+ self._init_curses()
+
+ def _init_curses(self):
+ try:
+ self._stdscr = curses.initscr()
+ curses.noecho()
+ curses.cbreak()
+ curses.curs_set(1) # Show cursor
+ self._stdscr.keypad(True)
+
+ # Enable colors if available
+ if curses.has_colors():
+ curses.start_color()
+ curses.use_default_colors()
+ self._colors_initialized = True
+ # Define some color pairs
+ curses.init_pair(1, curses.COLOR_WHITE, curses.COLOR_BLUE)
+ curses.init_pair(2, curses.COLOR_YELLOW, -1)
+ curses.init_pair(3, curses.COLOR_GREEN, -1)
+ curses.init_pair(4, curses.COLOR_RED, -1)
+ except Exception as e:
+ print(f"Error initializing curses: {e}")
+ self._cleanup_curses()
+ raise
+
+ def _cleanup_curses(self):
+ try:
+ if self._stdscr:
+ curses.nocbreak()
+ self._stdscr.keypad(False)
+ curses.echo()
+ curses.curs_set(1)
+ curses.endwin()
+ except:
+ pass
+
+ def __del__(self):
+ self._cleanup_curses()
+
+ def widgetFactory(self):
+ return self._widget_factory
+
+ def optionalWidgetFactory(self):
+ return self._optional_widget_factory
+
+ def app(self):
+ return self._application
+
+ def application(self):
+ return self._application
+
+ def yApp(self):
+ return self._application
+
+class YApplicationCurses:
+ def __init__(self):
+ self._application_title = "manatools Curses Application"
+ self._product_name = "manatools AUI Curses"
+ self._icon_base_path = ""
+ self._icon = ""
+ # About dialog metadata
+ self._app_name = ""
+ self._version = ""
+ self._authors = ""
+ self._description = ""
+ self._license = ""
+ self._credits = ""
+ self._information = ""
+ self._logo = ""
+ # Default directories
+ try:
+ self._default_documents_dir = os.path.expanduser('~/Documenti')
+ except Exception:
+ self._default_documents_dir = os.path.expanduser('~')
+
+ def iconBasePath(self):
+ return self._icon_base_path
+
+ def setIconBasePath(self, new_icon_base_path):
+ self._icon_base_path = new_icon_base_path
+
+ def setProductName(self, product_name):
+ self._product_name = product_name
+
+ def productName(self):
+ return self._product_name
+
+ def setApplicationIcon(self, Icon):
+ """Set the application icon."""
+ self._icon = Icon
+
+ # --- About metadata getters/setters ---
+ def setApplicationName(self, name: str):
+ self._app_name = name or ""
+
+ def applicationName(self) -> str:
+ return self._app_name or self._product_name or ""
+
+ def setVersion(self, version: str):
+ self._version = version or ""
+
+ def version(self) -> str:
+ return self._version or ""
+
+ def setAuthors(self, authors: str):
+ self._authors = authors or ""
+
+ def authors(self) -> str:
+ return self._authors or ""
+
+ def setDescription(self, description: str):
+ self._description = description or ""
+
+ def description(self) -> str:
+ return self._description or ""
+
+ def setLicense(self, license_text: str):
+ self._license = license_text or ""
+
+ def license(self) -> str:
+ return self._license or ""
+
+ def setCredits(self, credits: str):
+ self._credits = credits or ""
+
+ def credits(self) -> str:
+ return self._credits or ""
+
+ def setInformation(self, information: str):
+ self._information = information or ""
+
+ def information(self) -> str:
+ return self._information or ""
+
+ def setLogo(self, logo_path: str):
+ self._logo = logo_path or ""
+
+ def logo(self) -> str:
+ return self._logo or ""
+
+ def askForExistingDirectory(self, startDir: str, headline: str):
+ """
+ NCurses overlay dialog to select an existing directory.
+ Presents a navigable list of directories similar to a simple file manager.
+ """
+ try:
+ start_dir = startDir if (startDir and os.path.isdir(startDir)) else os.path.expanduser('~')
+ return self._browse_paths(start_dir, select_file=False, headline=headline or "Select Directory", reason='directory')
+ except Exception:
+ return ""
+
+ def askForExistingFile(self, startWith: str, filter: str, headline: str):
+ """
+ NCurses overlay dialog to select an existing file.
+ Shows a navigable list of directories and files, honoring simple filters like "*.txt;*.md".
+ """
+ try:
+ if startWith and os.path.isfile(startWith):
+ start_dir = os.path.dirname(startWith)
+ elif startWith and os.path.isdir(startWith):
+ start_dir = startWith
+ else:
+ # Default to Documents if available, else home
+ start_dir = self._default_documents_dir if os.path.isdir(self._default_documents_dir) else os.path.expanduser('~')
+ return self._browse_paths(start_dir, select_file=True, headline=headline or "Open File", filter_str=filter, reason='file')
+ except Exception:
+ return ""
+
+ def askForSaveFileName(self, startWith: str, filter: str, headline: str):
+ """
+ NCurses overlay to choose a filename: navigate directories and type the name.
+ """
+ try:
+ if startWith and os.path.isfile(startWith):
+ start_dir = os.path.dirname(startWith)
+ default_name = os.path.basename(startWith)
+ elif startWith and os.path.isdir(startWith):
+ start_dir = startWith
+ default_name = ""
+ else:
+ start_dir = self._default_documents_dir if os.path.isdir(self._default_documents_dir) else os.path.expanduser('~')
+ default_name = ""
+ return self._browse_paths(start_dir, select_file=True, headline=headline or "Save File", filter_str=filter, reason='save', default_name=default_name)
+ except Exception:
+ return ""
+
+ def applicationIcon(self):
+ """Get the application icon."""
+ return self._icon
+
+ def setApplicationTitle(self, title):
+ """Set the application title."""
+ self._application_title = title
+ # Update terminal/window title for xterm-like terminals when stdout is a TTY
+ escape_sequences = [
+ f"\033]0;{title}\007", # Standard
+ f"\033]1;{title}\007", # Icon name
+ f"\033]2;{title}\007", # Window title
+ f"\033]30;{title}\007", # Konsole variant 1
+ f"\033]31;{title}\007", # Konsole variant 2
+ ]
+ try:
+ for seq in escape_sequences:
+ sys.stdout.write(seq)
+ sys.stdout.flush()
+ except Exception:
+ pass
+
+ def applicationTitle(self):
+ """Get the application title."""
+ return self._application_title
+
+ def setApplicationIcon(self, Icon):
+ """Set the application title."""
+ self._icon = Icon
+
+ def applicationIcon(self):
+ """Get the application title."""
+ return self.__icon
+
+ def isTextMode(self) -> bool:
+ """Indicate that this is a text-mode (ncurses) application."""
+ return True
+
+ def busyCursor(self):
+ """Set busy cursor (not applicable in ncurses)."""
+ pass
+
+ def normalCursor(self):
+ """Set normal cursor (not applicable in ncurses)."""
+ pass
+
+ # --- Internal helpers for ncurses file/directory chooser ---
+ def _parse_filter_patterns(self, filter_str: str):
+ try:
+ if not filter_str:
+ return []
+ parts = [p.strip() for p in filter_str.split(';') if p.strip()]
+ return parts
+ except Exception:
+ return []
+
+ def _list_entries(self, current_dir: str, select_file: bool, patterns):
+ """Return list of (label, path, type) for entries under current_dir.
+ type is 'dir' or 'file'. If select_file is True, apply patterns to files.
+ """
+ entries = []
+ try:
+ # Add parent directory entry
+ parent = os.path.dirname(current_dir.rstrip(os.sep)) or current_dir
+ if parent and parent != current_dir:
+ entries.append(("..", parent, 'dir'))
+ # List directory contents
+ with os.scandir(current_dir) as it:
+ dirs = []
+ files = []
+ for e in it:
+ try:
+ if e.is_dir(follow_symlinks=False):
+ dirs.append((e.name + '/', e.path, 'dir'))
+ elif e.is_file(follow_symlinks=False):
+ if not select_file:
+ continue
+ if not patterns:
+ files.append((e.name, e.path, 'file'))
+ else:
+ for pat in patterns:
+ if fnmatch.fnmatch(e.name, pat):
+ files.append((e.name, e.path, 'file'))
+ break
+ except Exception:
+ pass
+ # Sort directories and files separately
+ dirs.sort(key=lambda x: x[0].lower())
+ files.sort(key=lambda x: x[0].lower())
+ entries.extend(dirs)
+ entries.extend(files)
+ except Exception:
+ # On failure, just return parent
+ pass
+ return entries
+
+ def _browse_paths(self, start_dir: str, select_file: bool, headline: str, filter_str: str = "", reason: str = "file", default_name: str = ""):
+ """
+ Unified ncurses overlay to navigate directories and pick a file, directory or save path.
+ - `reason` in ('file', 'directory', 'save') controls final behavior.
+ - Uses a `YTableCurses` with a single "Name" column to list entries.
+ - Avoids race between refresh and button-based selection by keeping
+ the current selection in a label/input field that is updated on
+ SelectionChanged and read when the Select/Save button is pressed.
+ """
+ current_dir = start_dir if os.path.isdir(start_dir) else os.path.expanduser('~')
+ patterns = self._parse_filter_patterns(filter_str)
+
+ # Build dialog UI
+ dlg = YDialogCurses(YDialogType.YPopupDialog, YDialogColorMode.YDialogNormalColor)
+ root = YVBoxCurses(dlg)
+ YLabelCurses(root, headline, isHeading=True)
+ path_lbl = YLabelCurses(root, f"Current: {current_dir}")
+
+ # Table with single "Name" column
+ header = YTableHeader()
+ header.addColumn("Name")
+ table = YTableCurses(root, header, multiSelection=False)
+ try:
+ table.setWeight(YUIDimension.YD_VERT, 1)
+ except Exception:
+ pass
+
+ # Selection preview and optional filename input (useful for save)
+ selected_lbl = YLabelCurses(root, "Selected: ")
+ filename_input = None
+ if reason == 'save':
+ filename_input = YInputFieldCurses(root, label="Filename:")
+ try:
+ if default_name:
+ filename_input.setValue(default_name)
+ except Exception:
+ pass
+
+ # Buttons
+ buttons = YHBoxCurses(root)
+ btn_label = "Save" if reason == 'save' else "Select"
+ btn_select = YPushButtonCurses(buttons, btn_label)
+ btn_cancel = YPushButtonCurses(buttons, "Cancel")
+
+ selected_item_data = None
+
+ def refresh_listing(dir_path):
+ nonlocal selected_item_data
+ try:
+ table.deleteAllItems()
+ for (label, path, typ) in self._list_entries(dir_path, select_file, patterns):
+ it = YTableItem(label)
+ try:
+ it.addCell(label)
+ it.setData({'path': path, 'type': typ})
+ except Exception:
+ pass
+ table.addItem(it)
+ # reset selection state when navigating
+ selected_item_data = None
+ try:
+ selected_lbl.setText("Selected: ")
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ refresh_listing(current_dir)
+ try:
+ dlg.open()
+ except Exception:
+ return ""
+
+ # Event loop
+ result = ""
+ while True:
+ ev = dlg.waitForEvent()
+ if isinstance(ev, YCancelEvent):
+ result = ""
+ break
+ if isinstance(ev, YWidgetEvent):
+ w = ev.widget()
+ if w == btn_cancel and ev.reason() == YEventReason.Activated:
+ result = ""
+ break
+
+ # Select/Save pressed: read from selection preview / input field
+ if w == btn_select and ev.reason() == YEventReason.Activated:
+ try:
+ # If save: prefer filename_input value; if a file was selected,
+ # use that name as default when input is empty.
+ if reason == 'save':
+ name = None
+ try:
+ if filename_input is not None:
+ name = filename_input.value()
+ except Exception:
+ name = None
+ if selected_item_data and selected_item_data.get('type') == 'file':
+ sel_base = os.path.basename(selected_item_data.get('path'))
+ if not name:
+ name = sel_base
+ if name:
+ result = os.path.join(current_dir, name)
+ break
+ # if no name, ignore press
+ continue
+
+ # Directory selection: if a directory row is selected, return it,
+ # otherwise return current_dir
+ if reason == 'directory':
+ if selected_item_data and selected_item_data.get('type') == 'dir':
+ result = selected_item_data.get('path')
+ else:
+ result = current_dir
+ break
+
+ # File selection: if a file row is selected, return it
+ if reason == 'file':
+ if selected_item_data and selected_item_data.get('type') == 'file':
+ result = selected_item_data.get('path')
+ break
+ # nothing selected: ignore
+ continue
+ except Exception:
+ continue
+
+ # Table selection changed: either navigate into directories or update preview
+ if w == table and ev.reason() == YEventReason.SelectionChanged:
+ try:
+ sel = table.selectedItems()
+ if not sel:
+ selected_item_data = None
+ try:
+ selected_lbl.setText("Selected: ")
+ except Exception:
+ pass
+ continue
+ item = sel[0]
+ data = item.data() if hasattr(item, 'data') else None
+ if not data or 'path' not in data:
+ selected_item_data = None
+ try:
+ selected_lbl.setText("Selected: ")
+ except Exception:
+ pass
+ continue
+ if data.get('type') == 'dir':
+ # navigate into directory
+ current_dir = data['path']
+ try:
+ path_lbl.setText(f"Current: {current_dir}")
+ except Exception:
+ pass
+ refresh_listing(current_dir)
+ continue
+ else:
+ # file selected: update preview and prefill filename when saving
+ selected_item_data = data
+ try:
+ selected_lbl.setText(f"Selected: {os.path.basename(data.get('path'))}")
+ except Exception:
+ pass
+ if reason == 'save' and filename_input is not None:
+ try:
+ filename_input.setValue(os.path.basename(data.get('path')))
+ except Exception:
+ pass
+ except Exception:
+ continue
+
+ try:
+ dlg.destroy()
+ except Exception:
+ pass
+ return result
+
+
+class YWidgetFactoryCurses:
+ def __init__(self):
+ pass
+
+ def createMainDialog(self, color_mode=YDialogColorMode.YDialogNormalColor):
+ return YDialogCurses(YDialogType.YMainDialog, color_mode)
+
+ def createPopupDialog(self, color_mode=YDialogColorMode.YDialogNormalColor):
+ return YDialogCurses(YDialogType.YMainDialog, color_mode)
+
+ def createVBox(self, parent):
+ return YVBoxCurses(parent)
+
+ def createHBox(self, parent):
+ return YHBoxCurses(parent)
+
+ def createLabel(self, parent, text, isHeading=False, isOutputField=False):
+ return YLabelCurses(parent, text, isHeading, isOutputField)
+
+ def createHeading(self, parent, label):
+ return YLabelCurses(parent, label, isHeading=True)
+
+ def createInputField(self, parent, label, password_mode=False):
+ return YInputFieldCurses(parent, label, password_mode)
+
+ def createIntField(self, parent, label, minVal, maxVal, initialVal):
+ return YIntFieldCurses(parent, label, minVal, maxVal, initialVal)
+
+ def createMultiLineEdit(self, parent, label):
+ return YMultiLineEditCurses(parent, label)
+
+ def createPushButton(self, parent, label):
+ return YPushButtonCurses(parent, label)
+
+ def createIconButton(self, parent, iconName, fallbackTextLabel):
+ ''' create a button with a fallback text label in ncurses'''
+ return YPushButtonCurses(parent, label=fallbackTextLabel, icon_name=iconName, icon_only=True)
+
+ def createCheckBox(self, parent, label, is_checked=False):
+ return YCheckBoxCurses(parent, label, is_checked)
+
+ def createComboBox(self, parent, label, editable=False):
+ return YComboBoxCurses(parent, label, editable)
+
+ def createSelectionBox(self, parent, label):
+ return YSelectionBoxCurses(parent, label)
+
+ #Multi-selection box variant
+ def createMultiSelectionBox(self, parent, label):
+ return YSelectionBoxCurses(parent, label, multi_selection=True)
+
+ # Alignment helpers
+ def createLeft(self, parent):
+ return YAlignmentCurses(parent, horAlign=YAlignmentType.YAlignBegin, vertAlign=YAlignmentType.YAlignUnchanged)
+
+ def createRight(self, parent):
+ return YAlignmentCurses(parent, horAlign=YAlignmentType.YAlignEnd, vertAlign=YAlignmentType.YAlignUnchanged)
+
+ def createTop(self, parent):
+ return YAlignmentCurses(parent, horAlign=YAlignmentType.YAlignUnchanged, vertAlign=YAlignmentType.YAlignBegin)
+
+ def createBottom(self, parent):
+ return YAlignmentCurses(parent, horAlign=YAlignmentType.YAlignUnchanged, vertAlign=YAlignmentType.YAlignEnd)
+
+ def createHCenter(self, parent):
+ return YAlignmentCurses(parent, horAlign=YAlignmentType.YAlignCenter, vertAlign=YAlignmentType.YAlignUnchanged)
+
+ def createVCenter(self, parent):
+ return YAlignmentCurses(parent, horAlign=YAlignmentType.YAlignUnchanged, vertAlign=YAlignmentType.YAlignCenter)
+
+ def createHVCenter(self, parent):
+ return YAlignmentCurses(parent, horAlign=YAlignmentType.YAlignCenter, vertAlign=YAlignmentType.YAlignCenter)
+
+ def createAlignment(self, parent, horAlignment: YAlignmentType, vertAlignment: YAlignmentType):
+ """Create a generic YAlignment using YAlignmentType enums (or compatible specs)."""
+ return YAlignmentCurses(parent, horAlign=horAlignment, vertAlign=vertAlignment)
+
+ def createMinWidth(self, parent, minWidth: int):
+ a = YAlignmentCurses(parent, horAlign=YAlignmentType.YAlignUnchanged, vertAlign=YAlignmentType.YAlignUnchanged)
+ try:
+ a.setMinWidth(int(minWidth))
+ except Exception:
+ pass
+ return a
+
+ def createMinHeight(self, parent, minHeight: int):
+ a = YAlignmentCurses(parent, horAlign=YAlignmentType.YAlignUnchanged, vertAlign=YAlignmentType.YAlignUnchanged)
+ try:
+ a.setMinHeight(int(minHeight))
+ except Exception:
+ pass
+ return a
+
+ def createMinSize(self, parent, minWidth: int, minHeight: int):
+ a = YAlignmentCurses(parent, horAlign=YAlignmentType.YAlignUnchanged, vertAlign=YAlignmentType.YAlignUnchanged)
+ try:
+ a.setMinSize(int(minWidth), int(minHeight))
+ except Exception:
+ pass
+ return a
+
+ def createTree(self, parent, label, multiselection=False, recursiveselection = False):
+ """Create a Tree widget."""
+ return YTreeCurses(parent, label, multiselection, recursiveselection)
+
+ def createFrame(self, parent, label: str=""):
+ """Create a Frame widget."""
+ return YFrameCurses(parent, label)
+
+ def createCheckBoxFrame(self, parent, label: str = "", checked: bool = False):
+ """Create a CheckBox Frame widget."""
+ return YCheckBoxFrameCurses(parent, label, checked)
+
+ def createProgressBar(self, parent, label, max_value=100):
+ """Create a Progress Bar widget."""
+ return YProgressBarCurses(parent, label, max_value)
+
+ def createRadioButton(self, parent, label="", isChecked=False):
+ """Create a Radio Button widget."""
+ return YRadioButtonCurses(parent, label, isChecked)
+
+ def createTable(self, parent, header: YTableHeader, multiSelection=False):
+ """Create a Table widget (curses backend)."""
+ return YTableCurses(parent, header, multiSelection)
+
+ def createRichText(self, parent, text: str = "", plainTextMode: bool = False):
+ """Create a RichText widget (curses backend)."""
+ return YRichTextCurses(parent, text, plainTextMode)
+
+ def createMenuBar(self, parent):
+ """Create a MenuBar widget (curses backend)."""
+ return YMenuBarCurses(parent)
+
+ def createReplacePoint(self, parent):
+ """Create a ReplacePoint widget (curses backend)."""
+ return YReplacePointCurses(parent)
+
+ def createDumbTab(self, parent):
+ """Create a DumbTab widget (curses backend)."""
+ return YDumbTabCurses(parent)
+
+ def createSpacing(self, parent, dim: YUIDimension, stretchable: bool = False, size_px: int = 0):
+ """Create a Spacing/Stretch widget.
+
+ - `dim`: primary dimension for spacing (YUIDimension)
+ - `stretchable`: expand in primary dimension when True (minimum size = `size`)
+ - `size_px`: spacing size in pixels (integer), converted to character cells
+ using 8 px/char horizontally and ~16 px/row vertically.
+ """
+ return YSpacingCurses(parent, dim, stretchable, size_px)
+
+ def createImage(self, parent, imageFileName):
+ """Create an image widget as an empty frame for curses."""
+ return YImageCurses(parent, imageFileName)
+
+ # Create a Spacing widget variant
+ def createHStretch(self, parent):
+ """Create a Horizontal Stretch widget."""
+ return self.createSpacing(parent, YUIDimension.Horizontal, stretchable=True)
+
+ def createVStretch(self, parent):
+ """Create a Vertical Stretch widget."""
+ return self.createSpacing(parent, YUIDimension.Vertical, stretchable=True)
+
+ def createHSpacing(self, parent, size_px: int = 8):
+ """Create a Horizontal Spacing widget."""
+ return self.createSpacing(parent, YUIDimension.Horizontal, stretchable=False, size_px=size_px)
+
+ def createVSpacing(self, parent, size_px: int = 16):
+ """Create a Vertical Spacing widget."""
+ return self.createSpacing(parent, YUIDimension.Vertical, stretchable=False, size_px=size_px)
+
+ def createSlider(self, parent, label: str, minVal: int, maxVal: int, initialVal: int):
+ """Create a Slider widget (ncurses backend)."""
+ return YSliderCurses(parent, label, minVal, maxVal, initialVal)
+
+ def createDateField(self, parent, label):
+ """Create a DateField widget (curses backend)."""
+ return YDateFieldCurses(parent, label)
+
+ def createLogView(self, parent, label, visibleLines, storedLines=0):
+ """Create a LogView widget (ncurses backend)."""
+ from .backends.curses import YLogViewCurses
+ try:
+ return YLogViewCurses(parent, label, visibleLines, storedLines)
+ except Exception as e:
+ logging.getLogger(__name__).exception("Failed to create YLogViewCurses: %s", e)
+ raise
+
+ def createTimeField(self, parent, label):
+ """Create a TimeField widget (ncurses backend)."""
+ from .backends.curses import YTimeFieldCurses
+ try:
+ return YTimeFieldCurses(parent, label)
+ except Exception as e:
+ logging.getLogger(__name__).exception("Failed to create YTimeFieldCurses: %s", e)
+ raise
+
+ def createPaned(self, parent, dimension: YUIDimension = YUIDimension.YD_HORIZ):
+ """Create a Paned widget (ncurses backend)."""
+ from .backends.curses import YPanedCurses
+ try:
+ return YPanedCurses(parent, dimension)
+ except Exception as e:
+ logging.getLogger(__name__).exception("Failed to create YPanedCurses: %s", e)
+ raise
\ No newline at end of file
diff --git a/manatools/aui/yui_gtk.py b/manatools/aui/yui_gtk.py
new file mode 100644
index 0000000..19336ea
--- /dev/null
+++ b/manatools/aui/yui_gtk.py
@@ -0,0 +1,920 @@
+"""
+GTK4 backend implementation for YUI (converted from GTK3)
+"""
+import gi
+gi.require_version('Gtk', '4.0')
+gi.require_version('Gdk', '4.0')
+from gi.repository import Gtk, Gdk, GObject, GdkPixbuf, GLib, Gio
+from typing import List
+import os
+import logging
+from .yui_common import *
+from .backends.gtk import *
+
+
+class YUIGtk:
+ def __init__(self):
+ # Use a dedicated widget factory to match other backends.
+ self._widget_factory = YWidgetFactoryGtk()
+ self._optional_widget_factory = None
+ self._application = YApplicationGtk()
+ try:
+ self._logger = logging.getLogger(f"manatools.aui.gtk.{self.__class__.__name__}")
+ except Exception:
+ self._logger = logging.getLogger("manatools.aui.gtk.YUIGtk")
+
+ def widgetFactory(self):
+ return self._widget_factory
+
+ def optionalWidgetFactory(self):
+ return self._optional_widget_factory
+
+ def app(self):
+ return self._application
+
+ def application(self):
+ return self._application
+
+ def yApp(self):
+ return self._application
+
+class YApplicationGtk:
+ def __init__(self):
+ self._application_title = "manatools GTK Application"
+ self._product_name = "manatools AUI Gtk"
+ self._icon_base_path = None
+ self._icon = "manatools" # default icon name
+ # cached resolved GdkPixbuf.Pixbuf (or None)
+ self._gtk_icon_pixbuf = None
+ # About dialog metadata
+ self._app_name = ""
+ self._version = ""
+ self._authors = ""
+ self._description = ""
+ self._license = ""
+ self._credits = ""
+ self._information = ""
+ self._logo = ""
+ try:
+ self._logger = logging.getLogger(f"manatools.aui.gtk.{self.__class__.__name__}")
+ except Exception:
+ self._logger = logging.getLogger("manatools.aui.gtk.YApplicationGtk")
+
+ def _resolve_pixbuf(self, icon_spec):
+ """Resolve icon_spec into a GdkPixbuf.Pixbuf if possible.
+ Prefer local path resolved against iconBasePath if set, else try theme lookup.
+ """
+ if not icon_spec:
+ return None
+ # try explicit path (icon_base_path forced)
+ try:
+ # if base path set and icon_spec not absolute, try join
+ if self._icon_base_path:
+ cand = icon_spec if os.path.isabs(icon_spec) else os.path.join(self._icon_base_path, icon_spec)
+ if os.path.exists(cand) and GdkPixbuf is not None:
+ try:
+ return GdkPixbuf.Pixbuf.new_from_file(cand)
+ except Exception:
+ pass
+ # try absolute path
+ if os.path.isabs(icon_spec) and os.path.exists(icon_spec) and GdkPixbuf is not None:
+ try:
+ return GdkPixbuf.Pixbuf.new_from_file(icon_spec)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ # fallback: try icon theme lookup
+ try:
+ theme = Gtk.IconTheme.get_default()
+ # request a reasonable size (48) - theme will scale as needed
+ if theme and theme.has_icon(icon_spec) and GdkPixbuf is not None:
+ try:
+ pix = theme.load_icon(icon_spec, 48, 0)
+ if pix is not None:
+ return pix
+ except Exception:
+ pass
+ except Exception:
+ pass
+ return None
+
+ def iconBasePath(self):
+ return self._icon_base_path
+
+ def setIconBasePath(self, new_icon_base_path):
+ self._icon_base_path = new_icon_base_path
+
+ def setProductName(self, product_name):
+ self._product_name = product_name
+
+ def productName(self):
+ return self._product_name
+
+ def setApplicationTitle(self, title):
+ """Set the application title and try to update dialogs/windows."""
+ self._application_title = title
+ try:
+ # update the top most YDialogGtk window if available
+ try:
+ dlg = YDialogGtk.currentDialog(doThrow=False)
+ if dlg:
+ win = getattr(dlg, "_window", None)
+ if win:
+ try:
+ win.set_title(title)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def applicationTitle(self):
+ """Get the application title."""
+ return self._application_title
+
+ def setApplicationIcon(self, Icon):
+ """Set application icon spec (theme name or path). If iconBasePath is set, prefer local file."""
+ try:
+ self._icon = Icon or ""
+ except Exception:
+ self._icon = ""
+ # resolve and cache a GdkPixbuf if possible
+ try:
+ self._gtk_icon_pixbuf = self._resolve_pixbuf(self._icon)
+ except Exception:
+ self._gtk_icon_pixbuf = None
+
+ # Try to set a global default icon for windows (best-effort)
+ try:
+ if self._gtk_icon_pixbuf is not None:
+ # Gtk.Window.set_default_icon/from_file may be available
+ try:
+ Gtk.Window.set_default_icon(self._gtk_icon_pixbuf)
+ except Exception:
+ try:
+ # try using file path if we resolved one from disk
+ if self._icon_base_path:
+ cand = self._icon if os.path.isabs(self._icon) else os.path.join(self._icon_base_path, self._icon)
+ if os.path.exists(cand):
+ Gtk.Window.set_default_icon_from_file(cand)
+ else:
+ # if _icon was an absolute file
+ if os.path.isabs(self._icon) and os.path.exists(self._icon):
+ Gtk.Window.set_default_icon_from_file(self._icon)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ # Apply icon to any open YDialogGtk windows
+ try:
+ for dlg in getattr(YDialogGtk, "_open_dialogs", []) or []:
+ try:
+ win = getattr(dlg, "_window", None)
+ if win:
+ if self._gtk_icon_pixbuf is not None:
+ try:
+ # try direct pixbuf assignment
+ win.set_icon(self._gtk_icon_pixbuf)
+ except Exception:
+ try:
+ # try setting icon name as fallback
+ win.set_icon_name(self._icon)
+ except Exception:
+ pass
+ else:
+ # if we have only a name, try icon name
+ try:
+ win.set_icon_name(self._icon)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def applicationIcon(self):
+ return self._icon
+
+ # --- About metadata getters/setters ---
+ def setApplicationName(self, name: str):
+ self._app_name = name or ""
+
+ def applicationName(self) -> str:
+ return self._app_name or self._product_name or ""
+
+ def setVersion(self, version: str):
+ self._version = version or ""
+
+ def version(self) -> str:
+ return self._version or ""
+
+ def setAuthors(self, authors: str):
+ self._authors = authors or ""
+
+ def authors(self) -> str:
+ return self._authors or ""
+
+ def setDescription(self, description: str):
+ self._description = description or ""
+
+ def description(self) -> str:
+ return self._description or ""
+
+ def setLicense(self, license_text: str):
+ self._license = license_text or ""
+
+ def license(self) -> str:
+ return self._license or ""
+
+ def setCredits(self, credits: str):
+ self._credits = credits or ""
+
+ def credits(self) -> str:
+ return self._credits or ""
+
+ def setInformation(self, information: str):
+ self._information = information or ""
+
+ def information(self) -> str:
+ return self._information or ""
+
+ def setLogo(self, logo_path: str):
+ self._logo = logo_path or ""
+
+ def logo(self) -> str:
+ return self._logo or ""
+
+ def isTextMode(self) -> bool:
+ """Indicate that this is not a text-mode (GTK) application."""
+ return False
+
+ def busyCursor(self):
+ """Set busy cursor (GTK implementation)."""
+ current_dialog = YDialogGtk.currentDialog()
+ if current_dialog is not None:
+ window = getattr(current_dialog, "_window", None)
+ if window is None:
+ return
+ surface = window.get_surface()
+ if surface is None:
+ return
+ display = surface.get_display()
+ cursor = Gdk.Cursor.new_from_name("wait", None)
+ # Try alternatives if "wait" not available
+ if not cursor:
+ cursor = Gdk.Cursor.new_from_name("progress", None)
+ if not cursor:
+ cursor = Gdk.Cursor.new_from_name("grabbing", None)
+ if cursor is None:
+ return
+ surface.set_cursor(cursor)
+ display.flush() # Force update
+
+ def normalCursor(self):
+ """Set normal cursor (GTK implementation)."""
+ current_dialog = YDialogGtk.currentDialog()
+ if current_dialog is not None:
+ window = getattr(current_dialog, "_window", None)
+ if window is None:
+ return
+ surface = window.get_surface()
+ if surface is None:
+ return
+ display = surface.get_display()
+ cursor = Gdk.Cursor.new_from_name("default", None)
+ if cursor is None:
+ return
+ surface.set_cursor(cursor)
+ display.flush() # Force update
+
+ def _create_gtk4_filters(self, filter_str: str) -> List[Gtk.FileFilter]:
+ """
+ Create GTK4 file filters from a semicolon-separated filter string.
+ """
+ filters = []
+
+ if not filter_str or filter_str.strip() == "":
+ return filters
+
+ # Split and clean patterns
+ patterns = [p.strip() for p in filter_str.split(';') if p.strip()]
+
+ # Create main filter
+ main_filter = Gtk.FileFilter()
+
+ # Set a meaningful name
+ if len(patterns) == 1:
+ ext = patterns[0].replace('*.', '').replace('*', '')
+ main_filter.set_name(f"{ext.upper()} files")
+ else:
+ main_filter.set_name("Supported files")
+
+ # Add patterns to the filter
+ for pattern in patterns:
+ pattern = pattern.strip()
+ if not pattern:
+ continue
+
+ # Handle different pattern formats
+ if pattern == "*" or pattern == "*.*":
+ # All files
+ main_filter.add_pattern("*")
+ elif pattern.startswith("*."):
+ # Pattern like "*.txt"
+ main_filter.add_pattern(pattern)
+ # Also add without star for some systems
+ main_filter.add_pattern(pattern[1:]) # ".txt"
+ elif pattern.startswith("."):
+ # Pattern like ".txt"
+ main_filter.add_pattern(f"*{pattern}") # "*.txt"
+ main_filter.add_pattern(pattern) # ".txt"
+ else:
+ # Try as mime type or literal pattern
+ if '/' in pattern: # Looks like a mime type
+ main_filter.add_mime_type(pattern)
+ else:
+ main_filter.add_pattern(pattern)
+
+ filters.append(main_filter)
+
+ # Add "All files" filter
+ all_filter = Gtk.FileFilter()
+ all_filter.set_name("All files")
+ all_filter.add_pattern("*")
+ filters.append(all_filter)
+
+ return filters
+
+ def askForExistingDirectory(self, startDir: str, headline: str):
+ """
+ Prompt user to select an existing directory (GTK implementation).
+ """
+ try:
+ # try to find an active YDialogGtk window to use as transient parent
+ parent_window = None
+ try:
+ for od in getattr(YDialogGtk, "_open_dialogs", []) or []:
+ try:
+ w = getattr(od, "_window", None)
+ if w:
+ parent_window = w
+ break
+ except Exception:
+ pass
+ except Exception:
+ parent_window = None
+
+ # ensure application name is set so recent manager can record resources
+ try:
+ GLib.set_prgname(self._product_name or "manatools")
+ except Exception:
+ pass
+ # Log portal-related environment to help diagnose behavior differences
+ try:
+ self._logger.debug("GTK_USE_PORTAL=%s", os.environ.get("GTK_USE_PORTAL"))
+ self._logger.debug("XDG_CURRENT_DESKTOP=%s", os.environ.get("XDG_CURRENT_DESKTOP"))
+ except Exception:
+ pass
+
+ # Use Gtk.FileDialog (GTK 4.10+) exclusively for directory selection
+ if not hasattr(Gtk, 'FileDialog'):
+ try:
+ self._logger.error('Gtk.FileDialog is not available in this GTK runtime')
+ except Exception:
+ pass
+ return ""
+
+ try:
+ fd = Gtk.FileDialog.new()
+ try:
+ fd.set_title(headline or "Select Directory")
+ except Exception:
+ pass
+
+ try:
+ fd.set_modal(True)
+ except Exception:
+ pass
+
+ if startDir and os.path.exists(startDir):
+ try:
+ fd.set_initial_folder(Gio.File.new_for_path(startDir))
+ except Exception:
+ try:
+ fd.set_initial_folder_uri(GLib.filename_to_uri(startDir, None))
+ except Exception:
+ pass
+
+ loop = GLib.MainLoop()
+ result_holder = {'file': None}
+
+ def _on_opened(dialog, result, _lh=loop, _holder=result_holder, _fd=fd):
+ try:
+ f = _fd.select_folder_finish(result)
+ _holder['file'] = f
+ except Exception:
+ _holder['file'] = None
+ try:
+ _fd.close()
+ except Exception:
+ pass
+ try:
+ _lh.quit()
+ except Exception:
+ pass
+
+ # Fallback transient parent for portals/desktops that require it
+ if parent_window is None:
+ self._logger.warning("askForExistingDirectory: no parent window found")
+ try:
+ parent_window = Gtk.Window()
+ try:
+ parent_window.set_title(self._application_title)
+ except Exception:
+ pass
+ except Exception:
+ self._logger.exception("askForExistingDirectory: failed to create fallback parent window")
+ parent_window = None
+
+ fd.select_folder(parent_window, None, _on_opened)
+ loop.run()
+ gf = result_holder.get('file')
+ if gf is not None:
+ try:
+ return gf.get_path() or gf.get_uri() or ""
+ except Exception:
+ return ""
+ return ""
+ except Exception:
+ try:
+ self._logger.exception('FileDialog.select_folder failed')
+ except Exception:
+ pass
+ return ""
+ except Exception:
+ try:
+ self._logger.exception("askForExistingDirectory failed")
+ except Exception:
+ pass
+ return ""
+
+ def askForExistingFile(self, startWith: str, filter: str, headline: str):
+ """
+ Prompt user to select an existing file.
+
+ Parameters:
+ - startWith: initial directory or file
+ - filter: semicolon-separated string containing a list of filters (e.g. "*.txt;*.md")
+ - headline: explanatory text for the dialog
+
+ Returns: selected filename as string, or empty string if cancelled.
+ """
+ try:
+ # try to use an active dialog window as transient parent
+ parent_window = None
+ try:
+ for od in getattr(YDialogGtk, "_open_dialogs", []) or []:
+ try:
+ w = getattr(od, "_window", None)
+ if w:
+ parent_window = w
+ break
+ except Exception:
+ pass
+ except Exception:
+ parent_window = None
+
+ if parent_window is None:
+ self._logger.info("askForExistingFile: no parent window found")
+
+ try:
+ GLib.set_prgname(self._product_name or "manatools")
+ except Exception:
+ pass
+ # Log portal-related environment to help diagnose behavior differences
+ try:
+ self._logger.debug("GTK_USE_PORTAL=%s", os.environ.get("GTK_USE_PORTAL"))
+ self._logger.debug("XDG_CURRENT_DESKTOP=%s", os.environ.get("XDG_CURRENT_DESKTOP"))
+ except Exception:
+ pass
+
+ # Use Gtk.FileDialog (GTK 4.10+) exclusively for file open
+ if not hasattr(Gtk, 'FileDialog'):
+ try:
+ self._logger.error('Gtk.FileDialog is not available in this GTK runtime')
+ except Exception:
+ pass
+ return ""
+
+ try:
+ fd = Gtk.FileDialog.new()
+ try:
+ fd.set_title(headline or "Open File")
+ except Exception:
+ pass
+ try:
+ fd.set_modal(True)
+ except Exception:
+ pass
+ try:
+ fd.set_accept_label("Open")
+ except Exception:
+ pass
+
+ # filters
+ try:
+ filters = self._create_gtk4_filters(filter)
+ if filters:
+ filter_list = Gio.ListStore.new(Gtk.FileFilter)
+ for file_filter in filters:
+ filter_list.append(file_filter)
+ fd.set_filters(filter_list)
+ except Exception:
+ self._logger.exception("askForExistingFile: setting filters failed")
+ pass
+
+ # Determine initial directory: requested path if valid, else default Documents
+ initial_dir = None
+ if startWith and os.path.exists(startWith):
+ # Set both folder and URI to improve portal compatibility
+ try:
+ target = os.path.dirname(startWith) if os.path.isfile(startWith) else startWith
+ fd.set_initial_folder(Gio.File.new_for_path(target))
+ except Exception:
+ self._logger.exception("askForExistingFile: setting initial folder failed")
+ try:
+ fd.set_initial_folder_uri(GLib.filename_to_uri(target, None))
+ except Exception:
+ pass
+
+ loop = GLib.MainLoop()
+ result_holder = {'file': None}
+
+ def _on_opened(dialog, result, _lh=loop, _holder=result_holder, _fd=fd):
+ try:
+ f = _fd.open_finish(result)
+ _holder['file'] = f
+ except Exception:
+ _holder['file'] = None
+ try:
+ _fd.close()
+ except Exception:
+ pass
+ try:
+ _lh.quit()
+ except Exception:
+ pass
+
+ fd.open(parent_window, None, _on_opened)
+ loop.run()
+ gf = result_holder.get('file')
+ if gf is not None:
+ pathname = gf.get_path() or gf.get_uri() or ""
+ self._logger.debug("askForExistingFile: selected file: %s", pathname)
+ return pathname
+ return ""
+ except Exception:
+ try:
+ self._logger.exception('FileDialog.open failed')
+ except Exception:
+ pass
+ return ""
+ except Exception:
+ return ""
+
+ def askForSaveFileName(self, startWith: str, filter: str, headline: str):
+ """
+ Prompt user to choose a filename to save data.
+
+ Returns selected filename or empty string if cancelled.
+ """
+ try:
+ parent_window = None
+ try:
+ for od in getattr(YDialogGtk, "_open_dialogs", []) or []:
+ try:
+ w = getattr(od, "_window", None)
+ if w:
+ parent_window = w
+ break
+ except Exception:
+ pass
+ except Exception:
+ parent_window = None
+
+ try:
+ GLib.set_prgname(self._product_name or "manatools")
+ except Exception:
+ pass
+
+ # Use Gtk.FileDialog (GTK 4.10+) exclusively for save
+ if not hasattr(Gtk, 'FileDialog'):
+ try:
+ self._logger.error('Gtk.FileDialog is not available in this GTK runtime')
+ except Exception:
+ pass
+ return ""
+
+ try:
+ fd = Gtk.FileDialog.new()
+ try:
+ fd.set_title(headline or "Save File")
+ except Exception:
+ pass
+ try:
+ fd.set_modal(True)
+ except Exception:
+ pass
+
+ if filter:
+ try:
+ filters = self._create_gtk4_filters(filter)
+ if filters:
+ filter_list = Gio.ListStore.new(Gtk.FileFilter)
+ for file_filter in filters:
+ filter_list.append(file_filter)
+ fd.set_filters(filter_list)
+ except Exception:
+ self._logger.exception("askForSaveFileName: setting filters failed")
+
+ if startWith and os.path.exists(startWith):
+ try:
+ target = os.path.dirname(startWith) if os.path.isfile(startWith) else startWith
+ fd.set_initial_folder(Gio.File.new_for_path(target))
+ except Exception:
+ self._logger.exception("askForSaveFileName: setting initial folder failed")
+ try:
+ fd.set_initial_folder_uri(GLib.filename_to_uri(target, None))
+ except Exception:
+ pass
+
+ loop = GLib.MainLoop()
+ result_holder = {'file': None}
+
+ def _on_saved(dialog, result, _lh=loop, _holder=result_holder, _fd=fd):
+ try:
+ f = _fd.save_finish(result)
+ _holder['file'] = f
+ except Exception:
+ _holder['file'] = None
+ try:
+ _fd.close()
+ except Exception:
+ pass
+ try:
+ _lh.quit()
+ except Exception:
+ pass
+
+ # Fallback transient parent creation if none present
+ if parent_window is None:
+ self._logger.warning("askForSaveFileName: no parent window found")
+ try:
+ parent_window = Gtk.Window()
+ try:
+ parent_window.set_title(self._application_title)
+ except Exception:
+ pass
+ except Exception:
+ self._logger.exception("askForSaveFileName: failed to create fallback parent window")
+ parent_window = None
+
+ fd.save(parent_window, None, _on_saved)
+ loop.run()
+ gf = result_holder.get('file')
+ if gf is not None:
+ try:
+ return gf.get_path() or gf.get_uri() or ""
+ except Exception:
+ return ""
+ return ""
+ except Exception:
+ try:
+ self._logger.exception('FileDialog.save failed')
+ except Exception:
+ pass
+ return ""
+ except Exception:
+ try:
+ self._logger.exception("askForSaveFileName failed")
+ except Exception:
+ pass
+ return ""
+
+class YWidgetFactoryGtk:
+ def __init__(self):
+ pass
+
+ def createMainDialog(self, color_mode=YDialogColorMode.YDialogNormalColor):
+ return YDialogGtk(YDialogType.YMainDialog, color_mode)
+
+ def createPopupDialog(self, color_mode=YDialogColorMode.YDialogNormalColor):
+ return YDialogGtk(YDialogType.YPopupDialog, color_mode)
+
+ def createVBox(self, parent):
+ return YVBoxGtk(parent)
+
+ def createHBox(self, parent):
+ return YHBoxGtk(parent)
+
+ def createPushButton(self, parent, label):
+ return YPushButtonGtk(parent, label)
+
+ def createIconButton(self, parent, iconName, fallbackTextLabel):
+ return YPushButtonGtk(parent, label=fallbackTextLabel, icon_name=iconName, icon_only=True)
+
+ def createLabel(self, parent, text, isHeading=False, isOutputField=False):
+ return YLabelGtk(parent, text, isHeading, isOutputField)
+
+ def createHeading(self, parent, label):
+ return YLabelGtk(parent, label, isHeading=True)
+
+ def createInputField(self, parent, label, password_mode=False):
+ return YInputFieldGtk(parent, label, password_mode)
+
+ def createMultiLineEdit(self, parent, label):
+ return YMultiLineEditGtk(parent, label)
+
+ def createIntField(self, parent, label, minVal, maxVal, initialVal):
+ return YIntFieldGtk(parent, label, minVal, maxVal, initialVal)
+
+ def createCheckBox(self, parent, label, is_checked=False):
+ return YCheckBoxGtk(parent, label, is_checked)
+
+ def createPasswordField(self, parent, label):
+ return YInputFieldGtk(parent, label, password_mode=True)
+
+ def createComboBox(self, parent, label, editable=False):
+ return YComboBoxGtk(parent, label, editable)
+
+ def createSelectionBox(self, parent, label):
+ return YSelectionBoxGtk(parent, label)
+
+ #Multi-selection box variant
+ def createMultiSelectionBox(self, parent, label):
+ return YSelectionBoxGtk(parent, label, multi_selection=True)
+
+ def createMenuBar(self, parent):
+ """Create a MenuBar widget (GTK backend)."""
+ return YMenuBarGtk(parent)
+
+ # Alignment helpers
+ def createLeft(self, parent):
+ return YAlignmentGtk(parent, horAlign=YAlignmentType.YAlignBegin, vertAlign=YAlignmentType.YAlignUnchanged)
+
+ def createRight(self, parent):
+ return YAlignmentGtk(parent, horAlign=YAlignmentType.YAlignEnd, vertAlign=YAlignmentType.YAlignUnchanged)
+
+ def createTop(self, parent):
+ return YAlignmentGtk(parent, horAlign=YAlignmentType.YAlignUnchanged, vertAlign=YAlignmentType.YAlignBegin)
+
+ def createBottom(self, parent):
+ return YAlignmentGtk(parent, horAlign=YAlignmentType.YAlignUnchanged, vertAlign=YAlignmentType.YAlignEnd)
+
+ def createHCenter(self, parent):
+ return YAlignmentGtk(parent, horAlign=YAlignmentType.YAlignCenter, vertAlign=YAlignmentType.YAlignUnchanged)
+
+ def createVCenter(self, parent):
+ return YAlignmentGtk(parent, horAlign=YAlignmentType.YAlignUnchanged, vertAlign=YAlignmentType.YAlignCenter)
+
+ def createHVCenter(self, parent):
+ return YAlignmentGtk(parent, horAlign=YAlignmentType.YAlignCenter, vertAlign=YAlignmentType.YAlignCenter)
+
+ def createAlignment(self, parent, horAlignment: YAlignmentType, vertAlignment: YAlignmentType):
+ """Create a generic YAlignment using YAlignmentType enums (or compatible specs)."""
+ return YAlignmentGtk(parent, horAlign=horAlignment, vertAlign=vertAlignment)
+
+ def createMinWidth(self, parent, minWidth: int):
+ a = YAlignmentGtk(parent)
+ try:
+ a._min_width_px = int(minWidth)
+ except Exception:
+ pass
+ return a
+
+ def createMinHeight(self, parent, minHeight: int):
+ a = YAlignmentGtk(parent)
+ try:
+ a._min_height_px = int(minHeight)
+ except Exception:
+ pass
+ return a
+
+ def createMinSize(self, parent, minWidth: int, minHeight: int):
+ a = YAlignmentGtk(parent)
+ try:
+ a._min_width_px = int(minWidth)
+ except Exception:
+ pass
+ try:
+ a._min_height_px = int(minHeight)
+ except Exception:
+ pass
+ return a
+
+ def createTree(self, parent, label, multiselection=False, recursiveselection = False):
+ """Create a Tree widget."""
+ return YTreeGtk(parent, label, multiselection, recursiveselection)
+
+ def createTable(self, parent, header: YTableHeader, multiSelection: bool = False):
+ """Create a Table widget."""
+ from .backends.gtk.tablegtk import YTableGtk
+ return YTableGtk(parent, header, multiSelection)
+
+ def createRichText(self, parent, text: str = "", plainTextMode: bool = False):
+ """Create a RichText widget (GTK backend)."""
+ from .backends.gtk.richtextgtk import YRichTextGtk
+ return YRichTextGtk(parent, text, plainTextMode)
+
+ def createCheckBoxFrame(self, parent, label: str = "", checked: bool = False):
+ """Create a CheckBox Frame widget."""
+ return YCheckBoxFrameGtk(parent, label, checked)
+
+ def createProgressBar(self, parent, label, max_value=100):
+ return YProgressBarGtk(parent, label, max_value)
+
+ def createRadioButton(self, parent, label:str = "", isChecked:bool = False):
+ """Create a Radio Button widget."""
+ return YRadioButtonGtk(parent, label, isChecked)
+
+ def createReplacePoint(self, parent):
+ """Create a ReplacePoint widget (GTK backend)."""
+ return YReplacePointGtk(parent)
+
+ def createDumbTab(self, parent):
+ from .backends.gtk import YDumbTabGtk
+ return YDumbTabGtk(parent)
+
+ def createSpacing(self, parent, dim: YUIDimension, stretchable: bool = False, size_px: int = 0):
+ """Create a Spacing/Stretch widget.
+
+ - `dim`: primary dimension for spacing (YUIDimension)
+ - `stretchable`: expand in primary dimension when True (minimum size = `size`)
+ - `size_px`: spacing size in pixels (device units, integer)
+ """
+ return YSpacingGtk(parent, dim, stretchable, size_px)
+
+ def createImage(self, parent, imageFileName):
+ """Create an image widget."""
+ return YImageGtk(parent, imageFileName)
+
+ # Create a Spacing widget variant
+ def createHStretch(self, parent):
+ """Create a Horizontal Stretch widget."""
+ return self.createSpacing(parent, YUIDimension.Horizontal, stretchable=True)
+
+ def createVStretch(self, parent):
+ """Create a Vertical Stretch widget."""
+ return self.createSpacing(parent, YUIDimension.Vertical, stretchable=True)
+
+ def createHSpacing(self, parent, size_px: int = 8):
+ """Create a Horizontal Spacing widget."""
+ return self.createSpacing(parent, YUIDimension.Horizontal, stretchable=False, size_px=size_px)
+
+ def createSlider(self, parent, label: str, minVal: int, maxVal: int, initialVal: int):
+ """Create a Slider widget (GTK backend)."""
+ from .backends.gtk import YSliderGtk
+ return YSliderGtk(parent, label, minVal, maxVal, initialVal)
+
+ def createVSpacing(self, parent, size_px: int = 16):
+ """Create a Vertical Spacing widget."""
+ return self.createSpacing(parent, YUIDimension.Vertical, stretchable=False, size_px=size_px)
+
+ def createDateField(self, parent, label):
+ """Create a DateField widget (GTK backend)."""
+ return YDateFieldGtk(parent, label)
+
+ def createLogView(self, parent, label, visibleLines, storedLines=0):
+ """Create a LogView widget (GTK backend)."""
+ from .backends.gtk import YLogViewGtk
+ try:
+ return YLogViewGtk(parent, label, visibleLines, storedLines)
+ except Exception as e:
+ logging.getLogger(__name__).exception("Failed to create YLogViewGtk: %s", e)
+ raise
+
+ def createTimeField(self, parent, label):
+ """Create a TimeField widget (GTK backend)."""
+ from .backends.gtk import YTimeFieldGtk
+ try:
+ return YTimeFieldGtk(parent, label)
+ except Exception as e:
+ logging.getLogger(__name__).exception("Failed to create YTimeFieldGtk: %s", e)
+ raise
+
+ def createFrame(self, parent, label: str=""):
+ """Create a Frame widget."""
+ return YFrameGtk(parent, label)
+
+ def createPaned(self, parent, dimension: YUIDimension = YUIDimension.YD_HORIZ):
+ """Create a Paned widget (GTK backend)."""
+ from .backends.gtk import YPanedGtk
+ try:
+ return YPanedGtk(parent, dimension)
+ except Exception as e:
+ logging.getLogger(__name__).exception("Failed to create YPanedGtk: %s", e)
+ raise
\ No newline at end of file
diff --git a/manatools/aui/yui_qt.py b/manatools/aui/yui_qt.py
new file mode 100644
index 0000000..0c5c316
--- /dev/null
+++ b/manatools/aui/yui_qt.py
@@ -0,0 +1,495 @@
+"""
+Qt backend implementation for YUI
+"""
+
+import sys
+from PySide6 import QtWidgets, QtCore, QtGui
+import os
+import logging
+from .yui_common import *
+from .backends.qt import *
+from .backends.qt.commonqt import _resolve_icon
+
+class YUIQt:
+ def __init__(self):
+ self._widget_factory = YWidgetFactoryQt()
+ self._optional_widget_factory = None
+ # Ensure QApplication exists
+ self._qapp = QtWidgets.QApplication.instance()
+ if not self._qapp:
+ self._qapp = QtWidgets.QApplication(sys.argv)
+ self._application = YApplicationQt()
+ # logger for the backend manager
+ try:
+ self._logger = logging.getLogger(f"manatools.aui.qt.{self.__class__.__name__}")
+ except Exception:
+ self._logger = logging.getLogger("manatools.aui.qt.YUIQt")
+
+ def widgetFactory(self):
+ return self._widget_factory
+
+ def optionalWidgetFactory(self):
+ return self._optional_widget_factory
+
+ def app(self):
+ return self._application
+
+ def application(self):
+ return self._application
+
+ def yApp(self):
+ return self._application
+
+class YApplicationQt:
+ def __init__(self):
+ self._application_title = "manatools Qt Application"
+ self._product_name = "manatools AUI Qt"
+ self._icon_base_path = None
+ self._icon = "manatools" # default icon name
+ # cached QIcon resolved from _icon (None if not resolved)
+ self._qt_icon = None
+ # About dialog metadata
+ self._app_name = ""
+ self._version = ""
+ self._authors = ""
+ self._description = ""
+ self._license = ""
+ self._credits = ""
+ self._information = ""
+ self._logo = ""
+ try:
+ self._logger = logging.getLogger(f"manatools.aui.qt.{self.__class__.__name__}")
+ except Exception:
+ self._logger = logging.getLogger("manatools.aui.qt.YApplicationQt")
+
+ def iconBasePath(self):
+ return self._icon_base_path
+
+ def setIconBasePath(self, new_icon_base_path):
+ self._icon_base_path = new_icon_base_path
+
+ def setProductName(self, product_name):
+ self._product_name = product_name
+
+ def productName(self):
+ return self._product_name
+
+ def setApplicationTitle(self, title):
+ """Set the application title and try to update dialogs/windows."""
+ self._application_title = title
+ try:
+ # update the top most YDialogQt window if available
+ try:
+ dlg = YDialogQt.currentDialog(doThrow=False)
+ if dlg:
+ win = getattr(dlg, "_qwidget", None)
+ if win:
+ try:
+ win.setWindowTitle(title)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def setApplicationIcon(self, Icon):
+ """Set application icon spec (theme name or path). Try to apply it to QApplication and active dialogs.
+
+ If iconBasePath is set, icon is considered relative to that path and will force local file usage.
+ """
+ try:
+ self._icon = Icon
+ except Exception:
+ self._icon = ""
+ # resolve into a QIcon and cache
+ try:
+ self._qt_icon = _resolve_icon(self._icon)
+ except Exception:
+ self._qt_icon = None
+
+ # apply to global QApplication
+ try:
+ app = QtWidgets.QApplication.instance()
+ if app and self._qt_icon:
+ try:
+ app.setWindowIcon(self._qt_icon)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ # apply to any open YDialogQt windows (if backend used)
+ try:
+ # avoid importing the whole module if not available
+ from . import yui as yui_mod
+ # try to update dialogs known in YDialogQt._open_dialogs
+ dlg_cls = getattr(yui_mod, "YDialogQt", None)
+ if dlg_cls is None:
+ # fallback to import local symbol if module structure different
+ dlg_cls = globals().get("YDialogQt", None)
+ if dlg_cls is not None:
+ for dlg in getattr(dlg_cls, "_open_dialogs", []) or []:
+ try:
+ w = getattr(dlg, "_qwidget", None)
+ if w and self._qt_icon:
+ try:
+ w.setWindowIcon(self._qt_icon)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ except Exception:
+ # best-effort; ignore failures
+ pass
+
+ def applicationTitle(self):
+ """Get the application title."""
+ return self._application_title
+
+ # --- About metadata getters/setters ---
+ def setApplicationName(self, name: str):
+ self._app_name = name or ""
+
+ def applicationName(self) -> str:
+ return self._app_name or self._product_name or ""
+
+ def setVersion(self, version: str):
+ self._version = version or ""
+
+ def version(self) -> str:
+ return self._version or ""
+
+ def setAuthors(self, authors: str):
+ self._authors = authors or ""
+
+ def authors(self) -> str:
+ return self._authors or ""
+
+ def setDescription(self, description: str):
+ self._description = description or ""
+
+ def description(self) -> str:
+ return self._description or ""
+
+ def setLicense(self, license_text: str):
+ self._license = license_text or ""
+
+ def license(self) -> str:
+ return self._license or ""
+
+ def setCredits(self, credits: str):
+ self._credits = credits or ""
+
+ def credits(self) -> str:
+ return self._credits or ""
+
+ def setInformation(self, information: str):
+ self._information = information or ""
+
+ def information(self) -> str:
+ return self._information or ""
+
+ def setLogo(self, logo_path: str):
+ self._logo = logo_path or ""
+
+ def logo(self) -> str:
+ return self._logo or ""
+
+ def isTextMode(self) -> bool:
+ """Indicate that this is not a text-mode (Qt) application."""
+ return False
+
+ def busyCursor(self):
+ """Set busy cursor (Qt backend)."""
+ try:
+ QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.WaitCursor)
+ except Exception:
+ pass
+
+ def normalCursor(self):
+ """Set normal cursor (Qt backend)."""
+ try:
+ QtWidgets.QApplication.restoreOverrideCursor()
+ except Exception:
+ pass
+
+ def askForExistingDirectory(self, startDir: str, headline: str):
+ """
+ Prompt user to select an existing directory.
+
+ Parameters:
+ - startDir: initial folder to display (string, may be empty)
+ - headline: explanatory text for the dialog
+
+ Returns: selected directory path as string, or empty string if cancelled.
+ """
+ try:
+ start = startDir or ""
+ # Use QFileDialog static helper for convenience
+ res = QtWidgets.QFileDialog.getExistingDirectory(None, headline or "Select Directory", start)
+ return res or ""
+ except Exception:
+ try:
+ self._logger.exception("askForExistingDirectory failed")
+ except Exception:
+ pass
+ return ""
+
+ def askForExistingFile(self, startWith: str, filter: str, headline: str):
+ """
+ Prompt user to select an existing file.
+
+ Parameters:
+ - startWith: initial directory or file
+ - filter: semicolon-separated string containing a list of filters (e.g. "*.txt;*.md")
+ - headline: explanatory text for the dialog
+
+ Returns: selected filename as string, or empty string if cancelled.
+ """
+ try:
+ start = startWith or ""
+ flt = filter if filter else "All files (*)"
+ fn, _ = QtWidgets.QFileDialog.getOpenFileName(None, headline or "Open File", start, flt)
+ return fn or ""
+ except Exception:
+ try:
+ self._logger.exception("askForExistingFile failed")
+ except Exception:
+ pass
+ return ""
+
+ def askForSaveFileName(self, startWith: str, filter: str, headline: str):
+ """
+ Prompt user to choose a filename to save data.
+
+ Parameters are as in `askForExistingFile`.
+
+ Returns: selected filename as string, or empty string if cancelled.
+ """
+ try:
+ start = startWith or ""
+ flt = filter if filter else "All files (*)"
+ fn, _ = QtWidgets.QFileDialog.getSaveFileName(None, headline or "Save File", start, flt)
+ return fn or ""
+ except Exception:
+ try:
+ self._logger.exception("askForSaveFileName failed")
+ except Exception:
+ pass
+ return ""
+
+ def setApplicationIcon(self, Icon):
+ """Set the application title."""
+ self._icon = Icon
+
+ def applicationIcon(self):
+ """Get the application icon."""
+ return self._icon
+
+class YWidgetFactoryQt:
+ def __init__(self):
+ pass
+
+ def createMainDialog(self, color_mode=YDialogColorMode.YDialogNormalColor):
+ return YDialogQt(YDialogType.YMainDialog, color_mode)
+
+ def createPopupDialog(self, color_mode=YDialogColorMode.YDialogNormalColor):
+ return YDialogQt(YDialogType.YPopupDialog, color_mode)
+
+ def createVBox(self, parent):
+ return YVBoxQt(parent)
+
+ def createHBox(self, parent):
+ return YHBoxQt(parent)
+
+ def createPushButton(self, parent, label):
+ return YPushButtonQt(parent, label)
+
+ def createIconButton(self, parent, iconName, fallbackTextLabel):
+ return YPushButtonQt(parent, label=fallbackTextLabel, icon_name=iconName, icon_only=True)
+
+ def createLabel(self, parent, text, isHeading=False, isOutputField=False):
+ return YLabelQt(parent, text, isHeading, isOutputField)
+
+ def createHeading(self, parent, label):
+ return YLabelQt(parent, label, isHeading=True)
+
+ def createInputField(self, parent, label, password_mode=False):
+ return YInputFieldQt(parent, label, password_mode)
+
+ def createMultiLineEdit(self, parent, label):
+ return YMultiLineEditQt(parent, label)
+
+ def createIntField(self, parent, label, minVal, maxVal, initialVal):
+ return YIntFieldQt(parent, label, minVal, maxVal, initialVal)
+
+ def createCheckBox(self, parent, label, is_checked=False):
+ return YCheckBoxQt(parent, label, is_checked)
+
+ def createPasswordField(self, parent, label):
+ return YInputFieldQt(parent, label, password_mode=True)
+
+ def createComboBox(self, parent, label, editable=False):
+ return YComboBoxQt(parent, label, editable)
+
+ def createSelectionBox(self, parent, label):
+ return YSelectionBoxQt(parent, label)
+
+ #Multi-selection box variant
+ def createMultiSelectionBox(self, parent, label):
+ return YSelectionBoxQt(parent, label, multi_selection=True)
+
+ def createProgressBar(self, parent, label, max_value=100):
+ return YProgressBarQt(parent, label, max_value)
+
+ # Alignment helpers
+ def createLeft(self, parent):
+ return YAlignmentQt(parent, horAlign=YAlignmentType.YAlignBegin, vertAlign=YAlignmentType.YAlignUnchanged)
+
+ def createRight(self, parent):
+ return YAlignmentQt(parent, horAlign=YAlignmentType.YAlignEnd, vertAlign=YAlignmentType.YAlignUnchanged)
+
+ def createTop(self, parent):
+ return YAlignmentQt(parent, horAlign=YAlignmentType.YAlignUnchanged, vertAlign=YAlignmentType.YAlignBegin)
+
+ def createBottom(self, parent):
+ return YAlignmentQt(parent, horAlign=YAlignmentType.YAlignUnchanged, vertAlign=YAlignmentType.YAlignEnd)
+
+ def createHCenter(self, parent):
+ return YAlignmentQt(parent, horAlign=YAlignmentType.YAlignCenter, vertAlign=YAlignmentType.YAlignUnchanged)
+
+ def createVCenter(self, parent):
+ return YAlignmentQt(parent, horAlign=YAlignmentType.YAlignUnchanged, vertAlign=YAlignmentType.YAlignCenter)
+
+ def createHVCenter(self, parent):
+ return YAlignmentQt(parent, horAlign=YAlignmentType.YAlignCenter, vertAlign=YAlignmentType.YAlignCenter)
+
+ def createAlignment(self, parent, horAlignment: YAlignmentType, vertAlignment: YAlignmentType):
+ """Create a generic YAlignment using YAlignmentType enums (or compatible specs)."""
+ return YAlignmentQt(parent, horAlign=horAlignment, vertAlign=vertAlignment)
+
+ def createMinWidth(self, parent, minWidth: int):
+ a = YAlignmentQt(parent)
+ try:
+ a.setMinWidth(int(minWidth))
+ except Exception:
+ pass
+ return a
+
+ def createMinHeight(self, parent, minHeight: int):
+ a = YAlignmentQt(parent)
+ try:
+ a.setMinHeight(int(minHeight))
+ except Exception:
+ pass
+ return a
+
+ def createMinSize(self, parent, minWidth: int, minHeight: int):
+ a = YAlignmentQt(parent)
+ try:
+ a.setMinSize(int(minWidth), int(minHeight))
+ except Exception:
+ pass
+ return a
+
+ def createTree(self, parent, label, multiselection=False, recursiveselection = False):
+ """Create a Tree widget."""
+ return YTreeQt(parent, label, multiselection, recursiveselection)
+
+ def createFrame(self, parent, label: str=""):
+ """Create a Frame widget."""
+ return YFrameQt(parent, label)
+
+ def createCheckBoxFrame(self, parent, label: str = "", checked: bool = False):
+ """Create a CheckBox Frame widget."""
+ return YCheckBoxFrameQt(parent, label, checked)
+
+ def createRadioButton(self, parent, label:str = "", isChecked:bool = False):
+ """Create a Radio Button widget."""
+ return YRadioButtonQt(parent, label, isChecked)
+
+ def createTable(self, parent, header: YTableHeader, multiSelection: bool = False):
+ """Create a Table widget."""
+ return YTableQt(parent, header, multiSelection)
+
+ def createRichText(self, parent, text: str = "", plainTextMode: bool = False):
+ """Create a RichText widget (Qt backend)."""
+ return YRichTextQt(parent, text, plainTextMode)
+
+ def createMenuBar(self, parent):
+ """Create a MenuBar widget (Qt backend)."""
+ return YMenuBarQt(parent)
+
+ def createReplacePoint(self, parent):
+ """Create a ReplacePoint widget (Qt backend)."""
+ return YReplacePointQt(parent)
+
+ def createDumbTab(self, parent):
+ """Create a DumbTab (tab bar with single content area, Qt backend)."""
+ return YDumbTabQt(parent)
+
+ def createSpacing(self, parent, dim: YUIDimension, stretchable: bool = False, size_px: int = 0):
+ """Create a Spacing/Stretch widget.
+
+ - `dim`: primary dimension for spacing (YUIDimension)
+ - `stretchable`: expand in primary dimension when True (minimum size = `size`)
+ - `size_px`: spacing size in pixels (device units, integer)
+ """
+ return YSpacingQt(parent, dim, stretchable, size_px)
+
+ def createImage(self, parent, imageFileName):
+ """Create an image widget."""
+ return YImageQt(parent, imageFileName)
+
+ # Create a Spacing widget variant
+ def createHStretch(self, parent):
+ """Create a Horizontal Stretch widget."""
+ return self.createSpacing(parent, YUIDimension.Horizontal, stretchable=True)
+
+ def createVStretch(self, parent):
+ """Create a Vertical Stretch widget."""
+ return self.createSpacing(parent, YUIDimension.Vertical, stretchable=True)
+
+ def createHSpacing(self, parent, size_px: int = 8):
+ """Create a Horizontal Spacing widget."""
+ return self.createSpacing(parent, YUIDimension.Horizontal, stretchable=False, size_px=size_px)
+
+ def createSlider(self, parent, label: str, minVal: int, maxVal: int, initialVal: int):
+ """Create a Slider widget (Qt backend)."""
+ return YSliderQt(parent, label, minVal, maxVal, initialVal)
+
+ def createVSpacing(self, parent, size_px: int = 16):
+ """Create a Vertical Spacing widget."""
+ return self.createSpacing(parent, YUIDimension.Vertical, stretchable=False, size_px=size_px)
+
+ def createDateField(self, parent, label):
+ """Create a DateField widget (Qt backend)."""
+ return YDateFieldQt(parent, label)
+
+ def createLogView(self, parent, label, visibleLines, storedLines=0):
+ """Create a LogView widget (Qt backend)."""
+ from .backends.qt import YLogViewQt
+ try:
+ return YLogViewQt(parent, label, visibleLines, storedLines)
+ except Exception as e:
+ logging.getLogger(__name__).exception("Failed to create YLogViewQt: %s", e)
+ raise
+
+ def createTimeField(self, parent, label):
+ """Create a TimeField widget (Qt backend)."""
+ from .backends.qt import YTimeFieldQt
+ try:
+ return YTimeFieldQt(parent, label)
+ except Exception as e:
+ logging.getLogger(__name__).exception("Failed to create YTimeFieldQt: %s", e)
+ raise
+
+ def createPaned(self, parent, dimension: YUIDimension = YUIDimension.YD_HORIZ):
+ """Create a Paned widget (Qt backend)."""
+ from .backends.qt import YPanedQt
+ try:
+ return YPanedQt(parent, dimension)
+ except Exception as e:
+ logging.getLogger(__name__).exception("Failed to create YPanedQt: %s", e)
+ raise
\ No newline at end of file
diff --git a/manatools/ui/basedialog.py b/manatools/ui/basedialog.py
index 635a70e..2a247cc 100644
--- a/manatools/ui/basedialog.py
+++ b/manatools/ui/basedialog.py
@@ -22,12 +22,12 @@
@package manatools.ui.basedialog
'''
-import yui
+from ..aui import yui as yui
from enum import Enum
-import manatools.event as event
-import manatools.eventmanager as eventManager
+from .. import event as event
+from .. import eventmanager as eventManager
class DialogType(Enum):
MAIN = 1
@@ -61,9 +61,10 @@ def __init__(self, title, icon="", dialogType=DialogType.MAIN, minWidth=-1, minH
@param title dialog title
@param icon dialog icon
@param dialogType (DialogType.MAIN or DialogType.POPUP)
- @param minWidth > 0 mim width size, see libYui createMinSize
- @param minHeight > 0 mim height size, see libYui createMinSize
+ @param minWidth > 0 min width size in pixels
+ @param minHeight > 0 min height size in pixels
'''
+ print(f"BaseDialog init title={title} icon={icon} dialogType={dialogType} minWidth={minWidth} minHeight={minHeight}")
self._dialogType = dialogType
self._icon = icon
self._title = title
@@ -116,13 +117,13 @@ def run(self):
'''
run the Dialog
'''
+ self._setupUI()
+
self.backupTitle = yui.YUI.app().applicationTitle()
yui.YUI.app().setApplicationTitle(self._title)
if self._icon:
backupIcon = yui.YUI.app().applicationIcon()
yui.YUI.app().setApplicationIcon(self._icon)
-
- self._setupUI()
self._running = True
self._handleEvents()
@@ -130,10 +131,10 @@ def run(self):
#restore old application title
yui.YUI.app().setApplicationTitle(self.backupTitle)
if self._icon:
- yui.YUI.app().setApplicationTitle(backupIcon)
-
- self.dialog.destroy()
- self.dialog = None
+ yui.YUI.app().setApplicationIcon(backupIcon)
+ if self.dialog is not None:
+ self.dialog.destroy()
+ self.dialog = None
@property
def eventManager(self):
@@ -149,43 +150,23 @@ def factory(self):
return yui widget factory
'''
return yui.YUI.widgetFactory()
-
- @property
- def optFactory(self):
- '''
- return yui optional widget factory
- '''
- return yui.YUI.optionalWidgetFactory()
-
- @property
- def mgaFactory(self):
- '''
- return yui mageia extended widget factory
- '''
- if (not self._mgaFactory) :
- self.factory
- MGAPlugin = "mga"
- mgaFact = yui.YExternalWidgets.externalWidgetFactory(MGAPlugin)
- self._mgaFactory = yui.YMGAWidgetFactory.getYMGAWidgetFactory(mgaFact)
- return self._mgaFactory
-
def _setupUI(self):
self.dialog = self.factory.createPopupDialog() if self._dialogType == DialogType.POPUP else self.factory.createMainDialog()
-
- parent = self.dialog
- if self._minSize:
- parent = self.factory.createMinSize(parent, self._minSize['minWidth'], self._minSize['minHeight'])
-
- vbox = self.factory.createVBox(parent)
- self.UIlayout(vbox)
- def pollEvent(self):
- '''
- perform yui pollEvent
- '''
- return self.dialog.pollEvent()
+ # If a minimum size is requested, wrap the layout inside a MinSize container.
+ # IMPORTANT: MinSize is a single-child container -> add a VBox inside it and
+ # pass that VBox to UIlayout so multiple children can be attached there.
+ content_vbox = None
+ if self._minSize is not None:
+ min_container = self.factory.createMinSize(self.dialog, self._minSize['minWidth'], self._minSize['minHeight'])
+ content_vbox = self.factory.createVBox(min_container)
+ else:
+ content_vbox = self.factory.createVBox(self.dialog)
+ layout_parent = content_vbox
+ # Build the dialog layout using the chosen parent (MinSize->VBox or root VBox)
+ self.UIlayout(layout_parent)
def _handleEvents(self):
'''
@@ -194,29 +175,30 @@ def _handleEvents(self):
while self._running == True:
event = self.dialog.waitForEvent(self.timeout)
-
- eventType = event.eventType()
-
- rebuild_package_list = False
- group = None
- #event type checking
- if (eventType == yui.YEvent.WidgetEvent) :
- # widget selected
- widget = event.widget()
- wEvent = yui.toYWidgetEvent(event)
- self.eventManager.widgetEvent(widget, wEvent)
- elif (eventType == yui.YEvent.MenuEvent) :
- ### MENU ###
- item = event.item()
- mEvent = yui.toYMenuEvent(event)
- self.eventManager.menuEvent(item, mEvent)
- elif (eventType == yui.YEvent.CancelEvent) :
- self.eventManager.cancelEvent()
- break
- elif (eventType == yui.YEvent.TimeoutEvent) :
- self.eventManager.timeoutEvent()
+ if event is not None:
+ eventType = event.eventType()
+
+ rebuild_package_list = False
+ group = None
+ #event type checking
+ if (eventType == yui.YEventType.WidgetEvent) :
+ # widget selected
+ widget = event.widget()
+ self.eventManager.widgetEvent(widget, event)
+ elif (eventType == yui.YEventType.MenuEvent) :
+ ### MENU ###
+ item = event.item()
+ self.eventManager.menuEvent(item, event)
+ elif (eventType == yui.YEventType.CancelEvent) :
+ self.eventManager.cancelEvent()
+ break
+ elif (eventType == yui.YEventType.TimeoutEvent) :
+ self.eventManager.timeoutEvent()
+ else:
+ print(f"Unmanaged event type {eventType}")
else:
- print("Unmanaged event type %d"%(eventType))
+ #TODO logging
+ pass
self.doSomethingIntoLoop()
diff --git a/manatools/ui/common.py b/manatools/ui/common.py
index f7115ae..a1da19c 100644
--- a/manatools/ui/common.py
+++ b/manatools/ui/common.py
@@ -11,9 +11,12 @@
@package manatools.ui.common
'''
-import yui
+from ..aui import yui
+from ..aui.yui_common import YUIDimension, YItem
from enum import Enum
import gettext
+import logging
+import warnings
# https://pymotw.com/3/gettext/#module-localization
t = gettext.translation(
'python-manatools',
@@ -23,129 +26,344 @@
_ = t.gettext
ngettext = t.ngettext
+logger = logging.getLogger("manatools.ui.common")
+
def destroyUI () :
'''
- destroy all the dialogs and delete YUI plugins. Use this at the very end of your application
- to close everything correctly, expecially if you experience a crash in Qt plugin as explained
- into ussue https://github.com/libyui/libyui-qt/issues/41
+ Best-effort teardown for AUI dialogs. AUI manages backend lifecycle internally,
+ so there is typically no need to manually destroy UI plugins as in libyui.
+ This function exists for API compatibility and currently performs no action.
'''
- yui.YDialog.deleteAllDialogs()
- # next line seems to be a workaround to prevent the qt-app from crashing
- # see https://github.com/libyui/libyui-qt/issues/41
- yui.YUILoader.deleteUI()
-
+ return
+
+
+def _push_app_title(new_title):
+ """Save current application title and optionally set a new one."""
+ try:
+ app = yui.YUI.app()
+ old_title = app.applicationTitle()
+ if new_title:
+ app.setApplicationTitle(str(new_title))
+ return old_title
+ except Exception:
+ return None
+
+
+def _restore_app_title(old_title):
+ """Restore the previously saved application title."""
+ app = yui.YUI.app()
+ try:
+ if old_title is not None:
+ app.setApplicationTitle(str(old_title))
+ except Exception:
+ pass
+
+def _extract_dialog_size(info):
+ """Normalize a size specification stored in an info dictionary."""
+ if not info:
+ return None
+ size_spec = info.get('size')
+ if not size_spec:
+ return None
+
+ width = height = None
+ if isinstance(size_spec, dict):
+ width = size_spec.get('width')
+ if width is None:
+ width = size_spec.get('column') or size_spec.get('columns')
+ height = size_spec.get('height')
+ if height is None:
+ height = size_spec.get('lines') or size_spec.get('rows')
+ elif isinstance(size_spec, (list, tuple)):
+ if len(size_spec) > 0:
+ width = size_spec[0]
+ if len(size_spec) > 1:
+ height = size_spec[1]
+ else:
+ logger.debug("Unsupported size spec type: %s", type(size_spec))
+ return None
+
+ try:
+ width = int(width) if width is not None else None
+ height = int(height) if height is not None else None
+ except Exception:
+ return None
+
+ if width and height and width > 0 and height > 0:
+ return (width, height)
+ return None
+
+
+def _create_message_text_widget(factory, parent, text, richtext):
+ """Create a compact text widget for message dialogs.
+
+ On Qt backend, YRichText is implemented with QTextBrowser whose intrinsic
+ minimum size is relatively large, causing popup dialogs with small size
+ hints to grow significantly. For compact message dialogs we prefer a wrapped
+ label on Qt while preserving rich text markup rendering.
+
+ Args:
+ factory: Active YUI widget factory.
+ parent: Parent widget/container.
+ text: Message text or markup.
+ richtext: Whether rich text rendering is requested.
+
+ Returns:
+ YWidget: Created text widget.
+ """
+ backend_name = ""
+ try:
+ backend_name = str(getattr(yui.YUI.backend(), "value", "") or "").lower()
+ except Exception:
+ backend_name = ""
+
+ if richtext and backend_name == "qt":
+ tw = factory.createLabel(parent, text)
+ try:
+ tw.setAutoWrap(True)
+ except Exception:
+ pass
+ try:
+ tw.setStretchable(yui.YUIDimension.YD_HORIZ, True)
+ tw.setStretchable(yui.YUIDimension.YD_VERT, False)
+ except Exception:
+ pass
+ return tw
+
+ if richtext:
+ tw = factory.createRichText(parent, "", False)
+ tw.setValue(text)
+ else:
+ tw = factory.createLabel(parent, text)
+ try:
+ tw.setAutoWrap(True)
+ except Exception:
+ pass
+
+ try:
+ tw.setStretchable(yui.YUIDimension.YD_HORIZ, True)
+ tw.setStretchable(yui.YUIDimension.YD_VERT, False)
+ except Exception:
+ pass
+ return tw
def warningMsgBox (info) :
'''
- This function creates an Warning dialog and show the message
- passed as input.
+ This function creates a Warning dialog and shows the message passed as input.
@param info: dictionary, information to be passed to the dialog.
title => dialog title
- text => string to be swhon into the dialog
+ text => string to be shown into the dialog
richtext => True if using rich text
+ size => Mapping | Sequence | None
+ Optional minimum-size hint. Accepted formats:
+ - mapping containing 'width'/'height' (or legacy 'column'/'lines')
+ - tuple/list where the first element is width and the second height
'''
- if (not info) :
+ if not info:
return 0
- retVal = 0
- yui.YUI.widgetFactory
- factory = yui.YExternalWidgets.externalWidgetFactory("mga")
- factory = yui.YMGAWidgetFactory.getYMGAWidgetFactory(factory)
- dlg = factory.createDialogBox(yui.YMGAMessageBox.B_ONE, yui.YMGAMessageBox.D_WARNING)
-
- if ('title' in info.keys()) :
- dlg.setTitle(info['title'])
-
- rt = False
- if ("richtext" in info.keys()) :
- rt = info['richtext']
-
- if ('text' in info.keys()) :
- dlg.setText(info['text'], rt)
-
- dlg.setButtonLabel(_("&Ok"), yui.YMGAMessageBox.B_ONE )
-# dlg.setMinSize(50, 5)
-
- retVal = dlg.show()
dlg = None
-
- return 1
-
+ try:
+ factory = yui.YUI.widgetFactory()
+ dlg = factory.createPopupDialog()
+ title = info.get('title')
+ old_title = _push_app_title(title)
+
+ root_vbox = factory.createVBox(dlg)
+ content_parent = root_vbox
+ size_hint = _extract_dialog_size(info)
+ if size_hint:
+ try:
+ content_parent = factory.createMinSize(root_vbox, size_hint[0], size_hint[1])
+ except Exception:
+ content_parent = root_vbox
+ vbox = factory.createVBox(content_parent)
+
+ # Content row: icon + text
+ text = info.get('text', "") or ""
+ rt = bool(info.get('richtext', False))
+ row = factory.createHBox(vbox)
+
+ # Icon (warning)
+ try:
+ icon_align = factory.createTop(row)
+ icon = factory.createImage(icon_align, "dialog-warning")
+ icon.setStretchable(yui.YUIDimension.YD_VERT, False)
+ icon.setStretchable(yui.YUIDimension.YD_HORIZ, False)
+ icon.setAutoScale(False)
+ except Exception:
+ # If icon creation fails, continue without it
+ pass
+
+ # Text widget
+ tw = _create_message_text_widget(factory, row, text, rt)
+
+ # Ok button on the right
+ btns = factory.createHBox(vbox)
+ factory.createHStretch(btns)
+ ok_btn = factory.createPushButton(btns, _("&Ok"))
+ factory.createHStretch(btns)
+
+ # Event loop
+ while True:
+ ev = dlg.waitForEvent()
+ et = ev.eventType()
+ if et in (yui.YEventType.CancelEvent, yui.YEventType.TimeoutEvent):
+ break
+ if et == yui.YEventType.WidgetEvent and ev.widget() == ok_btn and ev.reason() == yui.YEventReason.Activated:
+ break
+
+ dlg.destroy()
+ return 1
+ finally:
+ if dlg is not None:
+ try:
+ dlg.destroy()
+ except Exception:
+ pass
+ _restore_app_title(old_title)
def infoMsgBox (info) :
'''
- This function creates an Info dialog and show the message
- passed as input.
+ This function creates an Info dialog and shows the message passed as input.
@param info: dictionary, information to be passed to the dialog.
title => dialog title
- text => string to be swhon into the dialog
+ text => string to be shown into the dialog
richtext => True if using rich text
+ size => Mapping | Sequence | None
+ Optional minimum-size hint. Accepted formats:
+ - mapping containing 'width'/'height' (or legacy 'column'/'lines')
+ - tuple/list where the first element is width and the second height
'''
- if (not info) :
+ if not info:
return 0
- retVal = 0
- yui.YUI.widgetFactory
- factory = yui.YExternalWidgets.externalWidgetFactory("mga")
- factory = yui.YMGAWidgetFactory.getYMGAWidgetFactory(factory)
- dlg = factory.createDialogBox(yui.YMGAMessageBox.B_ONE, yui.YMGAMessageBox.D_INFO)
-
- if ('title' in info.keys()) :
- dlg.setTitle(info['title'])
-
- rt = False
- if ("richtext" in info.keys()) :
- rt = info['richtext']
-
- if ('text' in info.keys()) :
- dlg.setText(info['text'], rt)
-
- dlg.setButtonLabel(_("&Ok"), yui.YMGAMessageBox.B_ONE )
-# dlg.setMinSize(50, 5)
-
- retVal = dlg.show()
dlg = None
-
- return 1
+ try:
+ factory = yui.YUI.widgetFactory()
+ dlg = factory.createPopupDialog()
+ title = info.get('title')
+ old_title = _push_app_title(title)
+
+ root_vbox = factory.createVBox(dlg)
+ content_parent = root_vbox
+ size_hint = _extract_dialog_size(info)
+ if size_hint:
+ try:
+ content_parent = factory.createMinSize(root_vbox, size_hint[0], size_hint[1])
+ except Exception:
+ content_parent = root_vbox
+ vbox = factory.createVBox(content_parent)
+
+ # Content row: icon + text
+ text = info.get('text', "") or ""
+ rt = bool(info.get('richtext', False))
+ row = factory.createHBox(vbox)
+
+ # Icon (information)
+ try:
+ icon_align = factory.createTop(row)
+ icon = factory.createImage(icon_align, "dialog-information")
+ icon.setStretchable(yui.YUIDimension.YD_VERT, False)
+ icon.setStretchable(yui.YUIDimension.YD_HORIZ, False)
+ icon.setAutoScale(False)
+ except Exception:
+ pass
+
+ # Text widget
+ tw = _create_message_text_widget(factory, row, text, rt)
+
+ # Ok button on the right
+ btns = factory.createHBox(vbox)
+ factory.createHStretch(btns)
+ ok_btn = factory.createPushButton(btns, _("&Ok"))
+ factory.createHStretch(btns)
+
+ # Event loop
+ while True:
+ ev = dlg.waitForEvent()
+ et = ev.eventType()
+ if et in (yui.YEventType.CancelEvent, yui.YEventType.TimeoutEvent):
+ break
+ if et == yui.YEventType.WidgetEvent and ev.widget() == ok_btn and ev.reason() == yui.YEventReason.Activated:
+ break
+
+ dlg.destroy()
+ return 1
+ finally:
+ if dlg is not None:
+ try:
+ dlg.destroy()
+ except Exception:
+ pass
+ _restore_app_title(old_title)
def msgBox (info) :
'''
- This function creates a dialog and show the message passed as input.
+ This function creates a dialog and shows the message passed as input.
@param info: dictionary, information to be passed to the dialog.
title => dialog title
- text => string to be swhon into the dialog
+ text => string to be shown into the dialog
richtext => True if using rich text
+ size => Mapping | Sequence | None
+ Optional minimum-size hint. Accepted formats:
+ - mapping containing 'width'/'height' (or legacy 'column'/'lines')
+ - tuple/list where the first element is width and the second height
'''
- if (not info) :
+ if not info:
return 0
- retVal = 0
- yui.YUI.widgetFactory
- factory = yui.YExternalWidgets.externalWidgetFactory("mga")
- factory = yui.YMGAWidgetFactory.getYMGAWidgetFactory(factory)
- dlg = factory.createDialogBox(yui.YMGAMessageBox.B_ONE)
-
- if ('title' in info.keys()) :
- dlg.setTitle(info['title'])
-
- rt = False
- if ("richtext" in info.keys()) :
- rt = info['richtext']
-
- if ('text' in info.keys()) :
- dlg.setText(info['text'], rt)
-
- dlg.setButtonLabel(_("&Ok"), yui.YMGAMessageBox.B_ONE )
-# dlg.setMinSize(50, 5)
-
- retVal = dlg.show()
dlg = None
-
- return 1
-
+ try:
+ factory = yui.YUI.widgetFactory()
+ dlg = factory.createPopupDialog()
+ title = info.get('title')
+ old_title = _push_app_title(title)
+ root_vbox = factory.createVBox(dlg)
+ content_parent = root_vbox
+ size_hint = _extract_dialog_size(info)
+ if size_hint:
+ try:
+ content_parent = factory.createMinSize(root_vbox, size_hint[0], size_hint[1])
+ except Exception:
+ content_parent = root_vbox
+ vbox = factory.createVBox(content_parent)
+
+ # Content row: text only (no icon)
+ text = info.get('text', "") or ""
+ rt = bool(info.get('richtext', False))
+ row = factory.createHBox(vbox)
+
+ # Text widget
+ tw = _create_message_text_widget(factory, row, text, rt)
+
+ # Ok button on the right
+ btns = factory.createHBox(vbox)
+ factory.createHStretch(btns)
+ ok_btn = factory.createPushButton(btns, _("&Ok"))
+ factory.createHStretch(btns)
+
+ # Event loop
+ while True:
+ ev = dlg.waitForEvent()
+ et = ev.eventType()
+ if et in (yui.YEventType.CancelEvent, yui.YEventType.TimeoutEvent):
+ break
+ if et == yui.YEventType.WidgetEvent and ev.widget() == ok_btn and ev.reason() == yui.YEventReason.Activated:
+ break
+
+ dlg.destroy()
+ return 1
+ finally:
+ if dlg is not None:
+ try:
+ dlg.destroy()
+ except Exception:
+ pass
+ _restore_app_title(old_title)
def askOkCancel (info) :
'''
@@ -157,6 +375,10 @@ def askOkCancel (info) :
text => string to be swhon into the dialog
richtext => True if using rich text
default_button => optional default button [1 => Ok - any other values => Cancel]
+ size => Mapping | Sequence | None
+ Optional minimum-size hint. Accepted formats:
+ - mapping containing 'width'/'height' (or legacy 'column'/'lines')
+ - tuple/list where the first element is width and the second height
@output:
False: Cancel button has been pressed
@@ -165,36 +387,74 @@ def askOkCancel (info) :
if (not info) :
return False
- retVal = False
- yui.YUI.widgetFactory
- factory = yui.YExternalWidgets.externalWidgetFactory("mga")
- factory = yui.YMGAWidgetFactory.getYMGAWidgetFactory(factory)
- dlg = factory.createDialogBox(yui.YMGAMessageBox.B_TWO)
-
- if ('title' in info.keys()) :
- dlg.setTitle(info['title'])
-
- rt = False
- if ("richtext" in info.keys()) :
- rt = info['richtext']
-
- if ('text' in info.keys()) :
- dlg.setText(info['text'], rt)
-
- dlg.setButtonLabel(_("&Ok"), yui.YMGAMessageBox.B_ONE )
- dlg.setButtonLabel(_("&Cancel"), yui.YMGAMessageBox.B_TWO )
-
- if ("default_button" in info.keys() and info["default_button"] == 1) :
- dlg.setDefaultButton(yui.YMGAMessageBox.B_ONE)
- else :
- dlg.setDefaultButton(yui.YMGAMessageBox.B_TWO)
-
- dlg.setMinSize(50, 5)
-
- retVal = dlg.show() == yui.YMGAMessageBox.B_ONE;
dlg = None
-
- return retVal
+ try:
+ factory = yui.YUI.widgetFactory()
+ dlg = factory.createPopupDialog()
+ title = info.get('title')
+ old_title = _push_app_title(title)
+
+ root_vbox = factory.createVBox(dlg)
+ content_parent = root_vbox
+ size_hint = _extract_dialog_size(info)
+ if size_hint:
+ try:
+ content_parent = factory.createMinSize(root_vbox, size_hint[0], size_hint[1])
+ except Exception:
+ content_parent = root_vbox
+ vbox = factory.createVBox(content_parent)
+
+ # Content row: icon + text
+ text = info.get('text', "") or ""
+ rt = bool(info.get('richtext', False))
+ row = factory.createHBox(vbox)
+
+ # Icon (information)
+ try:
+ icon_align = factory.createTop(row)
+ icon = factory.createImage(icon_align, "dialog-information")
+ icon.setStretchable(yui.YUIDimension.YD_VERT, False)
+ icon.setStretchable(yui.YUIDimension.YD_HORIZ, False)
+ icon.setAutoScale(False)
+ except Exception:
+ pass
+
+ # Text widget
+ tw = _create_message_text_widget(factory, row, text, rt)
+
+ # Buttons on the right
+ btns = factory.createHBox(vbox)
+ factory.createHStretch(btns)
+ ok_btn = factory.createPushButton(btns, _("&Ok"))
+ cancel_btn = factory.createPushButton(btns, _("&Cancel"))
+
+ default_ok = bool(info.get('default_button', 0) == 1)
+ dlg.setDefaultButton(ok_btn if default_ok else cancel_btn)
+ # simple default: ignore focusing specifics for now
+ result = False
+ while True:
+ ev = dlg.waitForEvent()
+ et = ev.eventType()
+ if et == yui.YEventType.CancelEvent:
+ result = False
+ break
+ if et == yui.YEventType.WidgetEvent:
+ w = ev.widget()
+ if w == ok_btn and ev.reason() == yui.YEventReason.Activated:
+ result = True
+ break
+ if w == cancel_btn and ev.reason() == yui.YEventReason.Activated:
+ result = False
+ break
+ dlg.destroy()
+ return result
+ finally:
+ if dlg is not None:
+ try:
+ dlg.destroy()
+ except Exception:
+ pass
+ _restore_app_title(old_title)
def askYesOrNo (info) :
'''
@@ -206,7 +466,10 @@ def askYesOrNo (info) :
text => string to be swhon into the dialog
richtext => True if using rich text
default_button => optional default button [1 => Yes - any other values => No]
- size => [row, coulmn]
+ size => Mapping | Sequence | None
+ Optional minimum-size hint. Accepted formats:
+ - mapping containing 'width'/'height' (or legacy 'column'/'lines')
+ - tuple/list where the first element is width and the second height
@output:
False: No button has been pressed
@@ -215,35 +478,73 @@ def askYesOrNo (info) :
if (not info) :
return False
- retVal = False
- yui.YUI.widgetFactory
- factory = yui.YExternalWidgets.externalWidgetFactory("mga")
- factory = yui.YMGAWidgetFactory.getYMGAWidgetFactory(factory)
- dlg = factory.createDialogBox(yui.YMGAMessageBox.B_TWO)
-
- if ('title' in info.keys()) :
- dlg.setTitle(info['title'])
-
- rt = False
- if ("richtext" in info.keys()) :
- rt = info['richtext']
-
- if ('text' in info.keys()) :
- dlg.setText(info['text'], rt)
-
- dlg.setButtonLabel(_("&Yes"), yui.YMGAMessageBox.B_ONE )
- dlg.setButtonLabel(_("&No"), yui.YMGAMessageBox.B_TWO )
- if ("default_button" in info.keys() and info["default_button"] == 1) :
- dlg.setDefaultButton(yui.YMGAMessageBox.B_ONE)
- else :
- dlg.setDefaultButton(yui.YMGAMessageBox.B_TWO)
- if ('size' in info.keys()) :
- dlg.setMinSize(info['size'][0], info['size'][1])
-
- retVal = dlg.show() == yui.YMGAMessageBox.B_ONE;
dlg = None
-
- return retVal
+ try:
+ factory = yui.YUI.widgetFactory()
+ dlg = factory.createPopupDialog()
+ title = info.get('title')
+ old_title = _push_app_title(title)
+ root_vbox = factory.createVBox(dlg)
+ content_parent = root_vbox
+ size_hint = _extract_dialog_size(info)
+ if size_hint:
+ try:
+ content_parent = factory.createMinSize(root_vbox, size_hint[0], size_hint[1])
+ except Exception:
+ content_parent = root_vbox
+ vbox = factory.createVBox(content_parent)
+
+ # Content row: icon + text
+ text = info.get('text', "") or ""
+ rt = bool(info.get('richtext', False))
+ row = factory.createHBox(vbox)
+
+ # Icon (question)
+ try:
+ icon_align = factory.createTop(row)
+ icon = factory.createImage(icon_align, "dialog-question")
+ icon.setStretchable(yui.YUIDimension.YD_VERT, False)
+ icon.setStretchable(yui.YUIDimension.YD_HORIZ, False)
+ icon.setAutoScale(False)
+ except Exception:
+ pass
+
+ # Text widget
+ tw = _create_message_text_widget(factory, row, text, rt)
+
+ # Buttons on the right
+ btns = factory.createHBox(vbox)
+ factory.createHStretch(btns)
+ yes_btn = factory.createPushButton(btns, _("&Yes"))
+ no_btn = factory.createPushButton(btns, _("&No"))
+
+ default_yes = bool(info.get('default_button', 0) == 1)
+ dlg.setDefaultButton(yes_btn if default_yes else no_btn)
+
+ result = False
+ while True:
+ ev = dlg.waitForEvent()
+ et = ev.eventType()
+ if et == yui.YEventType.CancelEvent:
+ result = False
+ break
+ if et == yui.YEventType.WidgetEvent:
+ w = ev.widget()
+ if w == yes_btn and ev.reason() == yui.YEventReason.Activated:
+ result = True
+ break
+ if w == no_btn and ev.reason() == yui.YEventReason.Activated:
+ result = False
+ break
+ dlg.destroy()
+ return result
+ finally:
+ if dlg is not None:
+ try:
+ dlg.destroy()
+ except Exception:
+ pass
+ _restore_app_title(old_title)
class AboutDialogMode(Enum):
'''
@@ -254,52 +555,287 @@ class AboutDialogMode(Enum):
CLASSIC = 1
TABBED = 2
-def AboutDialog (info) :
+def AboutDialog(info=None, *, dialog_mode: AboutDialogMode = AboutDialogMode.CLASSIC, size=None):
'''
About dialog implementation. AboutDialog can be used by
- modules, to show authors, license, credits, etc.
-
- @param info: dictionary, optional information to be passed to the dialog.
- name => the application name
- version => the application version
- license => the application license, the short length one (e.g. GPLv2, GPLv3, LGPLv2+, etc)
- authors => the string providing the list of authors; it could be html-formatted
- description => the string providing a brief description of the application
- logo => the string providing the file path for the application logo (high-res image)
- icon => the string providing the file path for the application icon (low-res image)
- credits => the application credits, they can be html-formatted
- information => other extra informations, they can be html-formatted
- size => libyui dialog minimum size, dictionary containing {column, lines}
- dialog_mode => AboutDialogMode.CLASSIC: classic style dialog, any other as tabbed style dialog
+ modules to show authors, license, credits, etc.
+
+ Parameters
+ ----------
+ info: dict | None
+ Deprecated dictionary that historically provided dialog metadata.
+ Present values still override backend metadata, but callers should
+ prefer configuring fields via the application backend. When provided
+ a DeprecationWarning is emitted.
+ dialog_mode: AboutDialogMode
+ Target layout style (classic/tabbed). Defaults to CLASSIC.
+ size: Mapping | Sequence | None
+ Optional minimum-size hint. Accepted formats:
+ - mapping containing 'width'/'height' (or legacy 'column'/'lines')
+ - tuple/list where the first element is width and the second height
'''
- if (not info) :
- raise ValueError("Missing AboutDialog parameters")
-
- yui.YUI.widgetFactory
- factory = yui.YExternalWidgets.externalWidgetFactory("mga")
- factory = yui.YMGAWidgetFactory.getYMGAWidgetFactory(factory)
-
- name = info['name'] if 'name' in info.keys() else ""
- version = info['version'] if 'version' in info.keys() else ""
- license = info['license'] if 'license' in info.keys() else ""
- authors = info['authors'] if 'authors' in info.keys() else ""
- description = info['description'] if 'description' in info.keys() else ""
- logo = info['logo'] if 'logo' in info.keys() else ""
- icon = info['icon'] if 'icon' in info.keys() else ""
- credits = info['credits'] if 'credits' in info.keys() else ""
- information = info['information'] if 'information' in info.keys() else ""
- dialog_mode = yui.YMGAAboutDialog.TABBED
- if 'dialog_mode' in info.keys() :
- dialog_mode = yui.YMGAAboutDialog.CLASSIC if info['dialog_mode'] == AboutDialogMode.CLASSIC else yui.YMGAAboutDialog.TABBED
-
- dlg = factory.createAboutDialog(name, version, license,
- authors, description, logo,
- icon, credits, information
- )
- if 'size' in info.keys():
- if not 'column' in info['size'] or not 'lines' in info['size'] :
- raise ValueError("size must contains <> and <> keys")
- dlg.setMinSize(info['size']['column'], info['size']['lines'])
-
- dlg.show(dialog_mode)
+
+ safe_info = {}
+ if info is not None:
+ if not isinstance(info, dict):
+ raise TypeError("info must be a dict when provided")
+ safe_info = dict(info)
+ warnings.warn(
+ "AboutDialog 'info' parameter is deprecated; configure metadata via the application backend.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
+ logger.warning("AboutDialog 'info' parameter is deprecated; prefer backend metadata.")
+
+ app = None
+ try:
+ app = yui.YUI.app()
+ except Exception as exc:
+ logger.debug("Unable to obtain YUI application instance: %s", exc)
+
+ def _fetch_value(info_key, app_attr, fallback=""):
+ if safe_info.get(info_key):
+ return safe_info.get(info_key)
+ if app is None:
+ return fallback
+ value = None
+ attr = getattr(app, app_attr, None)
+ try:
+ value = attr() if callable(attr) else attr
+ except Exception as exc:
+ logger.debug("Unable to fetch %s via %s: %s", info_key, app_attr, exc)
+ value = None
+ if (not value) and info_key == 'name':
+ try:
+ value = app.productName()
+ except Exception:
+ pass
+ return value or fallback
+
+ name = _fetch_value('name', 'applicationName')
+ version = _fetch_value('version', 'version')
+ license_txt = _fetch_value('license', 'license')
+ authors = _fetch_value('authors', 'authors')
+ description = _fetch_value('description', 'description')
+ logo = _fetch_value('logo', 'logo')
+ credits = _fetch_value('credits', 'credits')
+ information = _fetch_value('information', 'information')
+
+ legacy_mode = safe_info.get('dialog_mode') if safe_info else None
+ effective_mode = dialog_mode or AboutDialogMode.CLASSIC
+ if legacy_mode is not None:
+ if isinstance(legacy_mode, AboutDialogMode):
+ effective_mode = legacy_mode
+ else:
+ try:
+ effective_mode = AboutDialogMode[str(legacy_mode)]
+ except Exception:
+ logger.debug("Unsupported legacy dialog_mode value: %s", legacy_mode)
+
+ def _normalize_size(size_spec):
+ width = 320
+ height = 240
+ if size_spec is None and safe_info.get('size'):
+ size_spec = safe_info.get('size')
+ if isinstance(size_spec, dict):
+ width = size_spec.get('width', width)
+ if 'width' not in size_spec:
+ if 'column' in size_spec:
+ width = size_spec['column']
+ elif 'columns' in size_spec:
+ width = size_spec['columns']
+ height = size_spec.get('height', height)
+ if 'height' not in size_spec:
+ if 'lines' in size_spec:
+ height = size_spec['lines']
+ elif 'rows' in size_spec:
+ height = size_spec['rows']
+ elif isinstance(size_spec, (list, tuple)):
+ if len(size_spec) > 0 and size_spec[0] is not None:
+ width = size_spec[0]
+ if len(size_spec) > 1 and size_spec[1] is not None:
+ height = size_spec[1]
+ elif size_spec is not None:
+ logger.debug("Unsupported size specification type: %s", type(size_spec))
+ try:
+ width = int(width)
+ height = int(height)
+ except Exception:
+ width, height = 320, 240
+ return width, height
+
dlg = None
+ try:
+ factory = yui.YUI.widgetFactory()
+ dlg = factory.createPopupDialog()
+ title = _("About") + (" " + name if name else "")
+ old_title = _push_app_title(title)
+ root_vbox = factory.createVBox(dlg)
+
+ # Optional MinSize wrapper (accepts {'width','height'} in pixels)
+ content_parent = root_vbox
+ width_hint, height_hint = _normalize_size(size if size is not None else None)
+ if width_hint > 0 and height_hint > 0:
+ try:
+ content_parent = factory.createMinSize(root_vbox, int(width_hint), int(height_hint))
+ except Exception as exc:
+ logger.debug("Unable to apply size hint (%s, %s): %s", width_hint, height_hint, exc)
+
+ vbox = factory.createVBox(content_parent)
+
+ logger.debug(
+ "Opening AboutDialog name='%s' mode=%s",
+ name or "",
+ getattr(effective_mode, "name", effective_mode),
+ )
+
+ # Header block (logo + labels)
+ header = factory.createHBox(vbox)
+ if logo:
+ try:
+ factory.createImage(header, logo)
+ factory.createSpacing(header, 8)
+ except Exception as exc:
+ logger.debug("Unable to load logo '%s': %s", logo, exc)
+ labels = factory.createVBox(header)
+ if name:
+ factory.createLabel(labels, name)
+ if version:
+ factory.createLabel(labels, version)
+ if license_txt:
+ factory.createLabel(labels, license_txt)
+
+ # Helper to add a RichText block
+ def _add_richtext(parent, value):
+ rt = factory.createRichText(parent, "", False)
+ rt.setValue(value)
+ try:
+ rt.setStretchable(YUIDimension.YD_HORIZ, True)
+ rt.setStretchable(YUIDimension.YD_VERT, True)
+ except Exception:
+ pass
+ return rt
+
+ info_btn = None
+ credits_btn = None
+ tab_widget = None
+ tab_content_updater = None
+
+ tab_sections = []
+ if description:
+ tab_sections.append((_('Description'), description))
+ if authors:
+ tab_sections.append((_('Authors'), authors))
+ if information:
+ tab_sections.append((_('Information'), information))
+ if credits:
+ tab_sections.append((_('Credits'), credits))
+
+ use_tabbed = (effective_mode == AboutDialogMode.TABBED)
+ tab_text_widget = None
+
+ if use_tabbed:
+ if not tab_sections:
+ logger.debug("Tabbed mode requested but there are no sections; falling back to classic mode.")
+ use_tabbed = False
+ else:
+ try:
+ tabs_box = factory.createVBox(vbox)
+ tab_widget = factory.createDumbTab(tabs_box)
+ tab_widget.setNotify(True)
+ content_holder = factory.createReplacePoint(tabs_box)
+ tab_text_widget = _add_richtext(content_holder, "")
+ try:
+ content_holder.showChild()
+ except Exception as exc:
+ logger.debug("Unable to show tab content immediately: %s", exc)
+ added_items = []
+ for section_title, section_value in tab_sections:
+ item = YItem(section_title)
+ item.setData(section_value)
+ tab_widget.addItem(item)
+ added_items.append(item)
+ if not added_items:
+ logger.debug("No tab items added; reverting to classic mode.")
+ use_tabbed = False
+ tab_widget = None
+ else:
+ try:
+ tab_widget.selectItem(added_items[0], True)
+ except Exception as exc:
+ logger.debug("Unable to preselect first tab: %s", exc)
+
+ def _update_tab_content():
+ current_item = tab_widget.selectedItem()
+ payload = ""
+ if current_item is not None:
+ payload = current_item.data() or ""
+ if tab_text_widget is not None:
+ tab_text_widget.setValue(payload)
+
+ _update_tab_content()
+ tab_content_updater = _update_tab_content
+ except Exception as exc:
+ logger.exception("Failed to initialize tabbed AboutDialog: %s", exc)
+ use_tabbed = False
+ tab_widget = None
+ tab_content_updater = None
+
+ if not use_tabbed:
+ if description:
+ _add_richtext(vbox, description)
+ if authors:
+ factory.createHeading(vbox, _("Authors"))
+ _add_richtext(vbox, authors)
+
+ if information or credits:
+ button_row = factory.createHBox(vbox)
+ if information:
+ info_btn = factory.createPushButton(button_row, _("&Info"))
+ if credits:
+ credits_btn = factory.createPushButton(button_row, _("&Credits"))
+ else:
+ button_row = None
+
+ # Close button aligned to the right, as in the C++ dialog
+ close_row = factory.createHBox(vbox)
+ factory.createHStretch(close_row)
+ close_btn = factory.createPushButton(close_row, _("&Close"))
+
+ while True:
+ ev = dlg.waitForEvent()
+ if not ev:
+ continue
+ et = ev.eventType()
+ if et in (yui.YEventType.CancelEvent, yui.YEventType.TimeoutEvent):
+ logger.debug("AboutDialog closing due to event type %s", et)
+ break
+ if et != yui.YEventType.WidgetEvent:
+ continue
+ widget = ev.widget()
+ if widget == close_btn:
+ logger.debug("AboutDialog close button activated")
+ break
+ if tab_widget and widget == tab_widget:
+ if tab_content_updater:
+ tab_content_updater()
+ continue
+ if info_btn and widget == info_btn:
+ logger.debug("AboutDialog information button activated")
+ msgBox({"title": _("Information"), "text": information or "", "richtext": True})
+ continue
+ if credits_btn and widget == credits_btn:
+ logger.debug("AboutDialog credits button activated")
+ msgBox({"title": _("Credits"), "text": credits or "", "richtext": True})
+ continue
+
+ logger.debug("Unhandled widget event from %s", getattr(widget, 'widgetClass', lambda: 'unknown')())
+
+ dlg.destroy()
+ finally:
+ if dlg is not None:
+ try:
+ dlg.destroy()
+ except Exception:
+ pass
+ _restore_app_title(old_title)
diff --git a/manatools/ui/helpdialog.py b/manatools/ui/helpdialog.py
index d6f23e5..2632a91 100644
--- a/manatools/ui/helpdialog.py
+++ b/manatools/ui/helpdialog.py
@@ -10,11 +10,12 @@
@package manatools.ui.helpdialog
'''
+import logging
import webbrowser
-import manatools.ui.basedialog as basedialog
-import manatools.basehelpinfo as helpdata
-import yui
+from . import basedialog as basedialog
+from .. import basehelpinfo as helpdata
+from ..aui import yui as yui
import gettext
# https://pymotw.com/3/gettext/#module-localization
t = gettext.translation(
@@ -25,42 +26,93 @@
_ = t.gettext
ngettext = t.ngettext
+logger = logging.getLogger("manatools.ui.helpdialog")
+
class HelpDialog(basedialog.BaseDialog):
- def __init__(self, info, title=_("Help dialog"), icon="", minWidth=80, minHeight=20):
- basedialog.BaseDialog.__init__(self, title, icon, basedialog.DialogType.POPUP, minWidth, minHeight)
+ """Simple rich-text help browser dialog with internal navigation."""
+ def __init__(self, info, title=_("Help dialog"), icon="", minWidth=320, minHeight=200):
+ self._minWidthHint = self._normalize_dimension(minWidth)
+ self._minHeightHint = self._normalize_dimension(minHeight)
+ basedialog.BaseDialog.__init__(self, title, icon, basedialog.DialogType.POPUP, -1, -1)
'''
HelpDialog constructor
@param title dialog title
@param icon dialog icon
- @param minWidth > 0 mim width size, see libYui createMinSize
- @param minHeight > 0 mim height size, see libYui createMinSize
+ @param minWidth > 0 mim width size in pixels
+ @param minHeight > 0 mim height size in pixels
'''
if not isinstance(info, helpdata.HelpInfoBase):
raise TypeError("info must be a HelpInfoBase instance")
self.info = info
+ logger.debug(
+ "HelpDialog initialized title=%s icon=%s minWidth=%s minHeight=%s",
+ title,
+ icon,
+ self._minWidthHint,
+ self._minHeightHint,
+ )
+
+ @staticmethod
+ def _normalize_dimension(value):
+ """Return positive integer dimension or 0 if invalid."""
+ try:
+ dimension = int(value)
+ except (TypeError, ValueError):
+ return 0
+ return dimension if dimension > 0 else 0
def UIlayout(self, layout):
'''
layout implementation called in base class to setup UI
'''
- # URL events are sent as YMenuEvent by libyui
+ # URL events may be sent as MenuEvent by backends that support it
self.eventManager.addMenuEvent(None, self.onURLEvent, False)
- self.text = self.factory.createRichText(layout, "", False)
+ content_parent = layout
+ if self._minWidthHint and self._minHeightHint:
+ try:
+ min_container = self.factory.createMinSize(layout, self._minWidthHint, self._minHeightHint)
+ content_parent = self.factory.createVBox(min_container)
+ logger.debug(
+ "Applied min-size container (%s x %s) to HelpDialog",
+ self._minWidthHint,
+ self._minHeightHint,
+ )
+ except Exception as exc:
+ content_parent = layout
+ logger.debug("Unable to apply min-size hint: %s", exc)
+ self.text = self.factory.createRichText(content_parent, "", False)
+ self.text.setStretchable(yui.YUIDimension.YD_HORIZ, True)
+ self.text.setStretchable(yui.YUIDimension.YD_VERT, True)
+ self.text.setWeight(yui.YUIDimension.YD_HORIZ, 1)
+ self.text.setWeight(yui.YUIDimension.YD_VERT, 1)
self.text.setValue(self.info.home())
- align = self.factory.createRight(layout)
- self.quitButton = self.factory.createPushButton(align, _("&Quit"))
+ logger.debug("Initial help content loaded")
+
+ button_row = self.factory.createHBox(content_parent)
+ button_row.setStretchable(yui.YUIDimension.YD_HORIZ, True)
+ button_row.setWeight(yui.YUIDimension.YD_HORIZ, 1)
+ right_align = self.factory.createRight(button_row)
+ self.quitButton = self.factory.createPushButton(right_align, _("Quit"))
self.eventManager.addWidgetEvent(self.quitButton, self.onQuitEvent)
+ logger.debug("Quit button registered")
def onQuitEvent(self) :
+ """Handle Quit button activation and terminate the dialog loop."""
# BaseDialog needs to force to exit the handle event loop
+ logger.debug("Quit event triggered")
self.ExitLoop()
def onURLEvent(self, mEvent):
+ """Handle rich text URL activations and navigate or open links."""
url = mEvent.id()
if url:
text = self.info.show(url)
if text:
self.text.setValue(text)
+ logger.debug("Help content switched to %s", url)
else:
- print("onURLEvent: running webbrowser", url)
- webbrowser.open(url, 2)
+ logger.debug("Opening external URL %s", url)
+ try:
+ webbrowser.open(url, 2)
+ except Exception as exc:
+ logger.error("Failed to open URL %s: %s", url, exc)
diff --git a/manatools/version.py b/manatools/version.py
index 3a03297..a35dcde 100644
--- a/manatools/version.py
+++ b/manatools/version.py
@@ -10,4 +10,4 @@
__project_name__ = "python-manatools"
''' project version '''
-__project_version__ = "0.0.4"
+__project_version__ = "0.99.0"
diff --git a/setup.py b/setup.py
index 032d205..3681317 100644
--- a/setup.py
+++ b/setup.py
@@ -1,31 +1,44 @@
#!/usr/bin/env python3
from setuptools import setup
-exec(open('manatools/version.py').read())
+import sys
+import io
+import os
-try:
- import yui
-except ImportError:
- import sys
- print('Please install python3-yui in order to install this package',
- file=sys.stderr)
- sys.exit(1)
+# load version variables from manatools/version.py
+_version_file = os.path.join(os.path.dirname(__file__), "manatools", "version.py")
+with io.open(_version_file, "r", encoding="utf-8") as vf:
+ exec(vf.read())
+# Read long description if README.md exists
+_long_description = ""
+_readme_path = os.path.join(os.path.dirname(__file__), "README.md")
+if os.path.exists(_readme_path):
+ with io.open(_readme_path, "r", encoding="utf-8") as rf:
+ _long_description = rf.read()
setup(
- name=__project_name__,
- version=__project_version__,
- author='Angelo Naselli',
- author_email='anaselli@linux.it',
- packages=['manatools', 'manatools.ui'],
- #scripts=['scripts/'],
- license='LGPLv2+',
- description='Python ManaTools framework.',
- long_description=open('README.md').read(),
- #data_files=[('conf/manatools', ['XXX.yy',]), ],
- install_requires=[
- "dbus-python",
- "python-gettext",
- "PyYAML",
- ],
+ name=__project_name__,
+ version=__project_version__,
+ author='Angelo Naselli',
+ author_email='anaselli@linux.it',
+ packages=[
+ 'manatools',
+ 'manatools.aui',
+ 'manatools.aui.backends',
+ 'manatools.aui.backends.qt',
+ 'manatools.aui.backends.gtk',
+ 'manatools.aui.backends.curses',
+ 'manatools.ui'
+ ],
+ include_package_data=True,
+ license='LGPLv2+',
+ description='Python ManaTools framework.',
+ long_description=_long_description,
+ long_description_content_type="text/markdown",
+ install_requires=[
+ "dbus-python",
+ "python-gettext",
+ "PyYAML",
+ ],
)
diff --git a/share/images/manatools.png b/share/images/manatools.png
new file mode 100644
index 0000000..97492c9
Binary files /dev/null and b/share/images/manatools.png differ
diff --git a/share/images/manatools.svg b/share/images/manatools.svg
new file mode 100644
index 0000000..3575b4f
--- /dev/null
+++ b/share/images/manatools.svg
@@ -0,0 +1,75 @@
+
+
+
diff --git a/sow/TODO.md b/sow/TODO.md
new file mode 100644
index 0000000..6ee5cc3
--- /dev/null
+++ b/sow/TODO.md
@@ -0,0 +1,100 @@
+The goal of this branch is to explore creating a pure-Python implementation of the libyui binding interface.
+
+While libyui has been a valuable dependency for this project for many years (and continues to be in other branches), this move aims to remove the hard dependency. We hope this will provide a more manageable backend and facilitate a smoother transition for related tools, **especially for dnfdragora**.
+
+As this is a non-profit project, we rely on developers who are contributing in their **very limited spare time**. To accelerate this effort, we are leveraging AI to assist with a significant portion of the development work.
+
+Next is the starting todo list.
+
+Missing Widgets comparing libyui original factory:
+
+ [X] YComboBox
+ [X] YSelectionBox
+ [X] YMultiSelectionBox (implemented as YSelectionBox + multiselection enabled)
+ [X] YPushButton
+ [X] YLabel
+ [X] YInputField
+ [X] YCheckBox
+ [X] YTree
+ [X] YFrame
+ [X] YTable (merging YMGACBTable)
+ [X] YProgressBar
+ [X] YRichText
+ [X] YMultiLineEdit
+ [X] YIntField
+ [X] YMenuBar
+ [X] YSpacing (detailed variants: createHStretch/createVStretch/createHSpacing/createVSpacing/createSpacing)
+ [X] YAlignment helpers (createLeft/createRight/createTop/createBottom/createHCenter/createVCenter/createHVCenter)
+ [X] YReplacePoint
+ [X] YRadioButton
+ [X] YImage
+ [X] YBusyIndicator
+ [X] YLogView
+
+Optional/special widgets (from `YOptionalWidgetFactory`):
+
+ [X] YDumbTab
+ [X] YSlider
+ [X] YDateField
+ [X] YTimeField
+
+To check/review:
+ how to manage YEvents [X] and YItems [X] (verify selection attirbute).
+
+ [X] YInputField password mode
+ [X] askForExistingDirectory
+ [X] askForExistingFile
+ [X] askForSaveFileName
+ [X] YAboutDialog (aka YMGAAboutDialog) - Implemented in manatools.ui
+ [X] adding factory create alternative methods (e.g. createMultiSelectionBox)
+ [X] managing shortcuts (only menu and pushbutton)
+ [ ] YTable sorting on columns management
+ [ ] localization
+
+Nice to have: improvements outside YUI API
+
+ [X] window title
+ [X] window icons
+ [ ] window title at window/dialog level
+ [ ] Context menu support
+ [ ] selected YItem(s) in event
+ [ ] Improving YEvents management (adding info on widget event containing data
+ such as item selection/s, checked item, rich text url, etc.)
+
+Skipped widgets:
+
+ [-] YPackageSelector (not ported)
+ [-] YRadioButtonGroup (not ported)
+ [-] YWizard (not ported)
+ [-] YItemSelector (not ported)
+ [-] YEmpty (not ported)
+ [-] YSquash / createSquash (not ported)
+ [-] YMenuButton (legacy menus)
+ [-] YBarGraph
+ [-] YPatternSelector (createPatternSelector)
+ [-] YSimplePatchSelector (createSimplePatchSelector)
+ [-] YMultiProgressMeter
+ [-] YPartitionSplitter
+ [-] YDownloadProgress
+ [-] YDummySpecialWidget
+ [-] YTimezoneSelector
+ [-] YGraph
+ [-] Context menu support / hasContextMenu
+
+Documentation gaps and recommendations
+--------------------------------------
+During review of backend implementations, several simple accessors and setters lack explicit docstrings. To improve developer experience:
+
+1. Add concise docstrings to all public getters/setters in:
+ - yui_qt.py (e.g., iconBasePath, setIconBasePath, productName)
+ - yui_gtk.py (same setters/getters, note GTK version dependencies)
+ - yui_curses.py (document NCurses-specific behaviors and filter parsing)
+2. Document file chooser semantics and supported filter syntax for NCurses and GTK fallbacks.
+3. Add a short README or reference page (this file) in the repository to explain expected cross-backend behavior and caveats.
+
+Checklist for maintainers
+-------------------------
+- [ ] Add one-line docstrings to all public methods in YApplication* classes.
+- [ ] Document minimum runtime dependencies and GTK/Qt version notes.
+- [ ] Provide examples for common tasks (file chooser, setting icon/title) in README or docs.
+- [ ] Ensure unit tests or integration tests cover file chooser fallbacks.
diff --git a/test/testCommon.py b/test/testCommon.py
index 568b309..82289e0 100644
--- a/test/testCommon.py
+++ b/test/testCommon.py
@@ -11,12 +11,21 @@
@package manatools
'''
+import os
+import sys
+import time
+import gettext
+
+# Prefer using the local workspace package when running this test directly
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+
import manatools.ui.basedialog as basedialog
import manatools.ui.common as common
import manatools.version as manatools
-import yui
-import time
-import gettext
+from manatools.aui import yui
+# allow running from repo root
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+import logging
######################################################################
##
@@ -27,34 +36,99 @@
class TestDialog(basedialog.BaseDialog):
def __init__(self):
- basedialog.BaseDialog.__init__(self, "Test dialog", "", basedialog.DialogType.POPUP, 80, 10)
+ basedialog.BaseDialog.__init__(self, "Test dialog", "", basedialog.DialogType.POPUP, 320, 200)
+ # Configure file logger for this test: write DEBUG logs to '.log' in cwd
+ try:
+ log_name = os.path.splitext(os.path.basename(__file__))[0] + '.log'
+ fh = logging.FileHandler(log_name, mode='w')
+ fh.setLevel(logging.DEBUG)
+ fh.setFormatter(logging.Formatter('%(asctime)s %(name)s %(levelname)s: %(message)s'))
+ self._logger = logging.getLogger()
+ self._logger.setLevel(logging.DEBUG)
+ existing = False
+ for h in list(self._logger.handlers):
+ try:
+ if isinstance(h, logging.FileHandler) and os.path.abspath(getattr(h, 'baseFilename', '')) == os.path.abspath(log_name):
+ existing = True
+ break
+ except Exception:
+ pass
+ if not existing:
+ self._logger.addHandler(fh)
+ print(f"Logging test output to: {os.path.abspath(log_name)}")
+ except Exception as _e:
+ print(f"Failed to configure file logger: {_e}")
+
+ self._tabbed_information = "Tabbed About dialog additional information"
+ self._about_dialog_size = (320, 240)
+ self._about_metadata = {
+ 'setApplicationName': "Test Dialog",
+ 'setVersion': manatools.__project_version__,
+ 'setAuthors': 'Angelo Naselli <anaselli@linux.it>
Author 2
Author 3
Author 4
Author 5',
+ 'setDescription': "Manatools Test Dialog example",
+ 'setLicense': 'GPLv2',
+ 'setCredits': "Copyright (C) 2014-2026 Angelo Naselli",
+ 'setLogo': 'manatools',
+ 'setInformation': "Classic About dialog additional information",
+ }
+ self._apply_about_metadata()
+
+ def _apply_about_metadata(self, **overrides):
+ '''
+ Push application metadata to the active YUI backend so AboutDialog
+ retrieves consistent information regardless of the selected backend.
+ '''
+ payload = dict(self._about_metadata)
+ for setter_name, value in overrides.items():
+ if value is not None:
+ payload[setter_name] = value
+
+ try:
+ app = yui.YUI.app()
+ except Exception as exc:
+ logging.getLogger(__name__).debug("Unable to reach YUI app: %s", exc)
+ return
+
+ for setter_name, value in payload.items():
+ setter = getattr(app, setter_name, None)
+ if not callable(setter):
+ continue
+ try:
+ setter(value)
+ except Exception as exc:
+ logging.getLogger(__name__).debug("Failed to apply %s: %s", setter_name, exc)
+
def UIlayout(self, layout):
'''
layout implementation called in base class to setup UI
'''
- # Let's test a Menu widget
- hbox = self.factory.createHBox(layout)
- menu = self.factory.createMenuButton(self.factory.createLeft(hbox), "&File")
- qm = yui.YMenuItem("&Quit")
- menu.addItem(qm)
- menu.rebuildMenuTree()
+ # Menu bar at the very top (attach directly to the main vertical layout)
+ menubar = self.factory.createMenuBar(layout)
+ file_menu = menubar.addMenu("&File")
+ qm = menubar.addItem(file_menu, "&Quit")
self.eventManager.addMenuEvent(qm, self.onQuitEvent)
- menu = self.factory.createMenuButton(self.factory.createRight(hbox), "&Help")
- about = yui.YMenuItem("&About")
- menu.addItem(about)
- menu.rebuildMenuTree()
+ help_menu = menubar.addMenu("&Help")
+ about = menubar.addItem(help_menu, "&About")
self.eventManager.addMenuEvent(about, self.onAbout)
- #let's test some buttons
- hbox = self.factory.createHBox(layout)
- self.pressButton = self.factory.createPushButton(hbox, "&Warning")
- self.eventManager.addWidgetEvent(self.pressButton, self.onPressWarning)
+ # Let's test some buttons (inside the content area)
+ self.factory.createVStretch(layout)
+ hbox = self.factory.createHBox(layout)
+ self.warnButton = self.factory.createPushButton(hbox, "&Warning")
+ self.eventManager.addWidgetEvent(self.warnButton, self.onPressWarning)
+ self.infoButton = self.factory.createPushButton(hbox, "&Information")
+ self.eventManager.addWidgetEvent(self.infoButton, self.onPressInformation)
+ self.OkCancelButton = self.factory.createPushButton(hbox, "&Ok/Cancel Dialog")
+ self.eventManager.addWidgetEvent(self.OkCancelButton, self.onPressOkCancel)
+ self.factory.createVStretch(layout)
+ hbox = self.factory.createHBox(layout)
+ align = self.factory.createRight(hbox)
# Let's test a quitbutton (same handle as Quit menu)
- self.quitButton = self.factory.createPushButton(layout, "&Quit")
+ self.quitButton = self.factory.createPushButton(align, "&Quit")
self.eventManager.addWidgetEvent(self.quitButton, self.onQuitEvent)
# Let's test a cancel event
@@ -65,35 +139,45 @@ def onAbout(self):
About menu call back
'''
yes = common.askYesOrNo({"title": "Choose About dialog mode", "text": "Do you want a tabbed About dialog?
Yes means Tabbed, No Classic", "richtext" : True, 'default_button': 1 })
- if yes:
- common.AboutDialog({ 'name' : "Test Tabbed About Dialog",
- 'dialog_mode' : common.AboutDialogMode.TABBED,
- 'version' : manatools.__project_version__,
- 'credits' :"Copyright (C) 2014-2017 Angelo Naselli",
- 'license' : 'GPLv2',
- 'authors' : 'Angelo Naselli <anaselli@linux.it>',
- 'information' : "Tabbed About dialog additional information",
- 'description' : "Project description here",
- 'size': {'column': 50, 'lines': 6},
- })
- else :
- common.AboutDialog({ 'name' : "Test Classic About Dialog",
- 'dialog_mode' : common.AboutDialogMode.CLASSIC,
- 'version' : manatools.__project_version__,
- 'credits' :"Copyright (C) 2014-2017 Angelo Naselli",
- 'license' : 'GPLv2',
- 'authors' : 'Angelo Naselli <anaselli@linux.it>',
- 'information' : "Classic About dialog additional information",
- 'description' : "Project description here",
- 'size': {'column': 50, 'lines': 5},
- })
+ selected_mode = common.AboutDialogMode.TABBED if yes else common.AboutDialogMode.CLASSIC
+ info_text = self._tabbed_information if yes else self._about_metadata.get('setInformation', "")
+ self._apply_about_metadata(setInformation=info_text)
+ common.AboutDialog(dialog_mode=selected_mode, size=self._about_dialog_size)
def onPressWarning(self) :
'''
Warning button call back
'''
- print ('Button "Press" pressed')
- wd = common.warningMsgBox({"title" : "Warning Dialog", "text": "Warning button has been pressed!", "richtext" : True})
+ print ('Button "Warning" pressed')
+ wd = common.warningMsgBox({
+ "title" : "Warning Dialog",
+ "text": "Warning button has been pressed!",
+ "size": (300, 120),
+ "richtext" : True})
+
+ def onPressInformation(self) :
+ '''
+ Information button call back
+ '''
+ print ('Button "Information" pressed')
+ id = common.infoMsgBox({
+ "title" : "Information Dialog",
+ "text": "Information button has been pressed!",
+ "size": (300, 120),
+ "richtext" : True})
+
+ def onPressOkCancel(self) :
+ '''
+ Ok/Cancel button call back
+ '''
+ print ('Button "Ok/Cancel Dialog" pressed')
+ ok = common.askOkCancel({
+ "title": "Ok/Cancel Dialog",
+ "text": "To proceed, click OK or Cancel to skip.",
+ "size": (300, 120),
+ "richtext" : True
+ })
+ print ("User selected: %s" % ("OK" if ok else "Cancel"))
def onCancelEvent(self) :
print ("Got a cancel event")
@@ -102,19 +186,27 @@ def onQuitEvent(self) :
'''
Quit button call back
'''
- ok = common.askYesOrNo({"title": "Quit confirmation", "text": "Do you really want to quit?", "richtext" : True })
+ ok = common.askYesOrNo({
+ "title": "Quit confirmation",
+ "text": "Do you really want to quit?",
+ "size": (300, 120),
+ "richtext" : True })
print ("Quit button pressed")
# BaseDialog needs to force to exit the handle event loop
if ok:
self.ExitLoop()
if __name__ == '__main__':
-
+ # Allow selecting backend via argv: e.g. `python3 test/testCommon.py gtk`
+ if len(sys.argv) > 1:
+ backend = sys.argv[1].lower()
+ os.environ['YUI_BACKEND'] = backend
+
gettext.install('manatools', localedir='/usr/share/locale', names=('ngettext',))
-
+
td = TestDialog()
td.run()
common.destroyUI()
-
-
+
+
diff --git a/test/testDialog.py b/test/testDialog.py
index 8f6c74f..f2720d5 100644
--- a/test/testDialog.py
+++ b/test/testDialog.py
@@ -11,8 +11,12 @@
@package manatools
'''
+import os
+import sys
+# Prefer using the local workspace package when running this test directly
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
import manatools.ui.basedialog as basedialog
-import yui
+from manatools.aui import yui
import time
######################################################################
@@ -41,46 +45,49 @@ def newFunc(*args, **kwargs):
class TestDialog(basedialog.BaseDialog):
def __init__(self):
- basedialog.BaseDialog.__init__(self, "Test dialog", "", basedialog.DialogType.POPUP, 80, 10)
+ basedialog.BaseDialog.__init__(self, "Test dialog", "", basedialog.DialogType.POPUP, 320, 200)
def UIlayout(self, layout):
'''
layout implementation called in base class to setup UI
'''
- # Let's test a Menu widget
- menu = self.factory.createMenuButton(self.factory.createLeft(layout), "Test &menu")
- tm1 = yui.YMenuItem("menu item 1")
- tm2 = yui.YMenuItem("menu item 2")
- qm = yui.YMenuItem("&Quit")
- menu.addItem(tm1)
- menu.addItem(tm2)
- menu.addItem(qm)
- menu.rebuildMenuTree()
- sendObjOnEvent=True
+ # Menu bar (top-level menubar)
+ menubar = self.factory.createMenuBar(layout)
+ top_menu = menubar.addMenu("Test menu")
+ tm1 = menubar.addItem(top_menu, "menu item 1")
+ tm2 = menubar.addItem(top_menu, "menu item 2")
+ qm = menubar.addItem(top_menu, "Quit")
+ # Ensure event handlers receive the menu item object
+ sendObjOnEvent = True
self.eventManager.addMenuEvent(tm1, self.onMenuItem, sendObjOnEvent)
self.eventManager.addMenuEvent(tm2, self.onMenuItem, sendObjOnEvent)
self.eventManager.addMenuEvent(qm, self.onQuitEvent, sendObjOnEvent)
#let's test some buttons
hbox = self.factory.createHBox(layout)
- self.pressButton = self.factory.createPushButton(hbox, "&Press")
+ self.pressButton = self.factory.createPushButton(hbox, "Press")
self.eventManager.addWidgetEvent(self.pressButton, self.onPressButton)
#Let's enable a time out on events
- self.timeoutButton = self.factory.createPushButton(hbox, "&Test timeout")
+ self.timeoutButton = self.factory.createPushButton(hbox, "Test timeout")
self.eventManager.addWidgetEvent(self.timeoutButton, self.onTimeOutButtonEvent)
self.eventManager.addTimeOutEvent(self.onTimeOutEvent)
+ self.factory.createVStretch(layout)
+ align = self.factory.createHVCenter(layout)
# Let's test a quitbutton (same handle as Quit menu)
- self.quitButton = self.factory.createPushButton(layout, "&Quit")
+ self.quitButton = self.factory.createPushButton(align, "&Quit")
self.eventManager.addWidgetEvent(self.quitButton, self.onQuitEvent, sendObjOnEvent)
# Let's test a cancel event
self.eventManager.addCancelEvent(self.onCancelEvent)
def onMenuItem(self, item):
- print ("Menu item <<", item.label(), ">>")
+ try:
+ print ("Menu item <<", item.label(), ">>")
+ except Exception:
+ print("Menu item activated")
def onTimeOutButtonEvent(self):
if self.timeout > 0 :
@@ -103,15 +110,21 @@ def onCancelEvent(self) :
print ("Got a cancel event")
def onQuitEvent(self, obj) :
- if isinstance(obj, yui.YItem):
- print ("Quit menu pressed")
- else:
- print ("Quit button pressed")
+ # obj can be a menu item (YMenuItem) or a widget (button)
+ try:
+ if isinstance(obj, yui.YMenuItem):
+ print ("Quit menu pressed")
+ else:
+ print ("Quit button pressed")
+ except Exception:
+ print ("Quit invoked")
# BaseDialog needs to force to exit the handle event loop
self.ExitLoop()
-if __name__ == '__main__':
-
+if __name__ == '__main__':
+ if len(sys.argv) > 1:
+ os.environ['YUI_BACKEND'] = sys.argv[1]
+
td = TestDialog()
td.run()
diff --git a/test/testHelpDialog.py b/test/testHelpDialog.py
index cbc46b2..5a8d20c 100644
--- a/test/testHelpDialog.py
+++ b/test/testHelpDialog.py
@@ -11,11 +11,16 @@
@package manatools
'''
+import os
+import sys
import manatools.basehelpinfo as helpdata
import manatools.ui.helpdialog as helpdialog
import yui
-import time
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+import logging
+
+logger = logging.getLogger("manatools.test.helpdialog")
######################################################################
##
@@ -28,9 +33,53 @@
class HelpInfo(helpdata.HelpInfoBase):
def __init__(self):
helpdata.HelpInfoBase.__init__(self)
+ # Configure file logger for this test: write DEBUG logs to '.log' in cwd
+ try:
+ log_name = os.path.splitext(os.path.basename(__file__))[0] + '.log'
+ fh = logging.FileHandler(log_name, mode='w')
+ fh.setLevel(logging.DEBUG)
+ fh.setFormatter(logging.Formatter('%(asctime)s %(name)s %(levelname)s: %(message)s'))
+ self._logger = logging.getLogger()
+ self._logger.setLevel(logging.DEBUG)
+ existing = False
+ for h in list(self._logger.handlers):
+ try:
+ if isinstance(h, logging.FileHandler) and os.path.abspath(getattr(h, 'baseFilename', '')) == os.path.abspath(log_name):
+ existing = True
+ break
+ except Exception:
+ pass
+ if not existing:
+ self._logger.addHandler(fh)
+ print(f"Logging test output to: {os.path.abspath(log_name)}")
+ except Exception as _e:
+ print(f"Failed to configure file logger: {_e}")
+
+ self._logger.debug("Creating HelpInfo contents")
index1 = '%s'%self._formatLink("Title 1", 'title1')
index2 = '%s'%self._formatLink("Title 2", 'titleindex2')
- self.text = { 'home': "This text explain how to use manatools Help Dialog.
%s
%s"%(index1, index2),
+ index3 = '%s'%self._formatLink("Info", 'info')
+ html = (
+ "Heading 1
"
+ "Heading 2
"
+ "Heading 3
"
+ "Heading 4
"
+ "Heading 5
"
+ "Heading 6
"
+ "
"
+ "Welcome to RichText
"
+ "
"
+ "This is a paragraph with bold, italic, and underlined text.
"
+ "Click the Manatools or go home link to emit an activation event.
"
+ "Colored text:
"
+ ""
+ "Lists:
"
+ ""
+ )
+ self.text = { 'home': "This text explain how to use manatools Help Dialog.
%s - %s
%s"%(index1, index2, index3),
+ 'info': html,
'title1': 'Title 1
This is the title 1 really interesting context.
%s'%self._formatLink("Go to index", 'home'),
'titleindex2': 'Title 2
This is the title 2 interesting context.
%s'%self._formatLink("Go to index", 'home'),
}
@@ -60,10 +109,10 @@ def home(self):
return self.text['home']
-if __name__ == '__main__':
-
+if __name__ == '__main__':
info = HelpInfo()
- td = helpdialog.HelpDialog(info)
+ td = helpdialog.HelpDialog(info)
td.run()
+
diff --git a/test/test_aligment.py b/test/test_aligment.py
new file mode 100644
index 0000000..182bce9
--- /dev/null
+++ b/test/test_aligment.py
@@ -0,0 +1,109 @@
+#!/usr/bin/env python3
+
+import os
+import sys
+import logging
+
+# Add parent directory to path
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+
+# Configure file logger for this test: write DEBUG logs to '.log' in cwd
+try:
+ log_name = os.path.splitext(os.path.basename(__file__))[0] + '.log'
+ fh = logging.FileHandler(log_name, mode='w')
+ fh.setLevel(logging.DEBUG)
+ fh.setFormatter(logging.Formatter('%(asctime)s %(name)s %(levelname)s: %(message)s'))
+ root_logger = logging.getLogger()
+ root_logger.setLevel(logging.DEBUG)
+ existing = False
+ for h in list(root_logger.handlers):
+ try:
+ if isinstance(h, logging.FileHandler) and os.path.abspath(getattr(h, 'baseFilename', '')) == os.path.abspath(log_name):
+ existing = True
+ break
+ except Exception:
+ pass
+ if not existing:
+ root_logger.addHandler(fh)
+ print(f"Logging test output to: {os.path.abspath(log_name)}")
+except Exception as _e:
+ print(f"Failed to configure file logger: {_e}")
+
+
+def test_Alignment(backend_name=None):
+ if backend_name:
+ print(f"Setting backend to: {backend_name}")
+ os.environ['YUI_BACKEND'] = backend_name
+ else:
+ print("Using auto-detection")
+
+ try:
+ from manatools.aui.yui import YUI, YUI_ui
+ import manatools.aui.yui_common as yui
+
+ # Force re-detection
+ YUI._instance = None
+ YUI._backend = None
+
+ backend = YUI.backend()
+ print(f"Using backend: {backend.value}")
+
+ ui = YUI_ui()
+ factory = ui.widgetFactory()
+ dialog = factory.createMainDialog()
+
+ vbox = factory.createVBox( dialog )
+ factory.createLabel(vbox, "Testing aligment Left and Right into HBox")
+ hbox = factory.createHBox( vbox )
+ leftAlignment = factory.createLeft( hbox )
+ left = factory.createPushButton( leftAlignment, "Left" )
+ rightAlignment = factory.createRight( hbox )
+ factory.createPushButton( rightAlignment, "Right" )
+
+ hbox = factory.createHBox( vbox )
+ rightAlignment = factory.createRight( hbox )
+ btn = factory.createPushButton( rightAlignment, ">Right<" )
+ btn.setStretchable( yui.YUIDimension.YD_HORIZ, True )
+
+ factory.createLabel(vbox, "Testing aligment Top and Bottom into HBox")
+ hbox = factory.createHBox( vbox )
+ topAlignment = factory.createTop( hbox )
+ factory.createPushButton( topAlignment, "Top" )
+ factory.createLabel(hbox, "separator")
+ bottomAlignment = factory.createBottom( hbox )
+ factory.createPushButton( bottomAlignment, "Bottom" )
+
+ factory.createLabel(vbox, "Testing aligment HCenter into HBox")
+ hbox = factory.createHBox( vbox )
+ align = factory.createHCenter( hbox )
+ factory.createPushButton( align, "HCenter" )
+
+ factory.createLabel(vbox, "Testing aligment VCenter into HBox")
+ hbox = factory.createHBox( vbox )
+ align = factory.createVCenter( hbox )
+ factory.createPushButton( align, "VCenter" )
+
+ factory.createLabel(vbox, "Testing aligment HVCenter into HBox")
+ hbox = factory.createHBox( vbox )
+ align = factory.createHVCenter( hbox )
+ factory.createPushButton( align, "HVCenter" )
+
+ b = factory.createPushButton( vbox, "OK" )
+ b.setIcon("application-exit")
+ b.setStretchable(yui.YUIDimension.YD_HORIZ, True )
+ b.setStretchable(yui.YUIDimension.YD_VERT, True )
+ dialog.open()
+ event = dialog.waitForEvent()
+ dialog.destroy()
+
+
+ except Exception as e:
+ print(f"Error testing Alignment with backend {backend_name}: {e}")
+ import traceback
+ traceback.print_exc()
+
+if __name__ == "__main__":
+ if len(sys.argv) > 1:
+ test_Alignment(sys.argv[1])
+ else:
+ test_Alignment()
diff --git a/test/test_combobox.py b/test/test_combobox.py
new file mode 100644
index 0000000..bc8661f
--- /dev/null
+++ b/test/test_combobox.py
@@ -0,0 +1,145 @@
+#!/usr/bin/env python3
+
+import os
+import sys
+import logging
+
+# Add parent directory to path
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+
+try:
+ log_name = os.path.splitext(os.path.basename(__file__))[0] + '.log'
+ fh = logging.FileHandler(log_name, mode='w')
+ fh.setLevel(logging.DEBUG)
+ fh.setFormatter(logging.Formatter('%(asctime)s %(name)s %(levelname)s: %(message)s'))
+ root_logger = logging.getLogger()
+ root_logger.setLevel(logging.DEBUG)
+ existing = False
+ for h in list(root_logger.handlers):
+ try:
+ if isinstance(h, logging.FileHandler) and os.path.abspath(getattr(h, 'baseFilename', '')) == os.path.abspath(log_name):
+ existing = True
+ break
+ except Exception:
+ pass
+ if not existing:
+ root_logger.addHandler(fh)
+ print(f"Logging test output to: {os.path.abspath(log_name)}")
+except Exception as _e:
+ print(f"Failed to configure file logger: {_e}")
+
+def test_combobox(backend_name=None):
+ """Test ComboBox widget specifically"""
+ if backend_name:
+ print(f"Setting backend to: {backend_name}")
+ os.environ['YUI_BACKEND'] = backend_name
+ else:
+ print("Using auto-detection")
+
+ try:
+ #from manatools.aui.yui import YUI, YUI_ui
+ #import manatools.aui.yui_common as yui
+ import manatools.aui.yui as MUI
+
+ backend = MUI.YUI.backend()
+ print(f"Using backend: {backend.value}")
+ logger = logging.getLogger("test.test_combobox")
+ logger.info(f"Testing ComboBox with backend: {backend.value}")
+
+ if backend.value == 'ncurses':
+ print("\nNCurses ComboBox Instructions:")
+ print("1. Use TAB to navigate to ComboBox")
+ print("2. Press SPACE to expand dropdown")
+ print("3. Use UP/DOWN arrows to navigate")
+ print("4. Press ENTER to select")
+ print("5. Selected value should be displayed")
+ print("6. Press F10 or Q to quit")
+
+ ui = MUI.YUI_ui()
+ factory = ui.widgetFactory()
+
+ # Create dialog focused on ComboBox testing
+ dialog = factory.createMainDialog()
+ vbox = factory.createVBox(dialog)
+
+ factory.createHeading(vbox, "ComboBox Test")
+ factory.createLabel(vbox, f"Backend: {backend.value}")
+ factory.createLabel(vbox, "Test selecting and displaying values")
+
+ # Test ComboBox with initial selection
+ factory.createLabel(vbox, "")
+ hbox = factory.createHBox(vbox)
+ label1 = "Select a fruit:"
+ combo = factory.createComboBox(hbox, label1, False)
+ fruits = [
+ MUI.YItem("Apple"), MUI.YItem("Banana"), MUI.YItem("Orange", selected=True), MUI.YItem("Grape"), MUI.YItem("Mango")]
+ combo.addItems(fruits)
+
+ # Set initial value to test display
+ combo.setValue("Banana")
+
+ factory.createLabel(hbox, " - ")
+ label2 = "Select an option:"
+ combo1 = factory.createComboBox(hbox, label2, False)
+ options = [
+ MUI.YItem("Option 1"), MUI.YItem("Option 2", selected=True, icon_name="dialog-warning"), MUI.YItem("Option 3"), MUI.YItem("Option 4")]
+ combo1.addItems(options)
+
+ labels = [label1, label2]
+ combo_items = [fruits, options]
+ first_combo_info = 0
+ # Simple buttons
+ selected = factory.createLabel(vbox, "")
+ hbox = factory.createHBox(vbox)
+ swap_button = factory.createPushButton(hbox, "Swap ComboBoxes")
+ cancel_button = factory.createPushButton(hbox, "Cancel")
+
+ print("\nOpening ComboBox test dialog...")
+
+ while True:
+ event = dialog.waitForEvent()
+ typ = event.eventType()
+ if typ == MUI.YEventType.CancelEvent:
+ dialog.destroy()
+ break
+ elif typ == MUI.YEventType.WidgetEvent:
+ wdg = event.widget()
+ if wdg == cancel_button:
+ dialog.destroy()
+ break
+ elif wdg == combo:
+ selected.setText(f"Selected: '{combo.value()}'")
+ for it in combo._items:
+ if it.selected():
+ logger.debug(f" - Item: '{it.label()}' Selected: {it.selected()} Icon: {it.iconName()}")
+ elif wdg == combo1:
+ selected.setText(f"Selected: '{combo1.value()}'")
+ for it in combo1._items:
+ if it.selected():
+ logger.debug(f" - Item: '{it.label()}' Selected: {it.selected()} Icon: {it.iconName()}")
+
+ elif wdg == swap_button:
+ selected.setText(f"Swap clicked.")
+ # Swap combo boxes
+ old = first_combo_info
+ first_combo_info = (first_combo_info + 1) % 2
+ combo.deleteAllItems()
+ combo1.deleteAllItems()
+ combo.setLabel(labels[first_combo_info])
+ combo.addItems(combo_items[first_combo_info])
+ combo1.setLabel(labels[(first_combo_info + 1) % 2])
+ combo1.addItems(combo_items[old])
+
+ # Show final result
+ print(f"\nFinal ComboBox value: '{combo.value()}' {combo1.value()}")
+
+ except Exception as e:
+ print(f"Error testing ComboBox with backend {backend_name}: {e}")
+ import traceback
+ traceback.print_exc()
+
+if __name__ == "__main__":
+ if len(sys.argv) > 1:
+ test_combobox(sys.argv[1])
+ else:
+ test_combobox()
diff --git a/test/test_datetime_fields.py b/test/test_datetime_fields.py
new file mode 100644
index 0000000..cbdfd48
--- /dev/null
+++ b/test/test_datetime_fields.py
@@ -0,0 +1,116 @@
+#!/usr/bin/env python3
+
+import os
+import sys
+import logging
+import datetime
+
+
+# Add parent directory to path
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+
+try:
+ log_name = os.path.splitext(os.path.basename(__file__))[0] + '.log'
+ fh = logging.FileHandler(log_name, mode='w')
+ fh.setLevel(logging.DEBUG)
+ fh.setFormatter(logging.Formatter('%(asctime)s %(name)s %(levelname)s: %(message)s'))
+ root_logger = logging.getLogger()
+ root_logger.setLevel(logging.DEBUG)
+ existing = False
+ for h in list(root_logger.handlers):
+ try:
+ if isinstance(h, logging.FileHandler) and os.path.abspath(getattr(h, 'baseFilename', '')) == os.path.abspath(log_name):
+ existing = True
+ break
+ except Exception:
+ pass
+ if not existing:
+ root_logger.addHandler(fh)
+ print(f"Logging test output to: {os.path.abspath(log_name)}")
+except Exception as _e:
+ print(f"Failed to configure file logger: {_e}")
+
+
+def test_datefield(backend_name=None):
+ """Interactive test for YDateField and YTimeField widgets."""
+ if backend_name:
+ print(f"Setting backend to: {backend_name}")
+ os.environ['YUI_BACKEND'] = backend_name
+ else:
+ print("Using auto-detection")
+
+ try:
+ from manatools.aui.yui import YUI, YUI_ui
+ import manatools.aui.yui_common as yui
+
+ # Force re-detection
+ YUI._instance = None
+ YUI._backend = None
+
+ backend = YUI.backend()
+ print(f"Using backend: {backend.value}")
+
+ ui = YUI_ui()
+ factory = ui.widgetFactory()
+ dialog = factory.createMainDialog()
+
+ vbox = factory.createVBox(dialog)
+ factory.createHeading(vbox, "Date/Time Field Test")
+ factory.createLabel(vbox, f"Backend: {backend.value}")
+
+ # Create datefield
+ hbox = factory.createHBox(vbox)
+ df = factory.createDateField(hbox, "Select Date:")
+ #df.setStretchable(yui.YUIDimension.YD_HORIZ, True)
+ now = datetime.datetime.now()
+ df.setValue(now.strftime("%Y-%m-%d"))
+ factory.createLabel(hbox, "right widget")
+
+ # Create timefield
+ try:
+ hbox = factory.createHBox(vbox)
+ tf = factory.createTimeField(hbox, "Select Time:")
+ #tf.setStretchable(yui.YUIDimension.YD_HORIZ, True)
+ tf.setValue(now.strftime("%H:%M:%S"))
+ factory.createLabel(hbox, "right widget")
+ except Exception as e:
+ tf = None
+ logging.getLogger(__name__).exception("Failed to create TimeField: %s", e)
+
+ # Buttons
+ h = factory.createHBox(vbox)
+ ok_btn = factory.createPushButton(h, "OK")
+ close_btn = factory.createPushButton(h, "Close")
+
+ print("\nOpening Date/TimeField test dialog...")
+
+ while True:
+ ev = dialog.waitForEvent()
+ et = ev.eventType()
+ if et == yui.YEventType.CancelEvent:
+ break
+ elif et == yui.YEventType.WidgetEvent:
+ wdg = ev.widget()
+ reason = ev.reason()
+ if wdg == close_btn and reason == yui.YEventReason.Activated:
+ break
+ if wdg == ok_btn and reason == yui.YEventReason.Activated:
+ print("OK clicked. Final date:", df.value())
+ if tf is not None:
+ print("Final time:", tf.value())
+ break
+ logging.info("Date: %s", df.value())
+ if tf is not None:
+ logging.info("Time: %s", tf.value())
+ dialog.destroy()
+ except Exception as e:
+ print(f"Error testing DateField with backend {backend_name}: {e}")
+ import traceback
+ traceback.print_exc()
+
+
+if __name__ == "__main__":
+ if len(sys.argv) > 1:
+ test_datefield(sys.argv[1])
+ else:
+ test_datefield()
diff --git a/test/test_dumptabwidget.py b/test/test_dumptabwidget.py
new file mode 100644
index 0000000..d04d4b3
--- /dev/null
+++ b/test/test_dumptabwidget.py
@@ -0,0 +1,152 @@
+#!/usr/bin/env python3
+
+import os
+import sys
+import logging
+
+# Add parent directory to path
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+
+try:
+ log_name = os.path.splitext(os.path.basename(__file__))[0] + '.log'
+ fh = logging.FileHandler(log_name, mode='w')
+ fh.setLevel(logging.DEBUG)
+ fh.setFormatter(logging.Formatter('%(asctime)s %(name)s %(levelname)s: %(message)s'))
+ root_logger = logging.getLogger()
+ root_logger.setLevel(logging.DEBUG)
+ existing = False
+ for h in list(root_logger.handlers):
+ try:
+ if isinstance(h, logging.FileHandler) and os.path.abspath(getattr(h, 'baseFilename', '')) == os.path.abspath(log_name):
+ existing = True
+ break
+ except Exception:
+ pass
+ if not existing:
+ root_logger.addHandler(fh)
+ print(f"Logging test output to: {os.path.abspath(log_name)}")
+except Exception as _e:
+ print(f"Failed to configure file logger: {_e}")
+
+def test_dumbtab(backend_name=None):
+ """Interactive test showcasing YDumbTab with three tabs and a ReplacePoint."""
+ if backend_name:
+ print(f"Setting backend to: {backend_name}")
+ os.environ['YUI_BACKEND'] = backend_name
+ else:
+ print("Using auto-detection")
+
+ try:
+ from manatools.aui.yui import YUI, YUI_ui
+ import manatools.aui.yui_common as yui
+
+ # Force re-detection
+ YUI._instance = None
+ YUI._backend = None
+
+ backend = YUI.backend()
+ print(f"Using backend: {backend.value}")
+
+ # Basic logging for diagnosis
+ import logging
+ logging.basicConfig(level=logging.DEBUG, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
+
+ ui = YUI_ui()
+ factory = ui.widgetFactory()
+ dialog = factory.createMainDialog()
+
+ vbox = factory.createVBox(dialog)
+ factory.createHeading(vbox, "YDumbTab Test")
+ factory.createLabel(vbox, f"Backend: {backend.value}")
+
+ # Create DumbTab and items
+ dumbtab = factory.createDumbTab(vbox)
+ tabs = ["Options", "Notes", "Actions"]
+ # Add items (select the first by default)
+ it0 = yui.YItem(tabs[0], selected=True)
+ it1 = yui.YItem(tabs[1])
+ it2 = yui.YItem(tabs[2])
+ dumbtab.addItem(it0)
+ dumbtab.addItem(it1)
+ dumbtab.addItem(it2)
+
+ # Content area: ReplacePoint as the single child
+ rp = factory.createReplacePoint(dumbtab)
+ print("ReplacePoint created:", rp.widgetClass())
+
+ # Helper to render content of the active tab
+ def render_content(index: int):
+ # Clear previous content
+ try:
+ rp.deleteChildren()
+ except Exception:
+ pass
+ # Build new content depending on selected tab
+ if index == 0:
+ box = factory.createVBox(rp)
+ factory.createLabel(box, "Enable the option below:")
+ factory.createCheckBox(box, "Enable feature", is_checked=True)
+ factory.createLabel(box, "Use this feature to blah blah...")
+ rp.showChild()
+ print("Rendered tab 0: Options")
+ elif index == 1:
+ box = factory.createVBox(rp)
+ factory.createLabel(box, "Notes:")
+ text = "This is a simple multi-tab demo.\nSwitch between tabs.\nThe content below changes per tab."
+ minsize = factory.createMinSize(box, 320, 200)
+ rt = factory.createRichText(minsize, text, plainTextMode=True)
+ rt.setStretchable(yui.YUIDimension.YD_VERT, True)
+ rt.setStretchable(yui.YUIDimension.YD_HORIZ, True)
+ rp.showChild()
+ print("Rendered tab 1: Notes")
+ else:
+ box = factory.createVBox(rp)
+ factory.createLabel(box, "Choose an action:")
+ h = factory.createHBox(box)
+ factory.createPushButton(h, "OK")
+ factory.createPushButton(h, "Cancel")
+ rp.showChild()
+ print("Rendered tab 2: Actions")
+
+ # Initial content for the first tab
+ render_content(0)
+ print("Initial content rendered for tab 0.")
+
+ # Close button
+ close_row = factory.createHBox(vbox)
+ close_btn = factory.createPushButton(close_row, "Close")
+
+ print("\nOpening YDumbTab test dialog...")
+
+ while True:
+ ev = dialog.waitForEvent()
+ et = ev.eventType()
+ if et == yui.YEventType.CancelEvent:
+ dialog.destroy()
+ break
+ elif et == yui.YEventType.WidgetEvent:
+ wdg = ev.widget()
+ reason = ev.reason()
+ if wdg == close_btn and reason == yui.YEventReason.Activated:
+ dialog.destroy()
+ break
+ if wdg == dumbtab and reason == yui.YEventReason.Activated:
+ sel = dumbtab.selectedItem()
+ if sel is not None:
+ try:
+ idx = tabs.index(sel.label())
+ except Exception:
+ idx = 0
+ print("Tab Activated:", sel.label(), "index=", idx)
+ render_content(idx)
+
+ except Exception as e:
+ print(f"Error testing YDumbTab with backend {backend_name}: {e}")
+ import traceback
+ traceback.print_exc()
+
+if __name__ == "__main__":
+ if len(sys.argv) > 1:
+ test_dumbtab(sys.argv[1])
+ else:
+ test_dumbtab()
diff --git a/test/test_file_dialogs.py b/test/test_file_dialogs.py
new file mode 100644
index 0000000..7b10899
--- /dev/null
+++ b/test/test_file_dialogs.py
@@ -0,0 +1,135 @@
+#!/usr/bin/env python3
+
+import os
+import sys
+
+# Add parent directory to path
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+import logging
+
+# Configure file logger for this test: write DEBUG logs to '.log' in cwd
+try:
+ log_name = os.path.splitext(os.path.basename(__file__))[0] + '.log'
+ fh = logging.FileHandler(log_name, mode='w')
+ fh.setLevel(logging.DEBUG)
+ fh.setFormatter(logging.Formatter('%(asctime)s %(name)s %(levelname)s: %(message)s'))
+ root_logger = logging.getLogger()
+ root_logger.setLevel(logging.DEBUG)
+ existing = False
+ for h in list(root_logger.handlers):
+ try:
+ if isinstance(h, logging.FileHandler) and os.path.abspath(getattr(h, 'baseFilename', '')) == os.path.abspath(log_name):
+ existing = True
+ break
+ except Exception:
+ pass
+ if not existing:
+ root_logger.addHandler(fh)
+ print(f"Logging test output to: {os.path.abspath(log_name)}")
+except Exception as _e:
+ print(f"Failed to configure file logger: {_e}")
+
+# Use current working directory (where the log file is written) as starting directory
+start_dir = os.path.abspath(os.getcwd())
+print(f"Using starting directory for dialogs: {start_dir}")
+
+
+def test_file_dialogs(backend_name=None):
+ if backend_name:
+ print(f"Setting backend to: {backend_name}")
+ os.environ['YUI_BACKEND'] = backend_name
+ else:
+ print("Using auto-detection")
+
+ try:
+ from manatools.aui.yui import YUI, YUI_ui
+ import manatools.aui.yui_common as yui
+
+ # Force re-detection
+ YUI._instance = None
+ YUI._backend = None
+
+ backend = YUI.backend()
+ print(f"Using backend: {backend.value}")
+
+ ui = YUI_ui()
+ app = ui.app()
+ factory = ui.widgetFactory()
+ dialog = factory.createPopupDialog()
+
+ vbox = factory.createVBox(dialog)
+ factory.createHeading(vbox, "File dialog test")
+
+ # main area: HBox with multiline edit on left and buttons on right
+ h = factory.createHBox(vbox)
+
+ mled = factory.createMultiLineEdit(h, "File content")
+ mled.setStretchable(yui.YUIDimension.YD_VERT, True)
+ mled.setStretchable(yui.YUIDimension.YD_HORIZ, True)
+ mled.setWeight(yui.YUIDimension.YD_HORIZ, 60)
+
+ right = factory.createVBox(h)
+ right.setWeight(yui.YUIDimension.YD_HORIZ, 40)
+ open_btn = factory.createPushButton(right, "Open")
+ save_btn = factory.createPushButton(right, "Save")
+ select_dir_btn = factory.createPushButton(right, "Select Directory")
+
+ hbox = factory.createHBox(vbox)
+ factory.createLabel(hbox, "Selected directory:")
+ dir_label = factory.createLabel(hbox, "No dir selected")
+ dir_label.setStretchable(yui.YUIDimension.YD_VERT, False)
+ dir_label.setStretchable(yui.YUIDimension.YD_HORIZ, True)
+
+ factory.createSpacing(vbox, yui.YUIDimension.YD_VERT, False, 10)
+ close_btn = factory.createPushButton(factory.createHVCenter(vbox), "Close")
+
+ # wire events in the event loop by checking WidgetEvent and comparing widgets
+ dialog.open()
+ while True:
+ event = dialog.waitForEvent()
+ if not event:
+ continue
+ typ = event.eventType()
+ if typ == yui.YEventType.CancelEvent:
+ dialog.destroy()
+ break
+ elif typ == yui.YEventType.WidgetEvent:
+ wdg = event.widget()
+ if wdg == open_btn:
+ # ask for text file (start in current working directory)
+ fname = app.askForExistingFile(start_dir, "*.txt", "Open text file")
+ if fname:
+ try:
+ with open(fname, 'r', encoding='utf-8') as f:
+ data = f.read()
+ mled.setValue(data)
+ except Exception as e:
+ print(f"Failed to read file: {e}")
+ elif wdg == save_btn:
+ fname = app.askForSaveFileName(start_dir, "*.txt", "Save text file")
+ if fname:
+ try:
+ data = mled.value()
+ with open(fname, 'w', encoding='utf-8') as f:
+ f.write(data)
+ except Exception as e:
+ print(f"Failed to save file: {e}")
+ elif wdg == select_dir_btn:
+ d = app.askForExistingDirectory(start_dir, "Select directory")
+ if d:
+ dir_label.setText(d)
+ elif wdg == close_btn:
+ dialog.destroy()
+ break
+
+ except Exception as e:
+ print(f"Error testing file dialogs with backend {backend_name}: {e}")
+ import traceback
+ traceback.print_exc()
+
+
+if __name__ == "__main__":
+ if len(sys.argv) > 1:
+ test_file_dialogs(sys.argv[1])
+ else:
+ test_file_dialogs()
diff --git a/test/test_frame.py b/test/test_frame.py
new file mode 100644
index 0000000..fea09a9
--- /dev/null
+++ b/test/test_frame.py
@@ -0,0 +1,141 @@
+#!/usr/bin/env python3
+
+import os
+import sys
+
+# Add parent directory to path
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+
+def test_selectionbox(backend_name=None):
+ """Test Frame widget specifically"""
+ if backend_name:
+ print(f"Setting backend to: {backend_name}")
+ os.environ['YUI_BACKEND'] = backend_name
+ else:
+ print("Using auto-detection")
+
+ try:
+ from manatools.aui.yui import YUI, YUI_ui
+ import manatools.aui.yui_common as yui
+
+ # Force re-detection
+ YUI._instance = None
+ YUI._backend = None
+
+ backend = YUI.backend()
+ print(f"Using backend: {backend.value}")
+
+ ui = YUI_ui()
+ factory = ui.widgetFactory()
+
+###############
+ ui.application().setApplicationTitle("Test Frame")
+ dialog = factory.createPopupDialog()
+# dialog.setEnabled(False)
+ mainVbox = factory.createVBox( dialog )
+# mainVbox.setEnabled(False)
+ hbox = factory.createHBox( mainVbox )
+# hbox.setEnabled(False)
+ frame = factory.createFrame( hbox , "Pasta Menu")
+# frame.setEnabled(False)
+ selBox = factory.createSelectionBox( frame, "Choose your pasta" )
+
+ selBox.addItem( "Spaghetti Carbonara" )
+ selBox.addItem( "Penne Arrabbiata" )
+ selBox.addItem( "Fettuccine" )
+ selBox.addItem( "Lasagna" )
+ selBox.addItem( "Ravioli" )
+ selBox.addItem( "Trofie al pesto" ) # Ligurian specialty
+
+ frame1 = factory.createCheckBoxFrame( hbox , "SelectionBox Options", True)
+# frame1 = factory.createFrame( hbox , "SelectionBox Options")
+# frame1.setEnabled(False)
+ vbox = factory.createVBox( frame1 )
+# vbox.setEnabled(False)
+ align = factory.createTop(vbox)
+ notifyCheckBox = factory.createCheckBox( align, "Notify on change", selBox.notify() )
+ notifyCheckBox.setStretchable( yui.YUIDimension.YD_HORIZ, True )
+ multiSelectionCheckBox = factory.createCheckBox( vbox, "Multi-selection", selBox.multiSelection() )
+ multiSelectionCheckBox.setStretchable( yui.YUIDimension.YD_HORIZ, True )
+ align = factory.createBottom( vbox )
+ disableSelectionBox = factory.createCheckBox( align, "disable selection box", not selBox.isEnabled() )
+ disableSelectionBox.setStretchable( yui.YUIDimension.YD_HORIZ, True )
+ disableValue = factory.createCheckBox( vbox, "disable value button", False )
+ disableValue.setStretchable( yui.YUIDimension.YD_HORIZ, True )
+# disableValue.setEnabled(False)
+
+ hbox = factory.createHBox( mainVbox )
+ valueButton = factory.createPushButton( hbox, "Value" )
+# valueButton.setEnabled(False)
+ disableValue.setValue(not valueButton.isEnabled())
+ label = factory.createLabel(hbox, "SelectionBox") #factory.createOutputField( hbox, "" )
+ label.setStretchable( yui.YUIDimension.YD_HORIZ, True )
+ valueField = factory.createLabel(hbox, "")
+ valueField.setStretchable( yui.YUIDimension.YD_HORIZ, True ) # // allow stretching over entire dialog width
+
+ #factory.createVSpacing( vbox, 0.3 )
+
+ hbox = factory.createHBox( mainVbox )
+ #factory.createLabel(hbox, " ") # spacer
+ leftAlignment = factory.createLeft( hbox )
+ left = factory.createPushButton( leftAlignment, "Left" )
+ rightAlignment = factory.createRight( hbox )
+ closeButton = factory.createPushButton( rightAlignment, "Close" )
+
+ #
+ # Event loop
+ #
+ #valueField.setText( "???" )
+ while True:
+ event = dialog.waitForEvent()
+ if not event:
+ print("Empty")
+ next
+ typ = event.eventType()
+ if typ == yui.YEventType.CancelEvent:
+ dialog.destroy()
+ break
+ elif typ == yui.YEventType.WidgetEvent:
+ wdg = event.widget()
+ if wdg == closeButton:
+ dialog.destroy()
+ break
+ elif wdg == left:
+ valueField.setText(left.label())
+ elif (wdg == valueButton):
+ if selBox.multiSelection():
+ labels = [item.label() for item in selBox.selectedItems()]
+ valueField.setText( ", ".join(labels) )
+ else:
+ item = selBox.selectedItem()
+ valueField.setText( item.label() if item else "" )
+ ui.application().setApplicationTitle("Test App")
+ elif (wdg == notifyCheckBox):
+ selBox.setNotify( notifyCheckBox.value() )
+ elif (wdg == multiSelectionCheckBox):
+ selBox.setMultiSelection( multiSelectionCheckBox.value() )
+ elif (wdg == disableSelectionBox):
+ selBox.setEnabled( not disableSelectionBox.value() )
+ elif (wdg == disableValue):
+ valueButton.setEnabled( not disableValue.value() )
+ elif (wdg == selBox): # selBox will only send events with setNotify() TODO
+ if selBox.multiSelection():
+ labels = [item.label() for item in selBox.selectedItems()]
+ valueField.setText( ", ".join(labels) )
+ else:
+ valueField.setText(selBox.value())
+
+ except Exception as e:
+ print(f"Error testing ComboBox with backend {backend_name}: {e}")
+ import traceback
+ traceback.print_exc()
+
+if __name__ == "__main__":
+ if len(sys.argv) > 1:
+ test_selectionbox(sys.argv[1])
+ else:
+ test_selectionbox()
+
+
+
+
diff --git a/test/test_hello_world.py b/test/test_hello_world.py
new file mode 100644
index 0000000..fda5289
--- /dev/null
+++ b/test/test_hello_world.py
@@ -0,0 +1,48 @@
+#!/usr/bin/env python3
+
+import os
+import sys
+
+# Add parent directory to path
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+
+def test_hello_world(backend_name=None):
+ """Test simple dialog with hello world"""
+ if backend_name:
+ print(f"Setting backend to: {backend_name}")
+ os.environ['YUI_BACKEND'] = backend_name
+ else:
+ print("Using auto-detection")
+
+ try:
+ from manatools.aui.yui import YUI, YUI_ui
+
+ # Force re-detection
+ YUI._instance = None
+ YUI._backend = None
+
+ backend = YUI.backend()
+ print(f"Using backend: {backend.value}")
+
+ ui = YUI_ui()
+ factory = ui.widgetFactory()
+ dialog = factory.createMainDialog()
+
+ vbox = factory.createVBox( dialog )
+ factory.createLabel( vbox, "Hello, World!" )
+ factory.createPushButton( vbox, "OK" )
+ dialog.open()
+ event = dialog.waitForEvent()
+ dialog.destroy()
+
+
+ except Exception as e:
+ print(f"Error testing ComboBox with backend {backend_name}: {e}")
+ import traceback
+ traceback.print_exc()
+
+if __name__ == "__main__":
+ if len(sys.argv) > 1:
+ test_hello_world(sys.argv[1])
+ else:
+ test_hello_world()
diff --git a/test/test_image.py b/test/test_image.py
new file mode 100644
index 0000000..bd9fe28
--- /dev/null
+++ b/test/test_image.py
@@ -0,0 +1,316 @@
+#!/usr/bin/env python3
+
+import os
+import sys
+
+# Add parent directory to path
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+import logging
+
+# Configure file logger for this test: write DEBUG logs to '.log' in cwd
+try:
+ log_name = os.path.splitext(os.path.basename(__file__))[0] + '.log'
+ fh = logging.FileHandler(log_name, mode='w')
+ fh.setLevel(logging.DEBUG)
+ fh.setFormatter(logging.Formatter('%(asctime)s %(name)s %(levelname)s: %(message)s'))
+ root_logger = logging.getLogger()
+ root_logger.setLevel(logging.DEBUG)
+ existing = False
+ for h in list(root_logger.handlers):
+ try:
+ if isinstance(h, logging.FileHandler) and os.path.abspath(getattr(h, 'baseFilename', '')) == os.path.abspath(log_name):
+ existing = True
+ break
+ except Exception:
+ pass
+ if not existing:
+ root_logger.addHandler(fh)
+ print(f"Logging test output to: {os.path.abspath(log_name)}")
+except Exception as _e:
+ print(f"Failed to configure file logger: {_e}")
+
+def test_image(backend_name=None):
+ if backend_name:
+ print(f"Setting backend to: {backend_name}")
+ os.environ['YUI_BACKEND'] = backend_name
+ else:
+ print("Using auto-detection")
+
+ try:
+ from manatools.aui.yui import YUI, YUI_ui
+ import manatools.aui.yui_common as yui
+
+ # Force re-detection
+ YUI._instance = None
+ YUI._backend = None
+
+ backend = YUI.backend()
+ print(f"Using backend: {backend.value}")
+
+ ui = YUI_ui()
+ factory = ui.widgetFactory()
+ dlg = factory.createPopupDialog()
+ vbox = factory.createVBox(dlg)
+ factory.createHeading(vbox, "Image widget demo")
+
+ # Use an example image path if available (relative to project), otherwise empty
+ example = os.path.join(os.path.dirname(__file__), '..', 'share/images', 'manatools.png')
+ example = os.path.abspath(example)
+ if not os.path.exists(example):
+ example = ""
+
+ images = [example, "system-software-install"]
+
+ # Create a MinSize wrapper so we can enforce a minimum visible size:
+ # start with a conservative minimum (width x height in chars)
+ min_w = 40
+ min_h = 6
+ min_container = factory.createMinSize(vbox, min_w, min_h)
+ img = factory.createImage(min_container, "/usr/share/isodumper/header.png")
+ # Keep references for interactive toggles
+ current_mode = {"auto_scale": True, "vert_stretch": True, "min_h": min_h}
+
+ # helper to inspect backend widget (best-effort multi-backend)
+ def inspect_backend_widget(w):
+ info = {}
+ try:
+ info['type'] = type(w).__name__
+ except Exception:
+ info['type'] = str(w)
+ # try Qt style
+ try:
+ if hasattr(w, 'size') and callable(getattr(w, 'size')):
+ s = w.size()
+ try:
+ info['w'] = s.width()
+ info['h'] = s.height()
+ except Exception:
+ try:
+ info['w'] = s.width
+ info['h'] = s.height
+ except Exception:
+ pass
+ except Exception:
+ pass
+ # try Gtk style
+ try:
+ if hasattr(w, 'get_allocated_width'):
+ info['w'] = w.get_allocated_width()
+ if hasattr(w, 'get_allocated_height'):
+ info['h'] = w.get_allocated_height()
+ except Exception:
+ pass
+ # try curses placeholder
+ try:
+ if hasattr(w, 'widgetClass') and w.widgetClass() == 'YImageCurses':
+ info['curses'] = True
+ except Exception:
+ pass
+ return info
+
+ def log_image_state(prefix=""):
+ try:
+ root_logger.debug("%s Image api: imageFileName=%r", prefix, (img.imageFileName() if hasattr(img, "imageFileName") else None))
+ except Exception:
+ root_logger.debug("%s Image api: imageFileName=>", prefix)
+ try:
+ root_logger.debug("%s Image api: autoScale=%r", prefix, (img.autoScale() if hasattr(img, "autoScale") else ""))
+ except Exception:
+ root_logger.debug("%s Image api: autoScale=", prefix)
+ try:
+ root_logger.debug("%s Image api: stretchable_vert=%r", prefix, bool(img.stretchable(yui.YUIDimension.YD_VERT)))
+ except Exception:
+ root_logger.debug("%s Image api: stretchable_vert=", prefix)
+ try:
+ # Min container introspection
+ mw = getattr(min_container, 'minHeight', None)
+ if callable(mw):
+ root_logger.debug("%s Min container minHeight()=%r", prefix, mw())
+ else:
+ # sometimes stored as attribute
+ root_logger.debug("%s Min container min_h attr=%r", prefix, getattr(min_container, '_minHeight', ''))
+ except Exception:
+ root_logger.debug("%s Min container: ", prefix)
+ # backend widget inspection
+ try:
+ be = None
+ try:
+ be = img.get_backend_widget()
+ except Exception:
+ try:
+ be = img._backend_widget
+ except Exception:
+ be = None
+ if be is not None:
+ info = inspect_backend_widget(be)
+ root_logger.debug("%s Backend widget info: %s", prefix, info)
+ else:
+ root_logger.debug("%s Backend widget: None", prefix)
+ except Exception:
+ root_logger.debug("%s Backend inspection failed", prefix)
+
+ # mode application routine
+ def apply_mode(name, autoscale, vert_stretch, min_h):
+ try:
+ # autoscale
+ try:
+ if hasattr(img, "setAutoScale"):
+ img.setAutoScale(bool(autoscale))
+ except Exception:
+ pass
+ # vertical stretch
+ try:
+ img.setStretchable(yui.YUIDimension.YD_VERT, bool(vert_stretch))
+ except Exception:
+ # fallback: setStretchable with older naming
+ try:
+ img.setStretchable(yui.YUIDimension.YD_VERT, vert_stretch)
+ except Exception:
+ pass
+ # set minimum height on the container
+ try:
+ if hasattr(min_container, "setMinHeight"):
+ min_container.setMinHeight(int(min_h))
+ else:
+ # try setMinSize if available
+ if hasattr(min_container, "setMinSize"):
+ min_container.setMinSize(getattr(min_container, "minWidth", min_w), int(min_h))
+ except Exception:
+ pass
+
+ current_mode.update({"auto_scale": autoscale, "vert_stretch": vert_stretch, "min_h": min_h})
+ log_image_state(prefix=f"APPLY_MODE {name}:")
+ except Exception as e:
+ root_logger.exception("Failed applying mode %s: %s", name, e)
+
+ # Prepare three typical modes to reproduce behaviour:
+ modes = [
+ ("autoscale=ON, vert_stretch=ON, min_h=6", True, True, 6),
+ ("autoscale=ON, vert_stretch=OFF, min_h=6", True, False, 6),
+ ("autoscale=OFF, vert_stretch=OFF, min_h=6", False, False, 6),
+ ]
+
+ # Controls: AutoScale + Stretch flags + MinHeight presets
+ ctrl = factory.createFrame(vbox, "Controls")
+ ctrl_v = factory.createVBox(ctrl)
+ desc = factory.createLabel(ctrl_v, "Autoscale keeps aspect. Stretch H/V lets the widget expand in width/height. With AutoScale=ON, expansion preserves ratio.")
+ toggles = factory.createHBox(ctrl_v)
+ chk_auto = factory.createCheckBox(toggles, "AutoScale", True)
+ chk_h = factory.createCheckBox(toggles, "Stretch H", True)
+ chk_v = factory.createCheckBox(toggles, "Stretch V", True)
+ mhbox = factory.createHBox(ctrl_v)
+ btn_min6 = factory.createPushButton(factory.createLeft(mhbox), "MinHeight: 6")
+ btn_min10 = factory.createPushButton(factory.createLeft(mhbox), "MinHeight: 10")
+
+ status = factory.createLabel(vbox, "Status: size=?, pix=?, mode=?")
+
+ # Apply initial state
+ try:
+ img.setAutoScale(True)
+ except Exception:
+ pass
+ try:
+ img.setStretchable(yui.YUIDimension.YD_HORIZ, True)
+ img.setStretchable(yui.YUIDimension.YD_VERT, True)
+ except Exception:
+ pass
+ try:
+ min_container.setMinHeight(min_h)
+ except Exception:
+ pass
+
+ def backend_sizes():
+ # best-effort introspection
+ try:
+ be = img.get_backend_widget()
+ except Exception:
+ be = None
+ w = h = pw = ph = None
+ if be is not None:
+ try:
+ s = be.size()
+ w = getattr(s, "width")() if callable(getattr(s, "width", None)) else getattr(s, "width", None)
+ h = getattr(s, "height")() if callable(getattr(s, "height", None)) else getattr(s, "height", None)
+ except Exception:
+ pass
+ # image API might not expose pix size; rely on backend size as proxy
+ return w, h, pw, ph
+
+ def update_status(prefix=""):
+ try:
+ w, h, pw, ph = backend_sizes()
+ mode = f"auto={getattr(img, 'autoScale', lambda: None)()} H={img.stretchable(yui.YUIDimension.YD_HORIZ)} V={img.stretchable(yui.YUIDimension.YD_VERT)}"
+ status.setText(f"{prefix} Status: widget=({w}x{h}) pix=({pw}x{ph}) {mode}")
+ except Exception:
+ pass
+
+ update_status("Init:")
+
+ # Existing buttons: toggle image and close
+ hbox = factory.createHBox(vbox)
+ toggle = factory.createPushButton(factory.createLeft(hbox), "Toggle Image")
+ close = factory.createPushButton(factory.createRight(hbox), "Close")
+
+ dlg.open()
+ while True:
+ event = dlg.waitForEvent()
+ if not event:
+ continue
+ typ = event.eventType()
+ if typ == yui.YEventType.CancelEvent:
+ dlg.destroy()
+ break
+ elif typ == yui.YEventType.WidgetEvent:
+ wdg = event.widget()
+ reason = event.reason()
+ if wdg == close:
+ dlg.destroy()
+ break
+ elif wdg == toggle:
+ img.setImage(images[1] if img.imageFileName() == images[0] else images[0])
+ update_status("Toggle:")
+ elif wdg == chk_auto and reason == yui.YEventReason.ValueChanged:
+ try:
+ img.setAutoScale(chk_auto.value())
+ except Exception:
+ pass
+ update_status("Auto:")
+ elif wdg == chk_h and reason == yui.YEventReason.ValueChanged:
+ try:
+ img.setStretchable(yui.YUIDimension.YD_HORIZ, chk_h.value())
+ except Exception:
+ pass
+ update_status("StretchH:")
+ elif wdg == chk_v and reason == yui.YEventReason.ValueChanged:
+ try:
+ img.setStretchable(yui.YUIDimension.YD_VERT, chk_v.value())
+ except Exception:
+ pass
+ update_status("StretchV:")
+ elif wdg == btn_min6 and reason == yui.YEventReason.Activated:
+ try:
+ min_container.setMinHeight(6)
+ except Exception:
+ pass
+ update_status("MinH=6:")
+ elif wdg == btn_min10 and reason == yui.YEventReason.Activated:
+ try:
+ min_container.setMinHeight(10)
+ except Exception:
+ pass
+ update_status("MinH=10:")
+ # manual log trigger: if clicking image (some backends may send event)
+ if wdg == img:
+ log_image_state(prefix="IMAGE_CLICK:")
+
+ root_logger.info("Dialog closed")
+ except Exception as e:
+ print(f"Error testing Image with backend {backend_name}: {e}")
+ import traceback
+ traceback.print_exc()
+
+if __name__ == '__main__':
+ if len(sys.argv) > 1:
+ test_image(sys.argv[1])
+ else:
+ test_image()
diff --git a/test/test_intfield.py b/test/test_intfield.py
new file mode 100644
index 0000000..6a7739c
--- /dev/null
+++ b/test/test_intfield.py
@@ -0,0 +1,120 @@
+#!/usr/bin/env python3
+"""Interactive test for YIntField across all backends.
+
+- Creates two int fields and two labels that display their values.
+- Has an OK button to exit.
+- Logs exceptions and backend creation issues.
+"""
+import os
+import sys
+import logging
+
+# Ensure project root on PYTHONPATH
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+
+try:
+ log_name = os.path.splitext(os.path.basename(__file__))[0] + '.log'
+ logFormatter = logging.Formatter('%(asctime)s %(name)s %(levelname)s: %(message)s')
+ root_logger = logging.getLogger()
+ fileHandler = logging.FileHandler(log_name, mode='w')
+ fileHandler.setFormatter(logFormatter)
+ root_logger.addHandler(fileHandler)
+ consoleHandler = logging.StreamHandler()
+ consoleHandler.setFormatter(logFormatter)
+ root_logger.addHandler(consoleHandler)
+ consoleHandler.setLevel(logging.INFO)
+ root_logger.setLevel(logging.DEBUG)
+except Exception as _e:
+ logging.getLogger().exception("Failed to configure file logger: %s", _e)
+
+
+def test_intfield(backend_name=None):
+ if backend_name:
+ os.environ['YUI_BACKEND'] = backend_name
+ logging.getLogger().info("Set backend to %s", backend_name)
+ else:
+ root_logger.info("Using auto-detection")
+ try:
+ from manatools.aui.yui import YUI, YUI_ui
+ import manatools.aui.yui_common as yui
+
+ # force re-detection
+ YUI._instance = None
+ YUI._backend = None
+
+ backend = YUI.backend()
+ root_logger.info("Using backend: %s", backend.value)
+
+ ui = YUI_ui()
+ factory = ui.widgetFactory()
+
+ ui.application().setApplicationTitle(f"Test {backend.value} IntField")
+ dlg = factory.createPopupDialog()
+ v = factory.createVBox(dlg)
+
+ # Left column with intfields
+ row1 = factory.createHBox(v)
+ row2 = factory.createHBox(v)
+
+ int1 = factory.createIntField(row1, "First", 0, 100, 10)
+ int2 = factory.createIntField(row2, "Second", -50, 50, 0)
+
+ lab1 = factory.createLabel(factory.createVCenter(row1), "Value 1: 10")
+ lab2 = factory.createLabel(factory.createVCenter(row2), "Value 2: 0")
+
+ ok = factory.createPushButton(v, "OK")
+
+ try:
+ # make int fields not vertically stretchable
+ int1.setStretchable(yui.YUIDimension.YD_VERT, False)
+ int2.setStretchable(yui.YUIDimension.YD_VERT, False)
+ int1.setStretchable(yui.YUIDimension.YD_HORIZ, False)
+ int2.setStretchable(yui.YUIDimension.YD_HORIZ, False)
+ #lab1.setStretchable(yui.YUIDimension.YD_VERT, False)
+ #lab2.setStretchable(yui.YUIDimension.YD_VERT, False)
+ #lab1.setStretchable(yui.YUIDimension.YD_HORIZ, False)
+ #lab2.setStretchable(yui.YUIDimension.YD_HORIZ, False)
+ except Exception:
+ pass
+
+ while True:
+ ev = dlg.waitForEvent()
+ if not ev:
+ continue
+ if ev.eventType() == yui.YEventType.CancelEvent:
+ dlg.destroy()
+ break
+ if ev.eventType() == yui.YEventType.WidgetEvent:
+ w = ev.widget()
+ try:
+ logging.getLogger().debug("WidgetEvent from %s", getattr(w, 'widgetClass', lambda: '?')())
+ except Exception:
+ pass
+ if w == ok:
+ dlg.destroy()
+ break
+ # Update labels if values change; some backends post events differently
+ try:
+ v1 = int1.value()
+ v2 = int2.value()
+ try:
+ lab1.setValue(f"Value 1: {v1}")
+ except Exception:
+ pass
+ try:
+ lab2.setValue(f"Value 2: {v2}")
+ except Exception:
+ pass
+ logging.getLogger().debug("Int values: %s, %s", v1, v2)
+ except Exception:
+ logging.getLogger().exception("Failed to read intfield values")
+
+ except Exception as e:
+ logging.getLogger().exception("Error in IntField test: %s", e)
+
+
+if __name__ == '__main__':
+ if len(sys.argv) > 1:
+ test_intfield(sys.argv[1])
+ else:
+ test_intfield()
diff --git a/test/test_label_wrap.py b/test/test_label_wrap.py
new file mode 100644
index 0000000..5151396
--- /dev/null
+++ b/test/test_label_wrap.py
@@ -0,0 +1,103 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""Simple interactive test for YLabel auto-wrap and output field.
+
+Shows a dialog with three labels:
+- Normal label
+- Wrapped label (autoWrap=True) with 12-line long content
+- Output-field label (isOutputField=True)
+
+Run this test manually to visually verify wrapping behavior in the available backend.
+"""
+import os
+import sys
+import logging
+
+# allow running from repo root
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+
+try:
+ log_name = os.path.splitext(os.path.basename(__file__))[0] + '.log'
+ logFormatter = logging.Formatter('%(asctime)s %(name)s %(levelname)s: %(message)s')
+ root_logger = logging.getLogger()
+ fileHandler = logging.FileHandler(log_name, mode='w')
+ fileHandler.setFormatter(logFormatter)
+ root_logger.addHandler(fileHandler)
+ consoleHandler = logging.StreamHandler()
+ consoleHandler.setFormatter(logFormatter)
+ root_logger.addHandler(consoleHandler)
+ consoleHandler.setLevel(logging.INFO)
+ root_logger.setLevel(logging.DEBUG)
+except Exception as _e:
+ logging.getLogger().exception("Failed to configure file logger: %s", _e)
+
+from manatools.aui.yui import YUI, YUI_ui
+import manatools.aui.yui_common as yui
+
+
+def make_long_text(lines=12):
+ parts = [f"Line {i+1}: The quick brown fox jumps over the lazy dog." for i in range(lines)]
+ return "\n".join(parts)
+
+
+def test_label_wrap_example(backend_name=None):
+ if backend_name:
+ os.environ['YUI_BACKEND'] = backend_name
+
+ # Ensure fresh YUI detection
+ YUI._instance = None
+ YUI._backend = None
+
+ ui = YUI_ui()
+ factory = ui.widgetFactory()
+
+ try:
+ backend = YUI.backend()
+ root_logger.debug("test_label_wrap_example: program=%s backend=%s", os.path.basename(sys.argv[0]), getattr(backend, 'value', str(backend)))
+ except Exception:
+ root_logger.debug("test_label_wrap_example: program=%s backend=unknown", os.path.basename(sys.argv[0]))
+
+ dlg = factory.createMainDialog()
+ vbox = factory.createVBox(dlg)
+
+ # Normal label
+ lab1 = factory.createLabel(vbox, "First label: normal text.")
+
+ # Wrapped label with 12 lines
+ long_text = make_long_text(12)
+ lab2 = factory.createLabel(vbox, long_text)
+ try:
+ lab2.setAutoWrap(True)
+ except Exception:
+ pass
+
+ # Output-field label
+ lab3 = factory.createLabel(vbox, "Output-field label: non-editable.", isOutputField=True)
+
+ # ok/quit
+ ctrl_h = factory.createHBox(vbox)
+ ok_btn = factory.createPushButton(ctrl_h, "OK")
+
+ root_logger.info("Opening Label wrap example dialog...")
+
+ while True:
+ ev = dlg.waitForEvent()
+ et = ev.eventType()
+ if et == yui.YEventType.CancelEvent:
+ dlg.destroy()
+ break
+ elif et == yui.YEventType.WidgetEvent:
+ w = ev.widget()
+ reason = ev.reason()
+ if w == ok_btn and reason == yui.YEventReason.Activated:
+ dlg.destroy()
+ break
+
+ root_logger.info("Dialog closed")
+
+
+if __name__ == '__main__':
+ if len(sys.argv) > 1:
+ test_label_wrap_example(sys.argv[1])
+ else:
+ test_label_wrap_example()
diff --git a/test/test_logview.py b/test/test_logview.py
new file mode 100644
index 0000000..8930f4e
--- /dev/null
+++ b/test/test_logview.py
@@ -0,0 +1,101 @@
+#!/usr/bin/env python3
+
+import os
+import sys
+import logging
+
+# Add parent directory to path
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+
+try:
+ log_name = os.path.splitext(os.path.basename(__file__))[0] + '.log'
+ fh = logging.FileHandler(log_name, mode='w')
+ fh.setLevel(logging.DEBUG)
+ fh.setFormatter(logging.Formatter('%(asctime)s %(name)s %(levelname)s: %(message)s'))
+ root_logger = logging.getLogger()
+ root_logger.setLevel(logging.DEBUG)
+ existing = False
+ for h in list(root_logger.handlers):
+ try:
+ if isinstance(h, logging.FileHandler) and os.path.abspath(getattr(h, 'baseFilename', '')) == os.path.abspath(log_name):
+ existing = True
+ break
+ except Exception:
+ pass
+ if not existing:
+ root_logger.addHandler(fh)
+ print(f"Logging test output to: {os.path.abspath(log_name)}")
+except Exception as _e:
+ print(f"Failed to configure file logger: {_e}")
+
+
+def test_logview(backend_name=None):
+ """Interactive test for YLogView widget."""
+ if backend_name:
+ print(f"Setting backend to: {backend_name}")
+ os.environ['YUI_BACKEND'] = backend_name
+ else:
+ print("Using auto-detection")
+
+ try:
+ from manatools.aui.yui import YUI, YUI_ui
+ import manatools.aui.yui_common as yui
+
+ # Force re-detection
+ YUI._instance = None
+ YUI._backend = None
+
+ backend = YUI.backend()
+ print(f"Using backend: {backend.value}")
+
+ ui = YUI_ui()
+ factory = ui.widgetFactory()
+ dialog = factory.createMainDialog()
+
+ vbox = factory.createVBox(dialog)
+ factory.createHeading(vbox, "LogView Test")
+ factory.createLabel(vbox, f"Backend: {backend.value}")
+
+ # Create LogView
+ lv = factory.createLogView(vbox, "Log:", 10, 50)
+ lv.appendLines("Initial line 1\nInitial line 2\nLong line 3 that should be horizontally scrolled if the width is small enough that cannot be fully displayed without scrolling.")
+
+ # Buttons
+ h = factory.createHBox(vbox)
+ add_btn = factory.createPushButton(h, "Append 5 lines")
+ clear_btn = factory.createPushButton(h, "Clear")
+ close_btn = factory.createPushButton(h, "Close")
+
+ print("\nOpening LogView test dialog...")
+
+ counter = 0
+ while True:
+ ev = dialog.waitForEvent()
+ et = ev.eventType()
+ if et == yui.YEventType.CancelEvent:
+ break
+ elif et == yui.YEventType.WidgetEvent:
+ wdg = ev.widget()
+ reason = ev.reason()
+ if wdg == close_btn and reason == yui.YEventReason.Activated:
+ break
+ if wdg == clear_btn and reason == yui.YEventReason.Activated:
+ lv.clearText()
+ if wdg == add_btn and reason == yui.YEventReason.Activated:
+ lines = []
+ for i in range(5):
+ counter += 1
+ lines.append(f"line {counter}")
+ lv.appendLines("\n".join(lines))
+ dialog.destroy()
+ except Exception as e:
+ print(f"Error testing LogView with backend {backend_name}: {e}")
+ import traceback
+ traceback.print_exc()
+
+
+if __name__ == "__main__":
+ if len(sys.argv) > 1:
+ test_logview(sys.argv[1])
+ else:
+ test_logview()
diff --git a/test/test_menubar.py b/test/test_menubar.py
new file mode 100644
index 0000000..130cfa8
--- /dev/null
+++ b/test/test_menubar.py
@@ -0,0 +1,164 @@
+#!/usr/bin/env python3
+"""Interactive test for YMenuBar across backends.
+
+- Creates a dialog with a menu bar and a status label.
+- File menu: Open (enabled), Close (disabled), Exit.
+- Selecting Open disables Open and enables Close; selecting Close reverses.
+- Exit closes the dialog.
+- Edit menu: Copy, Paste, Cut.
+- More menu: submenus to verify nested menus.
+- OK button exits.
+"""
+import os
+import sys
+import logging
+
+# allow running from repo root
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+
+try:
+ log_name = os.path.splitext(os.path.basename(__file__))[0] + '.log'
+ logFormatter = logging.Formatter('%(asctime)s %(name)s %(levelname)s: %(message)s')
+ root_logger = logging.getLogger()
+ fileHandler = logging.FileHandler(log_name, mode='w')
+ fileHandler.setFormatter(logFormatter)
+ root_logger.addHandler(fileHandler)
+ consoleHandler = logging.StreamHandler()
+ consoleHandler.setFormatter(logFormatter)
+ root_logger.addHandler(consoleHandler)
+ consoleHandler.setLevel(logging.INFO)
+ root_logger.setLevel(logging.DEBUG)
+except Exception as _e:
+ logging.getLogger().exception("Failed to configure file logger: %s", _e)
+
+from manatools.aui.yui import YUI, YUI_ui
+import manatools.aui.yui_common as yui
+
+
+def test_menubar_example(backend_name=None):
+ if backend_name:
+ os.environ['YUI_BACKEND'] = backend_name
+
+ # Ensure fresh YUI detection
+ YUI._instance = None
+ YUI._backend = None
+
+ ui = YUI_ui()
+ factory = ui.widgetFactory()
+
+ # Log program name and detected backend
+ try:
+ backend = YUI.backend()
+ root_logger.debug("test_menubar_example: program=%s backend=%s", os.path.basename(sys.argv[0]), getattr(backend, 'value', str(backend)))
+ except Exception:
+ root_logger.debug("test_menubar_example: program=%s backend=unknown", os.path.basename(sys.argv[0]))
+
+ ui.app().setApplicationTitle(f"Menu Bar {backend.value} Test")
+ dlg = factory.createMainDialog()
+ minsize = factory.createMinSize(dlg, 640, 400)
+ vbox = factory.createVBox(minsize)
+
+ # Menu bar
+ menubar = factory.createMenuBar(vbox)
+
+ # File menu
+ file_menu = menubar.addMenu("&File", icon_name="application-menu")
+ item_open = menubar.addItem(file_menu, "Open", icon_name="document-open", enabled=True)
+ item_close = menubar.addItem(file_menu, "Close", icon_name="window-close", enabled=False)
+ file_menu.addSeparator()
+ item_exit = menubar.addItem(file_menu, "E&xit", icon_name="application-exit", enabled=True)
+
+ # Edit menu
+ edit_menu = menubar.addMenu("&Edit")
+ menubar.addItem(edit_menu, "&Copy", icon_name="edit-copy")
+ menubar.addItem(edit_menu, "Paste", icon_name="edit-paste")
+ menubar.addItem(edit_menu, "Cut", icon_name="edit-cut")
+
+ # More menu with submenus
+ more_menu = menubar.addMenu("More")
+ sub1 = more_menu.addMenu("Submenu 1")
+ sub1.addItem("One")
+ sub1.addItem("Two")
+ sub2 = more_menu.addMenu("Submenu 2")
+ sub2.addItem("Alpha")
+ sub2.addItem("Beta")
+ sub3 = sub2.addMenu("Submenu 2.1")
+ sub3.addItem("Info")
+ sub2.addSeparator()
+ enableSubMenu3 = sub2.addItem("Enable Submenu 3")
+ sub2.addSeparator()
+ to_change_menu = sub2.addItem("To Change menu")
+ # Hidden submenu for testing visibility
+ sub4 = more_menu.addMenu("Submenu 3")
+ sub4.addItem("Was Hidden")
+# menubar.setItemVisible(sub4 ,False)
+ sub4.setVisible(False)
+
+ change_menu = yui.YMenuItem("Change", is_menu = True)
+ to_more_menu = change_menu.addItem("To More menu")
+
+ menu1 = [file_menu, edit_menu, more_menu]
+ menu2 = [file_menu, edit_menu, change_menu]
+ # Status label
+ status_label = factory.createLabel(vbox, "Selected: (none)")
+
+ # OK button
+ ctrl_h = factory.createHBox(vbox)
+ ok_btn = factory.createPushButton(factory.createHCenter(ctrl_h), "&OK")
+
+ root_logger.info("Opening MenuBar example dialog...")
+
+ while True:
+ ev = dlg.waitForEvent()
+ et = ev.eventType()
+ if et == yui.YEventType.CancelEvent:
+ dlg.destroy()
+ break
+ elif et == yui.YEventType.WidgetEvent:
+ w = ev.widget()
+ reason = ev.reason()
+ if w == ok_btn and reason == yui.YEventReason.Activated:
+ dlg.destroy()
+ break
+ elif et == yui.YEventType.MenuEvent:
+ path = ev.id() if ev.id() else '(none)'
+ status_label.setValue(f"Selected: {path}")
+ item = ev.item()
+ if item is not None:
+ if item == item_open:
+ root_logger.debug("Menu item selected: File/Open")
+ menubar.setItemEnabled(item_open, False)
+ menubar.setItemEnabled(item_close, True)
+ elif item == item_close:
+ root_logger.debug("Menu item selected: File/Close")
+ menubar.setItemEnabled(item_open, True)
+ menubar.setItemEnabled(item_close, False)
+ elif item == item_exit:
+ root_logger.info("Menu item selected: File/Exit")
+ dlg.destroy()
+ break
+ elif item == enableSubMenu3:
+ root_logger.info(f"Menu item selected: {item.label()}")
+ #menubar.setItemVisible(sub4, not sub4.visible())
+ sub4.setVisible(not sub4.visible())
+ enableSubMenu3.setLabel("Disable Submenu 3" if sub4.visible() else "Enable Submenu 3")
+ menubar.rebuildMenus() # more_menu
+ elif item == to_change_menu:
+ root_logger.info(f"Menu item selected: {item.label()}")
+ menubar.deleteMenus()
+ for m in menu2:
+ menubar.addMenu(menu=m)
+ elif item == to_more_menu:
+ root_logger.info(f"Menu item selected: {item.label()}")
+ menubar.deleteMenus()
+ for m in menu1:
+ menubar.addMenu(menu=m)
+
+ root_logger.info("Dialog closed")
+
+
+if __name__ == '__main__':
+ if len(sys.argv) > 1:
+ test_menubar_example(sys.argv[1])
+ else:
+ test_menubar_example()
diff --git a/test/test_min_size.py b/test/test_min_size.py
new file mode 100644
index 0000000..b7faf69
--- /dev/null
+++ b/test/test_min_size.py
@@ -0,0 +1,99 @@
+#!/usr/bin/env python3
+
+import os
+import sys
+
+# Add parent directory to path
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+import logging
+
+# Configure file logger for this test: write DEBUG logs to '.log' in cwd
+try:
+ log_name = os.path.splitext(os.path.basename(__file__))[0] + '.log'
+ fh = logging.FileHandler(log_name, mode='w')
+ fh.setLevel(logging.DEBUG)
+ fh.setFormatter(logging.Formatter('%(asctime)s %(name)s %(levelname)s: %(message)s'))
+ root_logger = logging.getLogger()
+ root_logger.setLevel(logging.DEBUG)
+ existing = False
+ for h in list(root_logger.handlers):
+ try:
+ if isinstance(h, logging.FileHandler) and os.path.abspath(getattr(h, 'baseFilename', '')) == os.path.abspath(log_name):
+ existing = True
+ break
+ except Exception:
+ pass
+ if not existing:
+ root_logger.addHandler(fh)
+ print(f"Logging test output to: {os.path.abspath(log_name)}")
+except Exception as _e:
+ print(f"Failed to configure file logger: {_e}")
+
+
+def test_min_size(backend_name=None):
+ if backend_name:
+ print(f"Setting backend to: {backend_name}")
+ os.environ['YUI_BACKEND'] = backend_name
+ else:
+ print("Using auto-detection")
+
+ try:
+ from manatools.aui.yui import YUI, YUI_ui
+ import manatools.aui.yui_common as yui
+
+ # Force re-detection
+ YUI._instance = None
+ YUI._backend = None
+
+ backend = YUI.backend()
+ print(f"Using backend: {backend.value}")
+
+ ui = YUI_ui()
+ factory = ui.widgetFactory()
+ dialog = factory.createPopupDialog()
+
+ # Try to set dialog minimum size (600x400 px) using generic API if available
+ mindialogsize = factory.createMinSize(dialog, 600, 400)
+
+
+ vbox = factory.createVBox(mindialogsize)
+ factory.createHeading(vbox, "Min-size test")
+
+ # create alignment that forces child to min 100x100 px
+ a = factory.createMinSize(vbox, 100, 100)
+ m = factory.createMultiLineEdit(a, "Multiline")
+
+ # OK/Close
+ hbox = factory.createHBox(vbox)
+ ok = factory.createPushButton(hbox, "OK")
+ close = factory.createPushButton(hbox, "Close")
+
+ dialog.open()
+ while True:
+ event = dialog.waitForEvent()
+ if not event:
+ continue
+ typ = event.eventType()
+ if typ == yui.YEventType.CancelEvent:
+ dialog.destroy()
+ break
+ elif typ == yui.YEventType.WidgetEvent:
+ wdg = event.widget()
+ if wdg == close:
+ dialog.destroy()
+ break
+ elif wdg == ok:
+ dialog.destroy()
+ break
+
+ except Exception as e:
+ print(f"Error testing min size with backend {backend_name}: {e}")
+ import traceback
+ traceback.print_exc()
+
+
+if __name__ == "__main__":
+ if len(sys.argv) > 1:
+ test_min_size(sys.argv[1])
+ else:
+ test_min_size()
diff --git a/test/test_multi_backend.py b/test/test_multi_backend.py
new file mode 100644
index 0000000..22dd53b
--- /dev/null
+++ b/test/test_multi_backend.py
@@ -0,0 +1,143 @@
+#!/usr/bin/env python3
+
+import os
+import sys
+import time
+
+# Add parent directory to path
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+
+def test_backend(backend_name=None):
+ """Test a specific backend"""
+ if backend_name:
+ print(f"Setting backend to: {backend_name}")
+ os.environ['YUI_BACKEND'] = backend_name
+ else:
+ print("Using auto-detection")
+
+ try:
+ from manatools.aui.yui import YUI, YUI_ui
+ import manatools.aui.yui_common as yui
+
+ # Force re-detection
+ YUI._instance = None
+ YUI._backend = None
+
+ backend = YUI.backend()
+ print(f"Using backend: {backend.value}")
+
+ ui = YUI_ui()
+ factory = ui.widgetFactory()
+
+ # Create dialog
+ dialog = factory.createMainDialog()
+ vbox = factory.createVBox(dialog)
+
+ # Add widgets - SAME LAYOUT FOR ALL BACKENDS
+ factory.createHeading(vbox, f"manatools AUI {backend.value.upper()} Test")
+ factory.createLabel(vbox, f"Backend: {backend.value}")
+
+ if backend.value == 'ncurses':
+ factory.createLabel(vbox, "ComboBox Test: Use SPACE to expand")
+ factory.createLabel(vbox, "Then use ARROWS and ENTER to select")
+
+ # Input fields: Username and Password (password mode)
+ input_field = factory.createInputField(vbox, "Username")
+ password_field = factory.createInputField(vbox, "Password", True)
+
+ # ComboBox - NEW WIDGET
+ combo = factory.createComboBox(vbox, "Select option:", False)
+ combo.addItem("Option 1")
+ combo.addItem("Option 2")
+ combo.addItem("Option 3")
+ combo.addItem("Option 4")
+ combo.addItem("Option 5")
+ combo.addItem("Option 6")
+
+ # Checkboxes
+ checkbox = factory.createCheckBox(vbox, "Enable features")
+
+ # Buttons
+ selected = factory.createLabel(vbox, "")
+ hbox = factory.createHBox(vbox)
+ ok_button = factory.createPushButton(hbox, "OK")
+ cancel_button = factory.createPushButton(hbox, "Cancel")
+
+ print("Opening dialog...")
+
+ while True:
+ event = dialog.waitForEvent()
+ typ = event.eventType()
+ if typ == yui.YEventType.CancelEvent:
+ dialog.destroy()
+ break
+ elif typ == yui.YEventType.WidgetEvent:
+ wdg = event.widget()
+ if wdg == cancel_button:
+ dialog.destroy()
+ break
+ elif wdg == combo:
+ selected.setText(f"Selected: '{combo.value()}'")
+ elif wdg == ok_button:
+ selected.setText(f"OK clicked. - user='{input_field.value()}' password='{password_field.value()}'")
+ elif wdg == checkbox:
+ selected.setText(f"{checkbox.label()} - {checkbox.value()}")
+
+ # Show results after dialog closes
+ print(f"Dialog closed.")
+
+ except Exception as e:
+ print(f"Error with backend {backend_name}: {e}")
+ import traceback
+ traceback.print_exc()
+
+def test_all_backends():
+ """Test all available backends"""
+ backends_to_test = []
+
+ # Check which backends are available
+ # Require PySide6 (Qt6)
+ try:
+ import PySide6.QtWidgets
+ backends_to_test.append('qt')
+ print("✓ Qt backend available (PySide6)")
+ except ImportError:
+ print("✗ Qt backend not available (PySide6 required)")
+
+ try:
+ import gi
+ gi.require_version('Gtk', '4.0')
+ from gi.repository import Gtk
+ backends_to_test.append('gtk')
+ print("✓ GTK backend available (GTK4)")
+ except (ImportError, ValueError) as e:
+ print(f"✗ GTK backend not available: {e}")
+
+ try:
+ import curses
+ backends_to_test.append('ncurses')
+ print("✓ NCurses backend available")
+ except ImportError as e:
+ print(f"✗ NCurses backend not available: {e}")
+
+ print(f"\nAvailable backends: {backends_to_test}")
+
+ for backend in backends_to_test:
+ print(f"\n{'='*60}")
+ print(f"Testing {backend.upper()} backend")
+ print(f"{'='*60}")
+
+ if backend == 'ncurses':
+ print("\nNCurses ComboBox: Use SPACE to expand, arrows to navigate, ENTER to select")
+ input("Press Enter to start...")
+
+ test_backend(backend)
+
+ if backend != 'ncurses' and backends_to_test.index(backend) < len(backends_to_test) - 1:
+ input("Press Enter to test next backend...")
+
+if __name__ == "__main__":
+ if len(sys.argv) > 1:
+ test_backend(sys.argv[1])
+ else:
+ test_all_backends()
diff --git a/test/test_multilineedit.py b/test/test_multilineedit.py
new file mode 100644
index 0000000..f40b24f
--- /dev/null
+++ b/test/test_multilineedit.py
@@ -0,0 +1,97 @@
+#!/usr/bin/env python3
+"""Interactive test for YMultiLineEdit across backends.
+
+- Creates a multiline edit and an OK button to exit.
+- Logs value-changed notifications and final value.
+"""
+import os
+import sys
+import logging
+
+# Ensure project root on PYTHONPATH
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+
+try:
+ log_name = os.path.splitext(os.path.basename(__file__))[0] + '.log'
+ logFormatter = logging.Formatter('%(asctime)s %(name)s %(levelname)s: %(message)s')
+ root_logger = logging.getLogger()
+ fileHandler = logging.FileHandler(log_name, mode='w')
+ fileHandler.setFormatter(logFormatter)
+ root_logger.addHandler(fileHandler)
+ consoleHandler = logging.StreamHandler()
+ consoleHandler.setFormatter(logFormatter)
+ root_logger.addHandler(consoleHandler)
+ consoleHandler.setLevel(logging.INFO)
+ root_logger.setLevel(logging.DEBUG)
+except Exception as _e:
+ logging.getLogger().exception("Failed to configure file logger: %s", _e)
+
+
+def test_multilineedit(backend_name=None):
+ if backend_name:
+ os.environ['YUI_BACKEND'] = backend_name
+ logging.getLogger().info("Set backend to %s", backend_name)
+ else:
+ logging.getLogger().info("Using auto-detection")
+ try:
+ from manatools.aui.yui import YUI, YUI_ui
+ import manatools.aui.yui_common as yui
+
+ # force re-detection
+ YUI._instance = None
+ YUI._backend = None
+
+ backend = YUI.backend()
+ logging.getLogger().info("Using backend: %s", backend.value)
+
+ ui = YUI_ui()
+ factory = ui.widgetFactory()
+
+ ui.application().setApplicationTitle(f"Test {backend.value} MultiLineEdit")
+ dlg = factory.createPopupDialog()
+ v = factory.createVBox(dlg)
+
+ mled = factory.createMultiLineEdit(v, "Notes")
+ mled.setStretchable(yui.YUIDimension.YD_VERT, True)
+ mled.setStretchable(yui.YUIDimension.YD_HORIZ, True)
+ ok = factory.createPushButton(v, "OK")
+
+ while True:
+ ev = dlg.waitForEvent()
+ if not ev:
+ continue
+ if ev.eventType() == yui.YEventType.CancelEvent:
+ dlg.destroy()
+ break
+ if ev.eventType() == yui.YEventType.WidgetEvent:
+ w = ev.widget()
+ try:
+ logging.getLogger().debug("WidgetEvent from %s", getattr(w, 'widgetClass', lambda: '?')())
+ except Exception:
+ pass
+ if w == ok:
+ try:
+ logging.getLogger().info("Final value:\n%s", mled.value())
+ except Exception:
+ logging.getLogger().exception("Failed to read final value")
+ dlg.destroy()
+ break
+ # Log value-changed notifications
+ try:
+ if hasattr(w, 'widgetClass') and w.widgetClass() == 'YMultiLineEdit':
+ try:
+ logging.getLogger().info("ValueChanged notification: %s", w.value())
+ except Exception:
+ logging.getLogger().exception("Failed to read multiline value on notification")
+ except Exception:
+ logging.getLogger().exception("Error handling widget event")
+
+ except Exception as e:
+ logging.getLogger().exception("Error in MultiLineEdit test: %s", e)
+
+
+if __name__ == '__main__':
+ if len(sys.argv) > 1:
+ test_multilineedit(sys.argv[1])
+ else:
+ test_multilineedit()
diff --git a/test/test_multiselection_tree.py b/test/test_multiselection_tree.py
new file mode 100644
index 0000000..07c964c
--- /dev/null
+++ b/test/test_multiselection_tree.py
@@ -0,0 +1,107 @@
+#!/usr/bin/env python3
+
+import os
+import sys
+
+# Add parent directory to path
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+
+def test_tree(backend_name=None):
+ """Test ComboBox widget specifically"""
+ if backend_name:
+ print(f"Setting backend to: {backend_name}")
+ os.environ['YUI_BACKEND'] = backend_name
+ else:
+ print("Using auto-detection")
+
+ try:
+ from manatools.aui.yui import YUI, YUI_ui
+ import manatools.aui.yui_common as yui
+
+ # Force re-detection
+ YUI._instance = None
+ YUI._backend = None
+
+ backend = YUI.backend()
+ print(f"Using backend: {backend.value}")
+
+ ui = YUI_ui()
+ factory = ui.widgetFactory()
+ title = ui.application().applicationTitle()
+ ui.application().setApplicationTitle("Tree Widget (multi selection) Application")
+
+ # Create dialog focused on ComboBox testing
+ dialog = factory.createMainDialog()
+ vbox = factory.createVBox(dialog)
+
+ factory.createHeading(vbox, "Tree Test")
+ factory.createLabel(vbox, f"Backend: {backend.value}")
+ factory.createLabel(vbox, "Test selecting and displaying values")
+
+ # Test ComboBox with initial selection
+ factory.createLabel(vbox, "")
+ hbox = factory.createHBox(vbox)
+ tree = factory.createTree(hbox, "Select:", multiselection=True)
+
+ for i in range(5):
+ item = yui.YTreeItem(f"Item {i+1}", is_open=(i==0))
+ for j in range(3):
+ subitem = yui.YTreeItem(f"SubItem {i+1}.{j+1}", parent=item)
+ if i==1 and j == 1:
+ for k in range(2):
+ yui.YTreeItem(f"SubItem {i+1}.{j+1}.{k+1}", parent=subitem)
+
+ tree.addItem(item)
+
+ selected = factory.createLabel(vbox, "Selected:")
+ hbox = factory.createHBox(vbox)
+ ok_button = factory.createPushButton(hbox, "OK")
+ cancel_button = factory.createPushButton(hbox, "Cancel")
+
+ print("\nOpening ComboBox test dialog...")
+
+ while True:
+ event = dialog.waitForEvent()
+ typ = event.eventType()
+ if typ == yui.YEventType.CancelEvent:
+ dialog.destroy()
+ break
+ elif typ == yui.YEventType.WidgetEvent:
+ wdg = event.widget()
+ reason = event.reason()
+ if wdg == cancel_button:
+ dialog.destroy()
+ break
+ elif wdg == tree:
+ if reason == yui.YEventReason.SelectionChanged:
+ if tree.hasMultiSelection():
+ labels = [item.label() for item in tree.selectedItems()]
+ selected.setText(f"Selected: {labels}")
+ elif tree.selectedItem() is not None:
+ selected.setText(f"Selected: '{tree.selectedItem().label()}'")
+ else:
+ selected.setText(f"Selected: None")
+ elif reason == yui.YEventReason.Activated:
+ if tree.selectedItem() is not None:
+ selected.setText(f"Activated: '{tree.selectedItem().label()}'")
+ else:
+ selected.setText(f"Activated: None")
+ elif wdg == ok_button:
+ selected.setText(f"OK clicked.")
+
+ # Show final result
+ if tree.selectedItem() is not None:
+ print(f"\nFinal Tree value: '{tree.selectedItem().label()}'")
+
+ except Exception as e:
+ print(f"Error testing Tree with backend {backend_name}: {e}")
+ import traceback
+ traceback.print_exc()
+ finally:
+ ui.application().setApplicationTitle(title)
+
+if __name__ == "__main__":
+ if len(sys.argv) > 1:
+ test_tree(sys.argv[1])
+ else:
+ test_tree()
diff --git a/test/test_paned.py b/test/test_paned.py
new file mode 100644
index 0000000..086b3b4
--- /dev/null
+++ b/test/test_paned.py
@@ -0,0 +1,161 @@
+# vim: set fileencoding=utf-8 :
+# vim: set et ts=4 sw=4:
+"""
+Test script for YPaned using the YUI abstraction (inspired by test_frame.py).
+
+- Horizontal paned: Tree widget + Table.
+- Vertical paned: RichText + Table.
+- Dialog includes a push button to quit.
+
+No backend-specific fallback; if it fails, fix the widget implementation.
+"""
+
+import os
+import sys
+import logging
+
+# Add parent directory to path
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+
+try:
+ log_name = os.path.splitext(os.path.basename(__file__))[0] + '.log'
+ fh = logging.FileHandler(log_name, mode='w')
+ fh.setLevel(logging.DEBUG)
+ fh.setFormatter(logging.Formatter('%(asctime)s %(name)s %(levelname)s: %(message)s'))
+ root_logger = logging.getLogger()
+ root_logger.setLevel(logging.DEBUG)
+ existing = False
+ for h in list(root_logger.handlers):
+ try:
+ if isinstance(h, logging.FileHandler) and os.path.abspath(getattr(h, 'baseFilename', '')) == os.path.abspath(log_name):
+ existing = True
+ break
+ except Exception:
+ pass
+ if not existing:
+ root_logger.addHandler(fh)
+ print(f"Logging test output to: {os.path.abspath(log_name)}")
+except Exception as _e:
+ print(f"Failed to configure file logger: {_e}")
+
+def test_paned(backend_name=None):
+ """Interactive test showcasing YDumbTab with three tabs and a ReplacePoint."""
+ if backend_name:
+ print(f"Setting backend to: {backend_name}")
+ os.environ['YUI_BACKEND'] = backend_name
+ else:
+ print("Using auto-detection")
+
+ try:
+ from manatools.aui.yui import YUI, YUI_ui
+ import manatools.aui.yui_common as yui
+
+ # Force re-detection
+ YUI._instance = None
+ YUI._backend = None
+
+ backend = YUI.backend()
+ print(f"Using backend: {backend.value}")
+
+ # Basic logging for diagnosis
+ import logging
+ logging.basicConfig(level=logging.DEBUG, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
+
+ ui = YUI_ui()
+ factory = ui.widgetFactory()
+ dialog = factory.createMainDialog()
+ mainVbox = factory.createVBox( dialog )
+ paned_h = factory.createPaned(mainVbox, yui.YUIDimension.YD_HORIZ)
+ tree = factory.createTree(paned_h, "Test Tree")
+ tree.setStretchable(yui.YUIDimension.YD_HORIZ, True)
+ tree.setStretchable(yui.YUIDimension.YD_VERT, True)
+ items = []
+ for i in range(1, 6):
+ itm = yui.YTreeItem(f"Item {i}", is_open=(i == 1))
+ for j in range(1, 4):
+ sub = yui.YTreeItem(f"SubItem {i}.{j}", parent=itm)
+ for k in range(1, 3):
+ yui.YTreeItem(f"SubItem {i}.{j}.{k}", parent=sub)
+ items.append(itm)
+ tree.addItem(itm)
+ header = yui.YTableHeader()
+ header.addColumn('num.', alignment=yui.YAlignmentType.YAlignEnd)
+ header.addColumn('document', alignment=yui.YAlignmentType.YAlignBegin)
+ table_h = factory.createTable(paned_h, header)
+ table_h.setStretchable(yui.YUIDimension.YD_HORIZ, True)
+ table_h.setStretchable(yui.YUIDimension.YD_VERT, True)
+ items = []
+ for i in range(1, 7):
+ itm = yui.YTableItem(f"Row {i}")
+ itm.addCell(str(i))
+ itm.addCell(f"test_{i}")
+ items.append(itm)
+ table_h.addItem(itm)
+ paned_v = factory.createPaned(mainVbox, yui.YUIDimension.YD_VERT)
+ rich = factory.createRichText(
+ paned_v,
+ "RichText sample
Visit: https://example.org",
+ )
+ rich.setStretchable(yui.YUIDimension.YD_HORIZ, True)
+ rich.setStretchable(yui.YUIDimension.YD_VERT, True)
+ header = yui.YTableHeader()
+ header.addColumn('num.', alignment=yui.YAlignmentType.YAlignEnd)
+ header.addColumn('info', alignment=yui.YAlignmentType.YAlignBegin)
+ header.addColumn('', checkBox=True, alignment=yui.YAlignmentType.YAlignCenter)
+ table_v = factory.createTable(paned_v, header)
+ table_v.setStretchable(yui.YUIDimension.YD_HORIZ, True)
+ table_v.setStretchable(yui.YUIDimension.YD_VERT, True)
+ items = []
+ for i in range(1, 7):
+ itm = yui.YTableItem(f"Row {i}")
+ itm.addCell(str(i))
+ itm.addCell(f"test {i}")
+ # third column is checkbox column
+ itm.addCell(False if i % 2 == 0 else True)
+ items.append(itm)
+ table_v.addItem(itm)
+ btn_quit = factory.createPushButton(mainVbox, "Quit")
+
+ #
+ # Event loop
+ #
+ while True:
+ event = dialog.waitForEvent()
+ if not event:
+ print("Empty")
+ next
+ typ = event.eventType()
+ if typ == yui.YEventType.CancelEvent:
+ dialog.destroy()
+ break
+ elif typ == yui.YEventType.WidgetEvent:
+ wdg = event.widget()
+ if wdg == btn_quit:
+ dialog.destroy()
+ break
+ if wdg == tree:
+ sel = tree.selectedItem()
+ if sel:
+ root_logger.debug(f"Tree selection changed: {sel.label()}")
+ elif wdg == table_h:
+ sel = table_h.selectedItem()
+ if sel:
+ root_logger.debug(f"Horizontal Table selection changed: {sel.label(0)} {sel.label(1)}")
+ elif wdg == table_v:
+ sel = table_v.selectedItem()
+ if sel:
+ root_logger.debug(f"Vertical Table selection changed: {sel.label(0)} {sel.label(1)}")
+
+ else:
+ print(f"Unhandled event type: {typ}")
+
+ except Exception as e:
+ print(f"Error testing Paned with backend {backend_name}: {e}")
+ import traceback
+ traceback.print_exc()
+
+if __name__ == "__main__":
+ if len(sys.argv) > 1:
+ test_paned(sys.argv[1])
+ else:
+ test_paned()
\ No newline at end of file
diff --git a/test/test_progressbar.py b/test/test_progressbar.py
new file mode 100644
index 0000000..4bb5c2c
--- /dev/null
+++ b/test/test_progressbar.py
@@ -0,0 +1,89 @@
+#!/usr/bin/env python3
+
+import os
+import sys
+
+# Add parent directory to path
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+
+
+def test_progressbar(backend_name=None):
+ """Test simple dialog with a progress bar and OK button"""
+ if backend_name:
+ print(f"Setting backend to: {backend_name}")
+ os.environ['YUI_BACKEND'] = backend_name
+ else:
+ print("Using auto-detection")
+ try:
+ import time
+ from manatools.aui.yui import YUI, YUI_ui
+ from manatools.aui.yui_common import YTimeoutEvent
+
+ backend = YUI.backend()
+ print(f"Using backend: {backend.value}")
+
+ ui = YUI_ui()
+ factory = ui.widgetFactory()
+ ui.application().setApplicationTitle(f"Progress bar {backend.value} application")
+ dialog = factory.createMainDialog()
+
+ vbox = factory.createVBox(dialog)
+ pb = factory.createProgressBar(vbox, "Progress", 100)
+
+ # ensure initial value is 0
+ pb.setValue(0)
+
+ factory.createPushButton(vbox, "OK")
+ dialog.open()
+
+ # Main loop: wait 500ms, increment progress by 1 on timeout.
+ # When reaching 100: wait 1s (allowing event handling), reset to 0,
+ # then wait 3s before restarting counting. Any non-timeout event (e.g. OK)
+ # will break the loop and close the dialog.
+ value = 0
+ timeout_ms = 100
+ phase = 0 # Normal counting
+ while True:
+ ev = dialog.waitForEvent(timeout_ms)
+ if isinstance(ev, YTimeoutEvent):
+ # increment
+ if phase == 0:
+ value = min(100, value + 1)
+ elif phase == 1:
+ YUI.app().busyCursor()
+ value = 0
+ timeout_ms = 3000
+ phase = 3 # Waiting before restart
+ elif phase == 3:
+ YUI.app().normalCursor()
+ value = 1
+ timeout_ms = 100
+ phase = 0 # Normal counting
+ try:
+ pb.setValue(value)
+ except Exception:
+ try:
+ pb.setProperty('value', value)
+ except Exception:
+ pass
+
+ if value >= 100:
+ # wait 1 second (still via waitForEvent to allow user interaction)
+ timeout_ms = 1000
+ phase = 1 # Waiting for reset
+ else:
+ # any other event (button press, close) break
+ break
+
+ dialog.destroy()
+
+ except Exception as e:
+ print(f"Error testing ProgressBar with backend {backend_name}: {e}")
+ import traceback
+ traceback.print_exc()
+
+if __name__ == "__main__":
+ if len(sys.argv) > 1:
+ test_progressbar(sys.argv[1])
+ else:
+ test_progressbar()
diff --git a/test/test_radiobutton.py b/test/test_radiobutton.py
new file mode 100644
index 0000000..69f6fa7
--- /dev/null
+++ b/test/test_radiobutton.py
@@ -0,0 +1,84 @@
+#!/usr/bin/env python3
+
+import os
+import sys
+
+# Add parent directory to path
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+
+
+def test_radiobutton(backend_name=None):
+ """Test dialog with 3 radio buttons, a label showing selection, and OK button"""
+ if backend_name:
+ print(f"Setting backend to: {backend_name}")
+ os.environ['YUI_BACKEND'] = backend_name
+ else:
+ print("Using auto-detection")
+
+ try:
+ import time
+ from manatools.aui.yui import YUI, YUI_ui
+ from manatools.aui.yui_common import YTimeoutEvent, YWidgetEvent, YCancelEvent
+
+ backend = YUI.backend()
+ print(f"Using backend: {backend.value}")
+
+ ui = YUI_ui()
+ factory = ui.widgetFactory()
+ ui.application().setApplicationTitle(f"RadioButton {backend.value} test")
+
+ dialog = factory.createMainDialog()
+ vbox = factory.createVBox(dialog)
+
+ # First radio group (3 options)
+ frame = factory.createFrame(vbox, "Options")
+ inner1 = factory.createVBox(frame)
+
+ rb1 = factory.createRadioButton(inner1, "Option 1", False)
+ rb2 = factory.createRadioButton(inner1, "Option 2", True)
+ rb3 = factory.createRadioButton(inner1, "Option 3", False)
+ selected_label1 = factory.createLabel(vbox, "Selected: Option 2")
+
+ # Second radio group (2 test options)
+ frame2 = factory.createFrame(vbox, "Tests")
+ inner2 = factory.createVBox(frame2)
+ tr1 = factory.createRadioButton(inner2, "Test 1", True)
+ tr2 = factory.createRadioButton(inner2, "Test 2", False)
+
+ selected_label2 = factory.createLabel(vbox, "Selected: Test 1")
+
+ ok_button = factory.createPushButton(vbox, "OK")
+
+ dialog.open()
+
+ # Event loop: wait for events; on widget events update the label;
+ # OK button closes the dialog.
+ while True:
+ ev = dialog.waitForEvent(500)
+ if isinstance(ev, YTimeoutEvent):
+ continue
+ if isinstance(ev, YCancelEvent):
+ break
+ if isinstance(ev, YWidgetEvent):
+ w = ev.widget()
+ # If OK pressed -> exit
+ if w == ok_button:
+ break
+ elif w in (rb1, rb2, rb3):
+ selected_label1.setText(f"Selected: {w.label()}")
+ elif w in (tr1, tr2):
+ selected_label2.setText(f"Selected: {w.label()}")
+
+ dialog.destroy()
+
+ except Exception as e:
+ print(f"Error testing RadioButton with backend {backend_name}: {e}")
+ import traceback
+ traceback.print_exc()
+
+
+if __name__ == "__main__":
+ if len(sys.argv) > 1:
+ test_radiobutton(sys.argv[1])
+ else:
+ test_radiobutton()
diff --git a/test/test_recursiveselection_tree.py b/test/test_recursiveselection_tree.py
new file mode 100644
index 0000000..67d33d1
--- /dev/null
+++ b/test/test_recursiveselection_tree.py
@@ -0,0 +1,107 @@
+#!/usr/bin/env python3
+
+import os
+import sys
+
+# Add parent directory to path
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+
+def test_tree(backend_name=None):
+ """Test ComboBox widget specifically"""
+ if backend_name:
+ print(f"Setting backend to: {backend_name}")
+ os.environ['YUI_BACKEND'] = backend_name
+ else:
+ print("Using auto-detection")
+
+ try:
+ from manatools.aui.yui import YUI, YUI_ui
+ import manatools.aui.yui_common as yui
+
+ # Force re-detection
+ YUI._instance = None
+ YUI._backend = None
+
+ backend = YUI.backend()
+ print(f"Using backend: {backend.value}")
+
+ ui = YUI_ui()
+ factory = ui.widgetFactory()
+ title = ui.application().applicationTitle()
+ ui.application().setApplicationTitle("Tree Widget (recursive selection) Application")
+
+ # Create dialog focused on ComboBox testing
+ dialog = factory.createMainDialog()
+ vbox = factory.createVBox(dialog)
+
+ factory.createHeading(vbox, "Tree Test")
+ factory.createLabel(vbox, f"Backend: {backend.value}")
+ factory.createLabel(vbox, "Test selecting and displaying values")
+
+ # Test ComboBox with initial selection
+ factory.createLabel(vbox, "")
+ hbox = factory.createHBox(vbox)
+ tree = factory.createTree(hbox, "Select:", recursiveselection=True)
+
+ for i in range(5):
+ item = yui.YTreeItem(f"Item {i+1}", is_open=(i==0))
+ for j in range(3):
+ subitem = yui.YTreeItem(f"SubItem {i+1}.{j+1}", parent=item)
+ if i==1 and j == 1:
+ for k in range(2):
+ yui.YTreeItem(f"SubItem {i+1}.{j+1}.{k+1}", parent=subitem)
+
+ tree.addItem(item)
+
+ selected = factory.createLabel(vbox, "Selected:")
+ hbox = factory.createHBox(vbox)
+ ok_button = factory.createPushButton(hbox, "OK")
+ cancel_button = factory.createPushButton(hbox, "Cancel")
+
+ print("\nOpening ComboBox test dialog...")
+
+ while True:
+ event = dialog.waitForEvent()
+ typ = event.eventType()
+ if typ == yui.YEventType.CancelEvent:
+ dialog.destroy()
+ break
+ elif typ == yui.YEventType.WidgetEvent:
+ wdg = event.widget()
+ reason = event.reason()
+ if wdg == cancel_button:
+ dialog.destroy()
+ break
+ elif wdg == tree:
+ if reason == yui.YEventReason.SelectionChanged:
+ if tree.hasMultiSelection():
+ labels = [item.label() for item in tree.selectedItems()]
+ selected.setText(f"Selected: {labels}")
+ elif tree.selectedItem() is not None:
+ selected.setText(f"Selected: '{tree.selectedItem().label()}'")
+ else:
+ selected.setText(f"Selected: None")
+ elif reason == yui.YEventReason.Activated:
+ if tree.selectedItem() is not None:
+ selected.setText(f"Activated: '{tree.selectedItem().label()}'")
+ else:
+ selected.setText(f"Activated: None")
+ elif wdg == ok_button:
+ selected.setText(f"OK clicked.")
+
+ # Show final result
+ if tree.selectedItem() is not None:
+ print(f"\nFinal Tree value: '{tree.selectedItem().label()}'")
+
+ except Exception as e:
+ print(f"Error testing Tree with backend {backend_name}: {e}")
+ import traceback
+ traceback.print_exc()
+ finally:
+ ui.application().setApplicationTitle(title)
+
+if __name__ == "__main__":
+ if len(sys.argv) > 1:
+ test_tree(sys.argv[1])
+ else:
+ test_tree()
diff --git a/test/test_replacePoint.py b/test/test_replacePoint.py
new file mode 100644
index 0000000..b5e6ad6
--- /dev/null
+++ b/test/test_replacePoint.py
@@ -0,0 +1,117 @@
+#!/usr/bin/env python3
+
+import os
+import sys
+import logging
+
+# Add parent directory to path
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+
+try:
+ log_name = os.path.splitext(os.path.basename(__file__))[0] + '.log'
+ logFormatter = logging.Formatter('%(asctime)s %(name)s %(levelname)s: %(message)s')
+ root_logger = logging.getLogger()
+ fileHandler = logging.FileHandler(log_name, mode='w')
+ fileHandler.setFormatter(logFormatter)
+ root_logger.addHandler(fileHandler)
+ consoleHandler = logging.StreamHandler()
+ consoleHandler.setFormatter(logFormatter)
+ root_logger.addHandler(consoleHandler)
+ consoleHandler.setLevel(logging.INFO)
+ root_logger.setLevel(logging.DEBUG)
+except Exception as _e:
+ logging.getLogger().exception("Failed to configure file logger: %s", _e)
+
+def test_replace_point(backend_name=None):
+ """Interactive test for ReplacePoint widget across backends.
+
+ - Creates a dialog with a ReplacePoint
+ - Adds an initial child layout and shows it
+ - Replaces it at runtime using deleteChildren() and showChild()
+ """
+ if backend_name:
+ root_logger.info("Setting backend to: %s", backend_name)
+ os.environ['YUI_BACKEND'] = backend_name
+ else:
+ root_logger.info("Using auto-detection")
+ try:
+ from manatools.aui.yui import YUI, YUI_ui
+ import manatools.aui.yui_common as yui
+
+ # Force re-detection
+ YUI._instance = None
+ YUI._backend = None
+
+ backend = YUI.backend()
+ root_logger.info("Using backend: %s", backend.value)
+
+ ui = YUI_ui()
+ factory = ui.widgetFactory()
+
+ ui.application().setApplicationTitle(f"Test {backend.value} ReplacePoint")
+ dialog = factory.createPopupDialog()
+ mainVbox = factory.createVBox(dialog)
+ frame = factory.createFrame(mainVbox, "Replace Point here")
+
+ rp = factory.createReplacePoint(frame)
+
+ # Initial child layout
+ vbox1 = factory.createVBox(rp)
+ # hint: allow vertical stretch to avoid collapsed area in some backends
+ import manatools.aui.yui_common as yui
+ try:
+ vbox1.setStretchable(yui.YUIDimension.YD_VERT, True)
+ except Exception:
+ pass
+ label1 = factory.createLabel(vbox1, "Initial child layout")
+ value_btn1 = factory.createPushButton(vbox1, "Value 1")
+ #rp.showChild()
+
+ hbox = factory.createHBox(mainVbox)
+ replace_button = factory.createPushButton(hbox, "Replace child")
+ close_button = factory.createPushButton(hbox, "Close")
+ n_call = 0
+ while True:
+ event = dialog.waitForEvent()
+ if not event:
+ continue
+ typ = event.eventType()
+ if typ == yui.YEventType.CancelEvent:
+ dialog.destroy()
+ break
+ elif typ == yui.YEventType.WidgetEvent:
+ wdg = event.widget()
+ try:
+ root_logger.info("WidgetEvent from: %s", getattr(wdg, "widgetClass", lambda: "?")())
+ except Exception:
+ pass
+ if wdg == close_button:
+ dialog.destroy()
+ break
+ elif wdg == replace_button:
+ # Replace the child content dynamically
+ n_call = (n_call + 1) % 100
+ root_logger.info("Replacing child layout iteration=%d", n_call)
+ rp.deleteChildren()
+ vbox2 = factory.createVBox(rp)
+ try:
+ vbox2.setStretchable(yui.YUIDimension.YD_VERT, True)
+ except Exception:
+ pass
+ label2 = factory.createLabel(vbox2, f"Replaced child layout ({n_call})")
+ value_btn2 = factory.createPushButton(vbox2, f"Value ({n_call})")
+ rp.showChild()
+ elif wdg == value_btn1:
+ # no-op; just ensure button works
+ root_logger.info("Value 1 clicked")
+ else:
+ # Handle events from new child too
+ root_logger.debug("Unhandled widget event")
+ except Exception as e:
+ root_logger.error("Error testing ReplacePoint with backend %s: %s", backend_name, e, exc_info=True)
+
+if __name__ == "__main__":
+ if len(sys.argv) > 1:
+ test_replace_point(sys.argv[1])
+ else:
+ test_replace_point()
diff --git a/test/test_richtext.py b/test/test_richtext.py
new file mode 100644
index 0000000..0760c7a
--- /dev/null
+++ b/test/test_richtext.py
@@ -0,0 +1,131 @@
+#!/usr/bin/env python3
+
+"""Example dialog to manually test YRichText widget.
+
+Layout:
+- VBox with heading and a RichText widget showing HTML.
+- Label displays last activated link.
+- OK button closes the dialog.
+
+Run with: `python -m pytest -q test/test_richtext.py::test_richtext_example -s` or run directly.
+"""
+
+import os
+import sys
+
+# allow running from repo root
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+import logging
+
+# Configure file logger for this test: write DEBUG logs to '.log' in cwd
+try:
+ log_name = os.path.splitext(os.path.basename(__file__))[0] + '.log'
+
+ logFormatter = logging.Formatter('%(asctime)s %(name)s %(levelname)s: %(message)s')
+ root_logger = logging.getLogger()
+ fileHandler = logging.FileHandler(log_name, mode='w')
+ fileHandler.setFormatter(logFormatter)
+ root_logger.addHandler(fileHandler)
+ consoleHandler = logging.StreamHandler()
+ consoleHandler.setFormatter(logFormatter)
+ root_logger.addHandler(consoleHandler)
+ consoleHandler.setLevel(logging.INFO)
+ root_logger.setLevel(logging.DEBUG)
+except Exception as _e:
+ logging.getLogger().exception("Failed to configure file logger: %s", _e)
+
+from manatools.aui.yui import YUI, YUI_ui
+import manatools.aui.yui_common as yui
+
+
+def test_richtext_example(backend_name=None):
+ if backend_name:
+ os.environ['YUI_BACKEND'] = backend_name
+
+ # Ensure fresh YUI detection
+ YUI._instance = None
+ YUI._backend = None
+
+ ui = YUI_ui()
+ factory = ui.widgetFactory()
+
+ # Log program name and detected backend
+ try:
+ backend = YUI.backend()
+ root_logger.debug("test_richtext_example: program=%s backend=%s", os.path.basename(sys.argv[0]), getattr(backend, 'value', str(backend)))
+ except Exception:
+ root_logger.debug("test_richtext_example: program=%s backend=unknown", os.path.basename(sys.argv[0]))
+
+ dlg = factory.createMainDialog()
+ vbox = factory.createVBox(dlg)
+ factory.createHeading(vbox, "RichText Example")
+ factory.createLabel(vbox, "Rich text below with HTML formatting and a link.")
+
+ # Rich text content (HTML)
+ html = (
+ "Heading 1
"
+ "Heading 2
"
+ "Heading 3
"
+ "Heading 4
"
+ "Heading 5
"
+ "Heading 6
"
+ "
"
+ "Welcome to RichText
"
+ "
"
+ "This is a paragraph with bold, italic, and underlined text.
"
+ "Click the example.com or go home link to emit an activation event.
"
+ "Colored text:
"
+ ""
+ "Lists:
"
+ ""
+ )
+ # Two RichText widgets side-by-side: left=rich HTML, right=plain text (10 lines)
+ h = factory.createHBox(vbox)
+ rich_left = factory.createRichText(h, html, False)
+ #rich_left.setAutoScrollDown(True)
+
+ # Plain text with 10 lines on the right
+ plain_lines = "\n".join([f"Line {i+1}: This is a sample plain text line." for i in range(10)])
+ rich_right = factory.createRichText(h, plain_lines, True)
+ try:
+ rich_right.setValue(plain_lines)
+ except Exception:
+ pass
+ #rich_right.setEnabled(False)
+
+ # Status label for last activated link
+ status_label = factory.createLabel(vbox, "Last link: (none)")
+
+ # ok/quit
+ ctrl_h = factory.createHBox(vbox)
+ ok_btn = factory.createPushButton(ctrl_h, "OK")
+
+ root_logger.info("Opening RichText example dialog...")
+
+ while True:
+ ev = dlg.waitForEvent()
+ et = ev.eventType()
+ if et == yui.YEventType.CancelEvent:
+ dlg.destroy()
+ break
+ elif et == yui.YEventType.WidgetEvent:
+ w = ev.widget()
+ reason = ev.reason()
+ if w == ok_btn and reason == yui.YEventReason.Activated:
+ dlg.destroy()
+ break
+ elif et == yui.YEventType.MenuEvent:
+ url = ev.id() if ev.id() else '(none)'
+ status_label.setValue(f"Last link: {url}")
+ root_logger.info("Link activated: %s", url)
+
+ root_logger.info("Dialog closed")
+
+
+if __name__ == '__main__':
+ if len(sys.argv) > 1:
+ test_richtext_example(sys.argv[1])
+ else:
+ test_richtext_example()
diff --git a/test/test_selectionbox-changing-items.py b/test/test_selectionbox-changing-items.py
new file mode 100644
index 0000000..aaf7c65
--- /dev/null
+++ b/test/test_selectionbox-changing-items.py
@@ -0,0 +1,145 @@
+#!/usr/bin/env python3
+
+import os
+import sys
+
+# Add parent directory to path
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+
+def test_selectionbox(backend_name=None):
+ """Test ComboBox widget specifically"""
+ if backend_name:
+ print(f"Setting backend to: {backend_name}")
+ os.environ['YUI_BACKEND'] = backend_name
+ else:
+ print("Using auto-detection")
+
+ try:
+ from manatools.aui.yui import YUI, YUI_ui
+ import manatools.aui.yui_common as yui
+
+ backend = YUI.backend()
+ print(f"Using backend: {backend.value}")
+
+ ui = YUI_ui()
+ factory = ui.widgetFactory()
+ ui.app().setApplicationTitle(f"SelectionBox {backend.value} Test")
+
+ dialog = factory.createPopupDialog()
+ vbox = factory.createVBox( dialog )
+ # Radio buttons selector for menu category (use factory abstraction)
+ radios_container = factory.createHBox(vbox)
+ rb1 = factory.createRadioButton(radios_container, "First courses", True)
+ rb2 = factory.createRadioButton(radios_container, "Second courses", False)
+ rb3 = factory.createRadioButton(radios_container, "Desserts", False)
+
+ selBox = factory.createSelectionBox( vbox, "Menu" )
+
+ firstCourses = [
+ yui.YItem("Spaghetti Carbonara"),
+ yui.YItem("Penne Arrabbiata"),
+ yui.YItem("Fettuccine"),
+ yui.YItem("Lasagna"),
+ yui.YItem("Ravioli"),
+ yui.YItem("Trofie al pesto") # Ligurian specialty
+ ]
+ firstCourses[0].setSelected( True )
+ secondCourses = [
+ yui.YItem("Beef Steak"),
+ yui.YItem("Roast Chicken"),
+ yui.YItem("Pork Chops"),
+ yui.YItem("Lamb Ribs"),
+ yui.YItem("Vegan Burger"),
+ yui.YItem("Grilled Tofu")
+ ]
+ secondCourses[0].setSelected( True )
+ desserts = [
+ yui.YItem("Apple Pie"),
+ yui.YItem("Ice Cream"),
+ yui.YItem("Cheesecake"),
+ yui.YItem("Brownies")
+ ]
+ desserts[0].setSelected( True )
+
+ # Default: first courses
+ selBox.addItems( firstCourses )
+
+ hbox = factory.createHBox( vbox )
+ checkBox = factory.createCheckBox( hbox, "Notify on change", selBox.notify() )
+ factory.createLabel(hbox, "SelectionBox") #factory.createOutputField( hbox, "" )
+ valueField = factory.createLabel(hbox, "")
+ #valueField.setStretchable( yui.YD_HORIZ, True ) # // allow stretching over entire dialog width
+
+ valueButton = factory.createPushButton( vbox, "Value" )
+ #factory.createVSpacing( vbox, 0.3 )
+
+ #rightAlignment = factory.createRight( vbox ) TODO
+ closeButton = factory.createPushButton( vbox, "Close" )
+
+ #
+ # Event loop
+ #
+ valueField.setText( "???" )
+ while True:
+ event = dialog.waitForEvent()
+ if not event:
+ print("Empty")
+ next
+ typ = event.eventType()
+ if typ == yui.YEventType.CancelEvent:
+ dialog.destroy()
+ break
+ elif typ == yui.YEventType.WidgetEvent:
+ wdg = event.widget()
+ if wdg == closeButton:
+ dialog.destroy()
+ break
+ elif (wdg == valueButton):
+ item = selBox.selectedItem()
+ valueField.setText( item.label() if item else "" )
+ elif (wdg == checkBox):
+ selBox.setNotify( checkBox.value() )
+ elif (wdg == selBox): # selBox will only send events with setNotify() TODO
+ valueField.setText(selBox.value())
+ elif wdg in (rb1, rb2, rb3):
+ # Category changed: replace selection box items accordingly
+ try:
+ selBox.deleteAllItems()
+ except Exception:
+ pass
+ try:
+ if wdg == rb1:
+ # First courses (default)
+ selBox.addItems( firstCourses )
+ elif wdg == rb2:
+ # Second courses: 4 meat, 2 vegan
+ selBox.addItems( secondCourses )
+ elif wdg == rb3:
+ # Desserts: 3 typical American desserts
+ selBox.addItems( desserts )
+ except Exception:
+ pass
+ # update display to first item
+ try:
+ first = selBox.selectedItem()
+ valueField.setText(first.label() if first else "")
+ except Exception:
+ try:
+ valueField.setText("")
+ except Exception:
+ pass
+
+ except Exception as e:
+ print(f"Error testing ComboBox with backend {backend_name}: {e}")
+ import traceback
+ traceback.print_exc()
+
+if __name__ == "__main__":
+ if len(sys.argv) > 1:
+ test_selectionbox(sys.argv[1])
+ else:
+ test_selectionbox()
+
+
+
+
diff --git a/test/test_selectionbox.py b/test/test_selectionbox.py
new file mode 100644
index 0000000..ce6df6b
--- /dev/null
+++ b/test/test_selectionbox.py
@@ -0,0 +1,160 @@
+#!/usr/bin/env python3
+
+import os
+import sys
+
+# Add parent directory to path
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+import logging
+
+# Configure file logger for this test: write DEBUG logs to '.log' in cwd
+try:
+ log_name = os.path.splitext(os.path.basename(__file__))[0] + '.log'
+ fh = logging.FileHandler(log_name, mode='w')
+ fh.setLevel(logging.DEBUG)
+ fh.setFormatter(logging.Formatter('%(asctime)s %(name)s %(levelname)s: %(message)s'))
+ root_logger = logging.getLogger()
+ root_logger.setLevel(logging.DEBUG)
+ existing = False
+ for h in list(root_logger.handlers):
+ try:
+ if isinstance(h, logging.FileHandler) and os.path.abspath(getattr(h, 'baseFilename', '')) == os.path.abspath(log_name):
+ existing = True
+ break
+ except Exception:
+ pass
+ if not existing:
+ root_logger.addHandler(fh)
+ print(f"Logging test output to: {os.path.abspath(log_name)}")
+except Exception as _e:
+ print(f"Failed to configure file logger: {_e}")
+
+def test_selectionbox(backend_name=None):
+ """Test Selection Box widget specifically"""
+ if backend_name:
+ print(f"Setting backend to: {backend_name}")
+ os.environ['YUI_BACKEND'] = backend_name
+ else:
+ print("Using auto-detection")
+
+ try:
+ from manatools.aui.yui import YUI, YUI_ui
+ import manatools.aui.yui_common as yui
+
+ # Force re-detection
+ YUI._instance = None
+ YUI._backend = None
+
+ backend = YUI.backend()
+ print(f"Using backend: {backend.value}")
+
+ ui = YUI_ui()
+ factory = ui.widgetFactory()
+ #ui.application().setIconBasePath("PATH_TO_TEST")
+
+###############
+ ui.application().setApplicationIcon("dnfdragora")
+ dialog = factory.createPopupDialog()
+ mainVbox = factory.createVBox( dialog )
+ hbox = factory.createHBox( mainVbox )
+ selBox = factory.createSelectionBox( hbox, "Choose your pasta" )
+ selBox.addItem( "Spaghetti Carbonara" )
+ selBox.addItem( "Penne Arrabbiata" )
+ selBox.addItem( "Fettuccine" )
+ selBox.addItem( "Lasagna" )
+ selBox.addItem( "Ravioli" )
+ selBox.addItem( "Trofie al pesto" ) # Ligurian specialty
+
+ #selBox.setMultiSelection(True)
+
+ vbox = factory.createVBox( hbox )
+ notifyCheckBox = factory.createCheckBox( vbox, "Notify on change", selBox.notify() )
+ notifyCheckBox.setStretchable( yui.YUIDimension.YD_HORIZ, True )
+ multiSelectionCheckBox = factory.createCheckBox( vbox, "Multi-selection", selBox.multiSelection() )
+ multiSelectionCheckBox.setStretchable( yui.YUIDimension.YD_HORIZ, True )
+ disableSelectionBox = factory.createCheckBox( vbox, "disable selection box", not selBox.isEnabled() )
+ disableSelectionBox.setStretchable( yui.YUIDimension.YD_HORIZ, True )
+ disableValue = factory.createCheckBox( vbox, "disable value button", False )
+ disableValue.setStretchable( yui.YUIDimension.YD_HORIZ, True )
+
+ hbox = factory.createHBox( mainVbox )
+ valueButton = factory.createPushButton( hbox, "Value" )
+ disableValue.setValue(not valueButton.isEnabled())
+ label = factory.createLabel(hbox, "SelectionBox") #factory.createOutputField( hbox, "" )
+ label.setStretchable( yui.YUIDimension.YD_HORIZ, True )
+ valueField = factory.createLabel(hbox, "")
+ valueField.setStretchable( yui.YUIDimension.YD_HORIZ, True ) # // allow stretching over entire dialog width
+ if selBox.multiSelection():
+ labels = [item.label() for item in selBox.selectedItems()]
+ valueField.setText( ", ".join(labels) )
+ else:
+ item = selBox.selectedItem()
+ valueField.setText( item.label() if item else "" )
+
+
+ #factory.createVSpacing( vbox, 0.3 )
+
+ hbox = factory.createHBox( mainVbox )
+ #factory.createLabel(hbox, " ") # spacer
+ leftAlignment = factory.createLeft( hbox )
+ left = factory.createPushButton( leftAlignment, "Left" )
+ rightAlignment = factory.createRight( hbox )
+ closeButton = factory.createPushButton( rightAlignment, "Close" )
+
+ #
+ # Event loop
+ #
+ #valueField.setText( "???" )
+ while True:
+ event = dialog.waitForEvent()
+ if not event:
+ print("Empty")
+ next
+ typ = event.eventType()
+ if typ == yui.YEventType.CancelEvent:
+ dialog.destroy()
+ break
+ elif typ == yui.YEventType.WidgetEvent:
+ wdg = event.widget()
+ if wdg == closeButton:
+ dialog.destroy()
+ break
+ elif wdg == left:
+ valueField.setText(left.label())
+ elif (wdg == valueButton):
+ if selBox.multiSelection():
+ labels = [item.label() for item in selBox.selectedItems()]
+ valueField.setText( ", ".join(labels) )
+ else:
+ item = selBox.selectedItem()
+ valueField.setText( item.label() if item else "" )
+ ui.application().setApplicationTitle("Test App")
+ elif (wdg == notifyCheckBox):
+ selBox.setNotify( notifyCheckBox.value() )
+ elif (wdg == multiSelectionCheckBox):
+ selBox.setMultiSelection( multiSelectionCheckBox.value() )
+ elif (wdg == disableSelectionBox):
+ selBox.setEnabled( not disableSelectionBox.value() )
+ elif (wdg == disableValue):
+ valueButton.setEnabled( not disableValue.value() )
+ elif (wdg == selBox): # selBox will only send events with setNotify() TODO
+ if selBox.multiSelection():
+ labels = [item.label() for item in selBox.selectedItems()]
+ valueField.setText( ", ".join(labels) )
+ else:
+ valueField.setText(selBox.value())
+
+ except Exception as e:
+ print(f"Error testing ComboBox with backend {backend_name}: {e}")
+ import traceback
+ traceback.print_exc()
+
+if __name__ == "__main__":
+ if len(sys.argv) > 1:
+ test_selectionbox(sys.argv[1])
+ else:
+ test_selectionbox()
+
+
+
+
diff --git a/test/test_selectionbox2.py b/test/test_selectionbox2.py
new file mode 100644
index 0000000..e8ccc4a
--- /dev/null
+++ b/test/test_selectionbox2.py
@@ -0,0 +1,138 @@
+#!/usr/bin/env python3
+
+import os
+import sys
+
+# Add parent directory to path
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+import logging
+
+# Configure file logger for this test: write DEBUG logs to '.log' in cwd
+try:
+ log_name = os.path.splitext(os.path.basename(__file__))[0] + '.log'
+ fh = logging.FileHandler(log_name, mode='w')
+ fh.setLevel(logging.DEBUG)
+ fh.setFormatter(logging.Formatter('%(asctime)s %(name)s %(levelname)s: %(message)s'))
+ root_logger = logging.getLogger()
+ root_logger.setLevel(logging.DEBUG)
+ # Avoid adding duplicate file handlers for repeated imports
+ existing = False
+ for h in list(root_logger.handlers):
+ try:
+ if isinstance(h, logging.FileHandler) and os.path.abspath(getattr(h, 'baseFilename', '')) == os.path.abspath(log_name):
+ existing = True
+ break
+ except Exception:
+ pass
+ if not existing:
+ root_logger.addHandler(fh)
+ print(f"Logging test output to: {os.path.abspath(log_name)}")
+except Exception as _e:
+ print(f"Failed to configure file logger: {_e}")
+
+def test_two_selectionbox(backend_name=None):
+ """Two selection boxes side by side; label below shows which box and value."""
+ if backend_name:
+ print(f"Setting backend to: {backend_name}")
+ os.environ['YUI_BACKEND'] = backend_name
+ else:
+ print("Using auto-detection")
+
+ try:
+ from manatools.aui.yui import YUI, YUI_ui
+ import manatools.aui.yui_common as yui
+
+ backend = YUI.backend()
+ print(f"Using backend: {backend.value}")
+
+ ui = YUI_ui()
+ factory = ui.widgetFactory()
+ ui.app().setApplicationTitle(f"Two SelectionBox {backend.value} Test")
+
+ dialog = factory.createPopupDialog()
+ mainVbox = factory.createVBox(dialog)
+
+ # HBox with two selection boxes
+ hbox = factory.createHBox(mainVbox)
+ sel1 = factory.createSelectionBox(hbox, "First Box")
+ sel2 = factory.createSelectionBox(hbox, "Second Box")
+
+ # Populate items (use YItem so we can set initial selection)
+ items1 = [
+ yui.YItem("Apple"),
+ yui.YItem("Banana"),
+ yui.YItem("Cherry")
+ ]
+ items1[1].setSelected(True)
+
+ items2 = [
+ yui.YItem("Red"),
+ yui.YItem("Green", icon_name="system-search"),
+ yui.YItem("Blue")
+ ]
+ items2[2].setSelected(True)
+
+ sel1.addItems(items1)
+ sel2.addItems(items2)
+
+ # Label below the hbox that reports which box sent the event and its value
+ label_box = factory.createVBox(mainVbox)
+ infoLabel = factory.createLabel(label_box, "")
+ infoLabel.setStretchable(yui.YUIDimension.YD_HORIZ, True)
+
+ # OK button to exit
+ okButton = factory.createPushButton(label_box, "OK")
+
+ root_logger.debug("Opening dialog to set values into selection boxes")
+ dialog.open()
+ # Initial display
+ sel2.selectItem(items2[2]) # trigger event to update label
+ try:
+ v1 = sel1.value() or (sel1.selectedItem().label() if sel1.selectedItem() else "")
+ except Exception:
+ v1 = ""
+ try:
+ v2 = sel2.value() or (sel2.selectedItem().label() if sel2.selectedItem() else "")
+ except Exception:
+ v2 = ""
+ infoLabel.setText(f"Box1: {v1} | Box2: {v2}")
+ root_logger.debug(f"set values: Box1: {v1} | Box2: {v2}")
+
+ # Event loop
+ while True:
+ event = dialog.waitForEvent()
+ if not event:
+ continue
+ typ = event.eventType()
+ if typ == yui.YEventType.CancelEvent:
+ dialog.destroy()
+ break
+ elif typ == yui.YEventType.WidgetEvent:
+ wdg = event.widget()
+ if wdg == okButton:
+ dialog.destroy()
+ break
+ elif wdg == sel1 or wdg == sel2:
+ # Update label with which box and its value
+ try:
+ v1 = sel1.value() or (sel1.selectedItem().label() if sel1.selectedItem() else "")
+ except Exception:
+ v1 = ""
+ try:
+ v2 = sel2.value() or (sel2.selectedItem().label() if sel2.selectedItem() else "")
+ except Exception:
+ v2 = ""
+ who = "Box1" if wdg == sel1 else "Box2"
+ infoLabel.setText(f"Now({who}: {wdg.value()}) [Box1: {v1}] [Box2: {v2}]")
+
+ except Exception as e:
+ print(f"Error in test_two_selectionbox with backend {backend_name}: {e}")
+ import traceback
+ traceback.print_exc()
+
+
+if __name__ == "__main__":
+ if len(sys.argv) > 1:
+ test_two_selectionbox(sys.argv[1])
+ else:
+ test_two_selectionbox()
diff --git a/test/test_slide.py b/test/test_slide.py
new file mode 100644
index 0000000..dbef0e5
--- /dev/null
+++ b/test/test_slide.py
@@ -0,0 +1,78 @@
+#!/usr/bin/env python3
+
+import os
+import sys
+
+# Add parent directory to path
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+
+def test_slide(backend_name=None):
+ """Interactive test showcasing Slider widget."""
+ if backend_name:
+ print(f"Setting backend to: {backend_name}")
+ os.environ['YUI_BACKEND'] = backend_name
+ else:
+ print("Using auto-detection")
+
+ try:
+ from manatools.aui.yui import YUI, YUI_ui
+ import manatools.aui.yui_common as yui
+
+ # Force re-detection
+ YUI._instance = None
+ YUI._backend = None
+
+ backend = YUI.backend()
+ print(f"Using backend: {backend.value}")
+
+ ui = YUI_ui()
+ factory = ui.widgetFactory()
+ dialog = factory.createMainDialog()
+
+ vbox = factory.createVBox(dialog)
+ factory.createHeading(vbox, "Slider Test")
+ factory.createLabel(vbox, f"Backend: {backend.value}")
+
+ # Create slider
+ slider = factory.createSlider(vbox, "Volume:", 0, 100, 25)
+
+ # Show current value
+ val_label = factory.createLabel(vbox, "Value: 25")
+
+ # Buttons
+ h = factory.createHBox(vbox)
+ ok_btn = factory.createPushButton(h, "OK")
+ close_btn = factory.createPushButton(h, "Close")
+
+ print("\nOpening Slider test dialog...")
+
+ while True:
+ ev = dialog.waitForEvent()
+ et = ev.eventType()
+ if et == yui.YEventType.CancelEvent:
+ dialog.destroy()
+ break
+ elif et == yui.YEventType.WidgetEvent:
+ wdg = ev.widget()
+ reason = ev.reason()
+ if wdg == close_btn and reason == yui.YEventReason.Activated:
+ dialog.destroy()
+ break
+ if wdg == ok_btn and reason == yui.YEventReason.Activated:
+ print("OK clicked. Final value:", slider.value())
+ if wdg == slider:
+ if reason == yui.YEventReason.ValueChanged:
+ val_label.setText(f"Value: {slider.value()}")
+ print("ValueChanged:", slider.value())
+ elif reason == yui.YEventReason.Activated:
+ print("Activated at:", slider.value())
+ except Exception as e:
+ print(f"Error testing Slider with backend {backend_name}: {e}")
+ import traceback
+ traceback.print_exc()
+
+if __name__ == "__main__":
+ if len(sys.argv) > 1:
+ test_slide(sys.argv[1])
+ else:
+ test_slide()
diff --git a/test/test_spacing.py b/test/test_spacing.py
new file mode 100644
index 0000000..87c37c7
--- /dev/null
+++ b/test/test_spacing.py
@@ -0,0 +1,97 @@
+#!/usr/bin/env python3
+
+import os
+import sys
+import logging
+
+# Add parent directory to path
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+try:
+ log_name = os.path.splitext(os.path.basename(__file__))[0] + '.log'
+ logFormatter = logging.Formatter('%(asctime)s %(name)s %(levelname)s: %(message)s')
+ root_logger = logging.getLogger()
+ fileHandler = logging.FileHandler(log_name, mode='w')
+ fileHandler.setFormatter(logFormatter)
+ root_logger.addHandler(fileHandler)
+ consoleHandler = logging.StreamHandler()
+ consoleHandler.setFormatter(logFormatter)
+ root_logger.addHandler(consoleHandler)
+ consoleHandler.setLevel(logging.INFO)
+ root_logger.setLevel(logging.DEBUG)
+except Exception as _e:
+ logging.getLogger().exception("Failed to configure file logger: %s", _e)
+
+def test_Spacing(backend_name=None):
+ if backend_name:
+ print(f"Setting backend to: {backend_name}")
+ os.environ['YUI_BACKEND'] = backend_name
+ else:
+ print("Using auto-detection")
+
+ try:
+ from manatools.aui.yui import YUI, YUI_ui
+ import manatools.aui.yui_common as yui
+
+ # Force re-detection
+ YUI._instance = None
+ YUI._backend = None
+
+ backend = YUI.backend()
+ print(f"Using backend: {backend.value}")
+
+ # Basic logging setup
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s: %(message)s")
+
+ ui = YUI_ui()
+ ui.yApp().setApplicationTitle("Spacing Demo")
+ factory = ui.widgetFactory()
+ dialog = factory.createMainDialog()
+
+ vbox = factory.createVBox(dialog)
+ factory.createHeading(vbox, "Spacing demo: pixels as unit; curses converts to chars.")
+
+ factory.createLabel(vbox, "Next row is [LeftLabel, spacing 50px, button, spacing 80px (stretchable), checkbox]")
+ # Horizontal spacing line: label - spacing(50px, fixed) - button - spacing(80px, stretch min)
+ hbox = factory.createHBox(vbox)
+ factory.createLabel(hbox, "LeftLabel")
+ factory.createSpacing(hbox, yui.YUIDimension.YD_HORIZ, False, 50)
+ factory.createPushButton(hbox, "Click Me")
+ # stretchable spacing should take remaining width between button and checkbox
+ s = factory.createSpacing(hbox, yui.YUIDimension.YD_HORIZ, True, 80)
+ factory.createCheckBox(hbox, "Check", False)
+
+ factory.createLabel(vbox, "Next vertical spacing 24px (fixed) between rows")
+ # Vertical spacing between rows (24px ~= 1 char in curses)
+ factory.createSpacing(vbox, yui.YUIDimension.YD_VERT, False, 24)
+
+ # Another row with stretchable vertical spacing on both sides
+ factory.createLabel(vbox, "Next row is [spacing 20px (stretchable), centered button, spacing 20px (stretchable)]")
+ hbox2 = factory.createHBox(vbox)
+ factory.createSpacing(hbox2, yui.YUIDimension.YD_HORIZ, True, 20)
+ btn = factory.createPushButton(hbox2, "Centered by spacers")
+ btn.setStretchable(yui.YUIDimension.YD_HORIZ, False)
+ btn.setStretchable(yui.YUIDimension.YD_VERT, False)
+ factory.createSpacing(hbox2, yui.YUIDimension.YD_HORIZ, True, 20)
+ factory.createLabel(vbox, "Next vertical spacing 20px (stretchable) between rows")
+ # add a vertical stretch spacer below the row to demonstrate vertical expansion
+ factory.createSpacing(vbox, yui.YUIDimension.YD_VERT, True, 20)
+
+ # OK button
+ ok = factory.createPushButton(vbox, "OK")
+ ok.setStretchable(yui.YUIDimension.YD_HORIZ, True)
+ ok.setStretchable(yui.YUIDimension.YD_VERT, False)
+
+ dialog.open()
+ event = dialog.waitForEvent()
+ dialog.destroy()
+
+ except Exception as e:
+ print(f"Error testing Spacing with backend {backend_name}: {e}")
+ import traceback
+ traceback.print_exc()
+
+if __name__ == "__main__":
+ if len(sys.argv) > 1:
+ test_Spacing(sys.argv[1])
+ else:
+ test_Spacing()
diff --git a/test/test_table.py b/test/test_table.py
new file mode 100644
index 0000000..2211336
--- /dev/null
+++ b/test/test_table.py
@@ -0,0 +1,171 @@
+#!/usr/bin/env python3
+
+"""Example dialog to manually test YTable widgets.
+
+Layout:
+- HBox with two tables (left checkboxed, right multi-selection).
+- Labels show selected rows and checkbox states.
+- OK button closes the dialog.
+
+Run with: `python -m pytest -q test/test_table.py::test_table_example -s` or run directly.
+"""
+
+import os
+import sys
+
+# allow running from repo root
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+import logging
+
+# Configure file logger for this test: write DEBUG logs to '.log' in cwd
+try:
+ log_name = os.path.splitext(os.path.basename(__file__))[0] + '.log'
+ fh = logging.FileHandler(log_name, mode='w')
+ fh.setLevel(logging.DEBUG)
+ fh.setFormatter(logging.Formatter('%(asctime)s %(name)s %(levelname)s: %(message)s'))
+ root_logger = logging.getLogger()
+ root_logger.setLevel(logging.DEBUG)
+ existing = False
+ for h in list(root_logger.handlers):
+ try:
+ if isinstance(h, logging.FileHandler) and os.path.abspath(getattr(h, 'baseFilename', '')) == os.path.abspath(log_name):
+ existing = True
+ break
+ except Exception:
+ pass
+ if not existing:
+ root_logger.addHandler(fh)
+ print(f"Logging test output to: {os.path.abspath(log_name)}")
+except Exception as _e:
+ print(f"Failed to configure file logger: {_e}")
+
+
+from manatools.aui.yui import YUI, YUI_ui
+import manatools.aui.yui_common as yui
+
+
+def build_left_checkbox_table(factory, table_widget):
+ items = []
+ for i in range(1, 7):
+ itm = yui.YTableItem(f"Row {i}")
+ itm.addCell(str(i))
+ itm.addCell(f"test {i}")
+ # third column is checkbox column
+ itm.addCell(False if i % 2 == 0 else True)
+ items.append(itm)
+ table_widget.addItem(itm)
+ return items
+
+
+def build_right_multi_table(factory, table_widget):
+ items = []
+ for i in range(1, 7):
+ itm = yui.YTableItem(f"Name {i}")
+ itm.addCells(f"Name {i}", f"Addr {i} Street", f"{10000+i}", f"Town {i}")
+ items.append(itm)
+ table_widget.addItem(itm)
+ return items
+
+
+def test_table_example(backend_name=None):
+ if backend_name:
+ os.environ['YUI_BACKEND'] = backend_name
+
+ # Ensure fresh YUI detection
+ YUI._instance = None
+ YUI._backend = None
+
+ ui = YUI_ui()
+ factory = ui.widgetFactory()
+
+ # Log program name and detected backend
+ try:
+ backend = YUI.backend()
+ logging.getLogger().debug("test_table_example: program=%s backend=%s", os.path.basename(sys.argv[0]), getattr(backend, 'value', str(backend)))
+ except Exception:
+ logging.getLogger().debug("test_table_example: program=%s backend=unknown", os.path.basename(sys.argv[0]))
+
+ dlg = factory.createMainDialog()
+ vbox = factory.createVBox(dlg)
+ factory.createHeading(vbox, "Table Example")
+ factory.createLabel(vbox, "Two tables side-by-side. Left has checkbox column.")
+
+ hbox = factory.createHBox(vbox)
+
+ # left: checkbox table header (third column is checkbox) with alignment:
+ # first column right aligned, checkbox column centered
+ left_header = yui.YTableHeader()
+ left_header.addColumn('num.', alignment=yui.YAlignmentType.YAlignEnd)
+ left_header.addColumn('info', alignment=yui.YAlignmentType.YAlignBegin)
+ left_header.addColumn('', checkBox=True, alignment=yui.YAlignmentType.YAlignCenter)
+ left_table = factory.createTable(hbox, left_header)
+ #left_table.setEnabled(False)
+
+ # right: multi-selection table
+ right_header = yui.YTableHeader()
+ right_header.addColumn('name')
+ right_header.addColumn('address')
+ right_header.addColumn('zip code')
+ right_header.addColumn('town')
+ right_table = factory.createTable(hbox, right_header, True)
+
+ # populate
+ left_items = build_left_checkbox_table(factory, left_table)
+ right_items = build_right_multi_table(factory, right_table)
+
+ # status labels
+ sel_label = factory.createLabel(vbox, "Selected: None")
+ chk_label = factory.createLabel(vbox, "Checked rows: []")
+
+ # ok/quit
+ ctrl_h = factory.createHBox(vbox)
+ ok_btn = factory.createPushButton(ctrl_h, "OK")
+
+ def update_labels(checked_item=None):
+ try:
+ sel = right_table.selectedItems()
+ sel_text = [it.label() for it in sel]
+ sel_label.setText(f"Selected: {sel_text}")
+ # left table checked states
+ checked = []
+ for it in left_items:
+ if it.cellCount() >= 3 and it.cell(2).checked():
+ checked.append(it.label(0))
+ if checked_item is not None:
+ chk_label.setText(f"Checked rows: {checked} | Selected{checked_item.label(0)}: checked:{checked_item.checked(2)}")
+ else:
+ chk_label.setText(f"Checked rows: {checked}")
+ except Exception:
+ pass
+
+ update_labels()
+
+ print("Opening Table example dialog...")
+
+ while True:
+ ev = dlg.waitForEvent()
+ et = ev.eventType()
+ if et == yui.YEventType.CancelEvent:
+ dlg.destroy()
+ break
+ if et == yui.YEventType.WidgetEvent:
+ w = ev.widget()
+ reason = ev.reason()
+ if w == right_table and reason == yui.YEventReason.SelectionChanged:
+ update_labels()
+ elif w == left_table and reason == yui.YEventReason.ValueChanged:
+ sel = left_table.changedItem()
+ #root_logger.debug(f"Left table checkbox changed, item: {sel.label(0) if sel else 'None'}")
+ update_labels(sel)
+ elif w == ok_btn and reason == yui.YEventReason.Activated:
+ dlg.destroy()
+ break
+
+ print("Dialog closed")
+
+
+if __name__ == '__main__':
+ if len(sys.argv) > 1:
+ test_table_example(sys.argv[1])
+ else:
+ test_table_example()
diff --git a/test/test_tree.py b/test/test_tree.py
new file mode 100644
index 0000000..1984436
--- /dev/null
+++ b/test/test_tree.py
@@ -0,0 +1,135 @@
+#!/usr/bin/env python3
+
+import os
+import sys
+
+# Add parent directory to path
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+
+import logging
+
+# Configure file logger for this test: write DEBUG logs to '.log' in cwd
+try:
+ log_name = os.path.splitext(os.path.basename(__file__))[0] + '.log'
+ fh = logging.FileHandler(log_name, mode='w')
+ fh.setLevel(logging.DEBUG)
+ fh.setFormatter(logging.Formatter('%(asctime)s %(name)s %(levelname)s: %(message)s'))
+ root_logger = logging.getLogger()
+ root_logger.setLevel(logging.DEBUG)
+ existing = False
+ for h in list(root_logger.handlers):
+ try:
+ if isinstance(h, logging.FileHandler) and os.path.abspath(getattr(h, 'baseFilename', '')) == os.path.abspath(log_name):
+ existing = True
+ break
+ except Exception:
+ pass
+ if not existing:
+ root_logger.addHandler(fh)
+ print(f"Logging test output to: {os.path.abspath(log_name)}")
+except Exception as _e:
+ print(f"Failed to configure file logger: {_e}")
+
+
+def test_tree(backend_name=None):
+ """Test ComboBox widget specifically"""
+ if backend_name:
+ print(f"Setting backend to: {backend_name}")
+ os.environ['YUI_BACKEND'] = backend_name
+ else:
+ print("Using auto-detection")
+
+ try:
+ from manatools.aui.yui import YUI, YUI_ui
+ import manatools.aui.yui_common as yui
+
+ # Force re-detection
+ YUI._instance = None
+ YUI._backend = None
+
+ backend = YUI.backend()
+ print(f"Using backend: {backend.value}")
+ root_logger.info("test_tree: program=%s backend=%s", os.path.basename(sys.argv[0]), getattr(backend, 'value', str(backend)))
+
+ ui = YUI_ui()
+ factory = ui.widgetFactory()
+ title = ui.application().applicationTitle()
+ ui.application().setApplicationTitle("Tree Widget Application")
+
+ # Create dialog focused on ComboBox testing
+ dialog = factory.createMainDialog()
+ vbox = factory.createVBox(dialog)
+
+ factory.createHeading(vbox, "Tree Test")
+ factory.createLabel(vbox, f"Backend: {backend.value}")
+ factory.createLabel(vbox, "Test selecting and displaying values")
+
+ # Test ComboBox with initial selection
+ factory.createLabel(vbox, "")
+ hbox = factory.createHBox(vbox)
+ tree = factory.createTree(hbox, "Select:")
+
+ for i in range(5):
+ item = yui.YTreeItem(f"Item {i+1}", is_open=(i==0))
+ for j in range(3):
+ subitem = yui.YTreeItem(f"SubItem {i+1}.{j+1}", parent=item, selected=(True if i==1 and j == 2 else False))
+ if i==1 and j == 1:
+ for k in range(2):
+ yui.YTreeItem(f"SubItem {i+1}.{j+1}.{k+1}", parent=subitem)
+
+ tree.addItem(item)
+
+ selected = factory.createLabel(vbox, "Selected:")
+ hbox = factory.createHBox(vbox)
+ ok_button = factory.createPushButton(hbox, "OK")
+ cancel_button = factory.createPushButton(hbox, "Cancel")
+
+ print("\nOpening ComboBox test dialog...")
+
+ dialog.open()
+ if tree.selectedItem() is not None:
+ selected.setText(f"Selected: '{tree.selectedItem().label()}'")
+ else:
+ selected.setText(f"Selected: None")
+ while True:
+ event = dialog.waitForEvent()
+ typ = event.eventType()
+ if typ == yui.YEventType.CancelEvent:
+ dialog.destroy()
+ break
+ elif typ == yui.YEventType.WidgetEvent:
+ wdg = event.widget()
+ reason = event.reason()
+ if wdg == cancel_button:
+ dialog.destroy()
+ break
+ elif wdg == tree:
+ if reason == yui.YEventReason.SelectionChanged:
+ if tree.selectedItem() is not None:
+ selected.setText(f"Selected: '{tree.selectedItem().label()}'")
+ else:
+ selected.setText(f"Selected: None")
+ elif reason == yui.YEventReason.Activated:
+ if tree.selectedItem() is not None:
+ selected.setText(f"Activated: '{tree.selectedItem().label()}'")
+ else:
+ selected.setText(f"Activated: None")
+ elif wdg == ok_button:
+ selected.setText(f"OK clicked.")
+
+ # Show final result
+ if tree.selectedItem() is not None:
+ print(f"\nFinal Tree value: '{tree.selectedItem().label()}'")
+
+ except Exception as e:
+ print(f"Error testing Tree with backend {backend_name}: {e}")
+ import traceback
+ traceback.print_exc()
+ finally:
+ ui.application().setApplicationTitle(title)
+
+if __name__ == "__main__":
+ if len(sys.argv) > 1:
+ test_tree(sys.argv[1])
+ else:
+ test_tree()
diff --git a/test/test_tree_example.py b/test/test_tree_example.py
new file mode 100644
index 0000000..756f447
--- /dev/null
+++ b/test/test_tree_example.py
@@ -0,0 +1,261 @@
+#!/usr/bin/env python3
+
+"""Example dialog to manually test YTree widgets.
+
+Layout:
+- HBox with two trees (left numeric items, right lettered items).
+- Button "Swap" that clears both trees and swaps their items, selecting different items.
+- Labels show the selected item and selected lists for both trees.
+
+Run with: `python -m pytest -q test/test_tree_example.py::test_tree_example -s` or run directly.
+"""
+
+import os
+import sys
+
+# allow running from repo root
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+import logging
+
+# Configure file logger for this test: write DEBUG logs to '.log' in cwd
+try:
+ log_name = os.path.splitext(os.path.basename(__file__))[0] + '.log'
+ fh = logging.FileHandler(log_name, mode='w')
+ fh.setLevel(logging.DEBUG)
+ fh.setFormatter(logging.Formatter('%(asctime)s %(name)s %(levelname)s: %(message)s'))
+ root_logger = logging.getLogger()
+ root_logger.setLevel(logging.DEBUG)
+ existing = False
+ for h in list(root_logger.handlers):
+ try:
+ if isinstance(h, logging.FileHandler) and os.path.abspath(getattr(h, 'baseFilename', '')) == os.path.abspath(log_name):
+ existing = True
+ break
+ except Exception:
+ pass
+ if not existing:
+ root_logger.addHandler(fh)
+ print(f"Logging test output to: {os.path.abspath(log_name)}")
+except Exception as _e:
+ print(f"Failed to configure file logger: {_e}")
+
+
+from manatools.aui.yui import YUI, YUI_ui
+import manatools.aui.yui_common as yui
+
+
+def build_numeric_tree(tree_factory, tree_widget):
+ items = []
+ for i in range(1, 6):
+ itm = yui.YTreeItem(f"Item {i}", is_open=(i == 1))
+ for j in range(1, 4):
+ sub = yui.YTreeItem(f"SubItem {i}.{j}", parent=itm)
+ for k in range(1, 3):
+ yui.YTreeItem(f"SubItem {i}.{j}.{k}", parent=sub)
+ items.append(itm)
+ tree_widget.addItem(itm)
+ return items
+
+
+def build_letter_tree(tree_widget):
+ items = []
+ letters = ['A', 'B', 'C', 'D', 'E']
+ for idx, L in enumerate(letters, start=1):
+ itm = yui.YTreeItem(f"Item {L}", is_open=(idx == 1), icon_name=("edit-cut" if L == 'A' else ""))
+ for j in range(1, 4):
+ sub = yui.YTreeItem(f"SubItem {L}.{j}", parent=itm)
+ for k in range(1, 3):
+ yui.YTreeItem(f"SubItem {L}.{j}.{k}", parent=sub)
+ items.append(itm)
+ tree_widget.addItem(itm)
+ return items
+
+
+def test_tree_example(backend_name=None):
+ if backend_name:
+ os.environ['YUI_BACKEND'] = backend_name
+
+ # Ensure fresh YUI detection
+ YUI._instance = None
+ YUI._backend = None
+
+ ui = YUI_ui()
+ factory = ui.widgetFactory()
+
+ # Log program name and detected backend
+ try:
+ backend = YUI.backend()
+ logging.getLogger().debug("test_tree_example: program=%s backend=%s", os.path.basename(sys.argv[0]), getattr(backend, 'value', str(backend)))
+ except Exception:
+ logging.getLogger().debug("test_tree_example: program=%s backend=unknown", os.path.basename(sys.argv[0]))
+
+ # prepare dialog
+ dlg = factory.createMainDialog()
+ vbox = factory.createVBox(dlg)
+ factory.createHeading(vbox, "Tree Swap Example")
+ factory.createLabel(vbox, "Two trees side-by-side. Press Swap to exchange items.")
+
+ hbox = factory.createHBox(vbox)
+ left_tree = factory.createTree(hbox, "Left Tree")
+ right_tree = factory.createTree(hbox, "Right Tree")
+
+ # populate both trees
+ left_items = build_numeric_tree(factory, left_tree)
+ right_items = build_letter_tree(right_tree)
+
+ # select one item per tree initially
+ try:
+ # choose a sub-sub item on left and a subitem on right
+ left_selected = left_items[1].children() if hasattr(left_items[1], 'children') else None
+ except Exception:
+ left_selected = None
+ try:
+ # pick second subitem of first top item on right
+ right_selected = right_items[0].children() if hasattr(right_items[0], 'children') else None
+ except Exception:
+ right_selected = None
+
+ # helper to pick a specific logical item if possible
+ def pick_initial_selections():
+ # left: choose Item 2.1.1 if exists
+ try:
+ t = left_items[1]
+ # get first child's first child
+ sc = list(t.children()) if callable(getattr(t, 'children', None)) else getattr(t, '_children', [])
+ if sc:
+ sc2 = list(sc[0].children()) if callable(getattr(sc[0], 'children', None)) else getattr(sc[0], '_children', [])
+ if sc2:
+ left_tree.selectItem(sc2[0], True)
+ except Exception:
+ pass
+ # right: choose Item A.2 (second subitem of first top item)
+ try:
+ t = right_items[0]
+ sc = list(t.children()) if callable(getattr(t, 'children', None)) else getattr(t, '_children', [])
+ if sc and len(sc) >= 2:
+ right_tree.selectItem(sc[1], True)
+ except Exception:
+ pass
+
+ pick_initial_selections()
+
+ # labels showing status
+ left_status = factory.createLabel(vbox, "Left selected: None")
+ right_status = factory.createLabel(vbox, "Right selected: None")
+ both_status = factory.createLabel(vbox, "Left selected list: [] | Right selected list: []")
+
+ # control buttons
+ ctrl_h = factory.createHBox(vbox)
+ swap_btn = factory.createPushButton(ctrl_h, "Swap")
+ quit_btn = factory.createPushButton(factory.createRight(ctrl_h), "Quit")
+
+ # track previous selected labels so we can choose different items after swap
+ prev_left_label = None
+ prev_right_label = None
+
+ def update_labels():
+ try:
+ lsel = left_tree.selectedItems()
+ rsel = right_tree.selectedItems()
+ l_label = lsel[0].label() if lsel else "None"
+ r_label = rsel[0].label() if rsel else "None"
+ left_status.setText(f"Left selected: {l_label}")
+ right_status.setText(f"Right selected: {r_label}")
+ left_list = [it.label() for it in lsel]
+ right_list = [it.label() for it in rsel]
+ both_status.setText(f"Left selected list: {left_list} | Right selected list: {right_list}")
+ except Exception:
+ pass
+
+ update_labels()
+
+ print("Opening Tree example dialog...")
+
+ while True:
+ ev = dlg.waitForEvent()
+ et = ev.eventType()
+ if et == yui.YEventType.CancelEvent:
+ dlg.destroy()
+ break
+ if et == yui.YEventType.WidgetEvent:
+ w = ev.widget()
+ reason = ev.reason()
+ # selection changed events from either tree
+ if w == left_tree and reason == yui.YEventReason.SelectionChanged:
+ update_labels()
+ elif w == right_tree and reason == yui.YEventReason.SelectionChanged:
+ update_labels()
+ elif w == swap_btn and reason == yui.YEventReason.Activated:
+ # perform swap: capture model items, clear, swap, and set new selections
+ root_logger.debug("YTree swapping tree items...")
+ try:
+ left_model = list(left_tree._items)
+ right_model = list(right_tree._items)
+ except Exception:
+ left_model = []
+ right_model = []
+ try:
+ left_tree.deleteAllItems()
+ right_tree.deleteAllItems()
+ except Exception:
+ pass
+ # add swapped
+ try:
+ right_tree.addItems(left_model)
+ left_tree.addItems(right_model)
+ except Exception:
+ pass
+ #try:
+ # for it in right_model:
+ # left_tree.addItem(it)
+ # for it in left_model:
+ # right_tree.addItem(it)
+ #except Exception:
+ # pass
+#
+ ## select different items than before: pick last top-level in each
+ #try:
+ # if left_tree.hasItems():
+ # # pick last top-level's first sub-sub if available
+ # it = left_tree._items[-1]
+ # children = list(getattr(it, 'children', lambda: [])()) if callable(getattr(it, 'children', None)) else getattr(it, '_children', [])
+ # target = None
+ # if children:
+ # sc = children[0]
+ # sc2 = list(getattr(sc, 'children', lambda: [])()) if callable(getattr(sc, 'children', None)) else getattr(sc, '_children', [])
+ # if sc2:
+ # target = sc2[-1]
+ # if target is None:
+ # target = it
+ # left_tree.selectItem(target, True)
+ #except Exception:
+ # pass
+ #try:
+ # if right_tree.hasItems():
+ # it = right_tree._items[-1]
+ # children = list(getattr(it, 'children', lambda: [])()) if callable(getattr(it, 'children', None)) else getattr(it, '_children', [])
+ # target = None
+ # if children and len(children) >= 2:
+ # target = children[1]
+ # elif children:
+ # target = children[0]
+ # if target is None:
+ # target = it
+ # right_tree.selectItem(target, True)
+ #except Exception:
+ # pass
+
+ update_labels()
+ elif w == quit_btn and reason == yui.YEventReason.Activated:
+ dlg.destroy()
+ break
+
+ print("Dialog closed")
+
+
+if __name__ == '__main__':
+ # allow running directly
+ if len(sys.argv) > 1:
+ test_tree_example(sys.argv[1])
+ else:
+ test_tree_example()