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"]*>", "", 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 @@ + + + + +Created by potrace 1.16, written by Peter Selinger 2001-2019 + + + + + + + + + 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:

" + "
  • Alpha
  • Beta
  • Gamma
" + ) + 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

  • Line 1
  • Line 2
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:

" + "
  • Alpha
  • Beta
  • Gamma
" + ) + # 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()