diff --git a/manatools/aui/backends/__init__.py b/manatools/aui/backends/__init__.py index 5dd4929..6bc6152 100644 --- a/manatools/aui/backends/__init__.py +++ b/manatools/aui/backends/__init__.py @@ -12,7 +12,7 @@ _logger = logging.getLogger("manatools.aui.backends") # advertised subpackages (may be absent if not installed) -__all__ = ["gtk", "qt", "ncurses"] +__all__ = ["gtk", "qt", "ncurses", "web"] def __getattr__(name: str) -> ModuleType: """ diff --git a/manatools/aui/backends/qt/replacepointqt.py b/manatools/aui/backends/qt/replacepointqt.py index eb20cf1..746c938 100644 --- a/manatools/aui/backends/qt/replacepointqt.py +++ b/manatools/aui/backends/qt/replacepointqt.py @@ -210,13 +210,13 @@ def _attach_child_backend(self): except Exception: pass except Exception: - # fallback: try remove any previous parent then add + # fallback: widget may already be in the layout; check indexOf + # before re-adding. Never call setParent(None) on a visible + # widget — that promotes it to a top-level window (popup). try: - try: - cw.setParent(None) - except Exception: - pass - self._layout.addWidget(cw) + if self._layout.indexOf(cw) < 0: + cw.setParent(self._backend_widget) + self._layout.addWidget(cw) except Exception: self._logger.exception("_attach_child_backend: addWidget fallback failed") # Encourage the child to expand so content becomes visible diff --git a/manatools/aui/backends/qt/tableqt.py b/manatools/aui/backends/qt/tableqt.py index e1f3258..5d89a15 100644 --- a/manatools/aui/backends/qt/tableqt.py +++ b/manatools/aui/backends/qt/tableqt.py @@ -383,20 +383,23 @@ class YTableQt(YSelectionWidget): """ def __init__(self, parent, header: YTableHeader, multiSelection=False): - super().__init__(parent) + # All instance attributes must be set BEFORE super().__init__(parent). + # YWidget.__init__ calls parent.addChild(self), which causes + # YDumbTabQt.addChild → get_backend_widget() → _create_backend_widget() + # while this constructor is still executing. _create_backend_widget() + # must find every attribute it reads already in place. + if header is None: + raise ValueError("YTableQt requires a YTableHeader") self._header = header self._multi = bool(multiSelection) # Force single-selection when any checkbox column is 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") + try: + for c_idx in range(self._header.columns()): + if self._header.isCheckboxColumn(c_idx): + self._multi = False + break + except Exception: + pass self._view = None # QTableView self._model = None # _YTableModel @@ -407,6 +410,8 @@ def __init__(self, parent, header: YTableHeader, multiSelection=False): self._logger = logging.getLogger(f"manatools.aui.qt.{self.__class__.__name__}") self._changed_item = None + super().__init__(parent) # may trigger _create_backend_widget via addChild + def widgetClass(self): return "YTable" @@ -512,8 +517,8 @@ def rebuildTable(self): beginResetModel/endResetModel does not create any widgets — the view repaints only the visible viewport (O(visible×cols)). """ - self._logger.debug("rebuildTable: %d items", - len(self._items) if self._items else 0) + _items = getattr(self, '_items', None) or [] + self._logger.debug("rebuildTable: %d items", len(_items)) if self._model is None: self._create_backend_widget() return # _create_backend_widget calls rebuildTable recursively diff --git a/manatools/aui/backends/qt/treeqt.py b/manatools/aui/backends/qt/treeqt.py index a9dde92..3dff3ef 100644 --- a/manatools/aui/backends/qt/treeqt.py +++ b/manatools/aui/backends/qt/treeqt.py @@ -24,13 +24,15 @@ class YTreeQt(YSelectionWidget): - recursiveSelection if it should select children recursively """ def __init__(self, parent=None, label="", multiSelection=False, recursiveSelection=False): - super().__init__(parent) + # All instance attributes must be set BEFORE super().__init__(parent). + # YDumbTabQt.addChild calls get_backend_widget() synchronously when + # its content area already exists, which triggers _create_backend_widget() + # before the subclass constructor has had a chance to set its own attrs. 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) @@ -42,7 +44,10 @@ def __init__(self, parent=None, label="", multiSelection=False, recursiveSelecti 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__}") + self._logger = logging.getLogger(f"manatools.aui.qt.{self.__class__.__name__}") + + super().__init__(parent) # may trigger _create_backend_widget via YDumbTabQt.addChild + self._immediate = self.notify() def widgetClass(self): return "YTree" @@ -83,7 +88,8 @@ def rebuildTree(self): 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) + _items = getattr(self, '_items', None) or [] + self._logger.debug("rebuildTree: rebuilding tree with %d items", len(_items)) self._suppress_selection_handler = True if self._tree_widget is None: # ensure backend exists diff --git a/manatools/aui/backends/web/README.md b/manatools/aui/backends/web/README.md new file mode 100755 index 0000000..f326bf3 --- /dev/null +++ b/manatools/aui/backends/web/README.md @@ -0,0 +1,212 @@ +# ManaTools Web Backend + +A web-based backend for python-manatools AUI that renders applications in a web browser via HTTP/WebSocket. + +## Overview + +This package adds a new `web` backend to python-manatools, allowing any ManaTools application to be accessed through a web browser without any code changes. + +## Usage + +### Running with Web Backend + +Set the `MUI_BACKEND` environment variable to `web`: + +```bash +# Linux/macOS +export MUI_BACKEND=web +python your_app.py + +# Windows (Command Prompt) +set MUI_BACKEND=web +python your_app.py + +# Windows (PowerShell) +$env:MUI_BACKEND="web" +python your_app.py +``` + +When the application starts, it will display a URL: + +``` +================================================== + Dialog available at: http://127.0.0.1:8080/ + Open this URL in your web browser +================================================== +``` + +Open this URL in any modern web browser to interact with the application. + +### Application Code + +No changes are needed to your application code! The same code works with Qt, GTK, curses, or web backend: + +```python +from manatools.aui.yui import YUI + +factory = YUI.widgetFactory() +dialog = factory.createMainDialog() +vbox = factory.createVBox(dialog) + +factory.createLabel(vbox, "Hello, World!") +button = factory.createPushButton(vbox, "&OK") + +dialog.open() + +while True: + event = dialog.waitForEvent() + if event.widget() == button: + break + +dialog.destroy() +``` + +## Architecture + +``` +Browser (HTML/CSS/JS) + │ + │ WebSocket / HTTP + ▼ +┌─────────────────────────────┐ +│ WebServer (Python) │ +│ - HTTP: Serve HTML/CSS/JS │ +│ - WebSocket: Real-time │ +└─────────────┬───────────────┘ + │ + ▼ +┌─────────────────────────────┐ +│ YDialogWeb │ +│ - Event queue │ +│ - Widget tree → HTML │ +│ - waitForEvent() blocks │ +└─────────────────────────────┘ +``` + +## Widget Support + +All standard ManaTools widgets are supported: + +| Widget | Status | Notes | +|--------|--------|-------| +| YDialog | ✅ | Main and popup dialogs | +| YVBox, YHBox | ✅ | Layout containers | +| YLabel | ✅ | Text and headings | +| YPushButton | ✅ | With icons and shortcuts | +| YInputField | ✅ | Text and password mode | +| YCheckBox | ✅ | | +| YRadioButton | ✅ | | +| YComboBox | ✅ | Dropdown selection | +| YSelectionBox | ✅ | List selection | +| YFrame | ✅ | Grouped content | +| YCheckBoxFrame | ✅ | Toggleable frame | +| YProgressBar | ✅ | | +| YSlider | ✅ | | +| YTable | ✅ | | +| YTree | ✅ | | +| YRichText | ✅ | HTML content | +| YMenuBar | ✅ | | +| YImage | ✅ | | +| YIntField | ✅ | Number input | +| YDateField | ✅ | Date picker | +| YTimeField | ✅ | Time picker | +| YMultiLineEdit | ✅ | Textarea | +| YLogView | ✅ | Log display | +| YDumbTab | ✅ | Tab bar | +| YPaned | ✅ | Split panes | +| YSpacing | ✅ | Layout spacing | +| YAlignment | ✅ | Content alignment | +| YReplacePoint | ✅ | Dynamic content | + +## File Structure + +``` +manatools/aui/ +├── yui.py # Modified: Added Backend.WEB +├── yui_web.py # NEW: YUIWeb, YWidgetFactoryWeb, YApplicationWeb +└── backends/ + ├── __init__.py # Modified: Added "web" to __all__ + └── web/ # NEW: All web backend files + ├── __init__.py + ├── commonweb.py + ├── server.py + ├── dialogweb.py + ├── vboxweb.py + ├── hboxweb.py + ├── labelweb.py + ├── pushbuttonweb.py + ├── inputfieldweb.py + ├── checkboxweb.py + ├── comboboxweb.py + ├── selectionboxweb.py + ├── frameweb.py + ├── progressbarweb.py + ├── alignmentweb.py + ├── spacingweb.py + ├── treeweb.py + ├── tableweb.py + ├── richtextweb.py + ├── menubarweb.py + ├── replacepointweb.py + ├── checkboxframeweb.py + ├── radiobuttonweb.py + ├── intfieldweb.py + ├── multilineditweb.py + ├── imageweb.py + ├── dumbtabweb.py + ├── sliderweb.py + ├── datefieldweb.py + ├── timefieldweb.py + ├── logviewweb.py + ├── panedweb.py + └── static/ + ├── css/ + │ └── manatools.css + └── js/ + └── manatools.js +``` + +## Browser Support + +- Chrome/Chromium (recommended) +- Firefox +- Safari +- Edge + +WebSocket is required for real-time updates. Falls back to HTTP POST for older browsers. + +## Configuration + +The web server binds to `127.0.0.1` (localhost only) by default. To allow remote access, modify `server.py`: + +```python +self._server = WebServer(self, host="0.0.0.0", port=8080) +``` + +⚠️ **Security Warning**: Allowing remote access exposes your application to the network. Consider adding authentication for production use. + +## Dependencies + +**None!** The web backend uses only Python standard library: +- `http.server` - HTTP serving +- `threading` - Background server +- `queue` - Event queue +- `json` - WebSocket messages +- `hashlib`, `base64`, `struct` - WebSocket protocol + +## Limitations + +- File dialogs (`askForExistingFile`, etc.) are not supported in the browser +- Window positioning/sizing is handled by the browser +- System tray integration is not available +- Native menus use HTML menus instead + +## License + +LGPLv2+ (same as python-manatools) + +## Author + +Matteo Pasotti + +Based on python-manatools by Angelo Naselli diff --git a/manatools/aui/backends/web/__init__.py b/manatools/aui/backends/web/__init__.py new file mode 100755 index 0000000..4acb1bc --- /dev/null +++ b/manatools/aui/backends/web/__init__.py @@ -0,0 +1,68 @@ +""" +Web backend widget implementations for ManaTools AUI. + +This package provides HTML/WebSocket-based widget implementations +that can be accessed via a web browser. +""" + +from .dialogweb import YDialogWeb +from .vboxweb import YVBoxWeb +from .hboxweb import YHBoxWeb +from .labelweb import YLabelWeb +from .pushbuttonweb import YPushButtonWeb +from .inputfieldweb import YInputFieldWeb +from .checkboxweb import YCheckBoxWeb +from .comboboxweb import YComboBoxWeb +from .selectionboxweb import YSelectionBoxWeb +from .frameweb import YFrameWeb +from .progressbarweb import YProgressBarWeb +from .alignmentweb import YAlignmentWeb +from .spacingweb import YSpacingWeb +from .treeweb import YTreeWeb +from .tableweb import YTableWeb +from .richtextweb import YRichTextWeb +from .menubarweb import YMenuBarWeb +from .replacepointweb import YReplacePointWeb +from .checkboxframeweb import YCheckBoxFrameWeb +from .radiobuttonweb import YRadioButtonWeb +from .intfieldweb import YIntFieldWeb +from .multilineditweb import YMultiLineEditWeb +from .imageweb import YImageWeb +from .dumbtabweb import YDumbTabWeb +from .sliderweb import YSliderWeb +from .datefieldweb import YDateFieldWeb +from .timefieldweb import YTimeFieldWeb +from .logviewweb import YLogViewWeb +from .panedweb import YPanedWeb + +__all__ = [ + "YDialogWeb", + "YVBoxWeb", + "YHBoxWeb", + "YLabelWeb", + "YPushButtonWeb", + "YInputFieldWeb", + "YCheckBoxWeb", + "YComboBoxWeb", + "YSelectionBoxWeb", + "YFrameWeb", + "YProgressBarWeb", + "YAlignmentWeb", + "YSpacingWeb", + "YTreeWeb", + "YTableWeb", + "YRichTextWeb", + "YMenuBarWeb", + "YReplacePointWeb", + "YCheckBoxFrameWeb", + "YRadioButtonWeb", + "YIntFieldWeb", + "YMultiLineEditWeb", + "YImageWeb", + "YDumbTabWeb", + "YSliderWeb", + "YDateFieldWeb", + "YTimeFieldWeb", + "YLogViewWeb", + "YPanedWeb", +] diff --git a/manatools/aui/backends/web/alignmentweb.py b/manatools/aui/backends/web/alignmentweb.py new file mode 100755 index 0000000..791ce26 --- /dev/null +++ b/manatools/aui/backends/web/alignmentweb.py @@ -0,0 +1,70 @@ +""" +Web backend Alignment implementation. +""" + +from ...yui_common import YSingleChildContainerWidget, YAlignmentType +from .commonweb import widget_attrs + + +class YAlignmentWeb(YSingleChildContainerWidget): + """Alignment container widget.""" + + def __init__(self, parent=None, horAlign=YAlignmentType.YAlignUnchanged, vertAlign=YAlignmentType.YAlignUnchanged): + super().__init__(parent) + self._hor_align = horAlign + self._vert_align = vertAlign + self._min_width = 0 + self._min_height = 0 + + def widgetClass(self): + return "YAlignment" + + def setMinWidth(self, min_width: int): + self._min_width = max(0, int(min_width)) + + def setMinHeight(self, min_height: int): + self._min_height = max(0, int(min_height)) + + def setMinSize(self, min_width: int, min_height: int): + self.setMinWidth(min_width) + self.setMinHeight(min_height) + + def render(self) -> str: + # Map alignment to CSS + align_map = { + YAlignmentType.YAlignUnchanged: "stretch", + YAlignmentType.YAlignBegin: "flex-start", + YAlignmentType.YAlignEnd: "flex-end", + YAlignmentType.YAlignCenter: "center", + } + + h_align = align_map.get(self._hor_align, "stretch") + v_align = align_map.get(self._vert_align, "stretch") + + style_parts = [ + f"justify-content: {h_align}", + f"align-items: {v_align}", + ] + + if self._min_width > 0: + style_parts.append(f"min-width: {self._min_width}px") + if self._min_height > 0: + style_parts.append(f"min-height: {self._min_height}px") + + style = "; ".join(style_parts) + + extra_attrs = {"style": style} + + attrs = widget_attrs( + self.id(), + "YAlignment", + True, + self._visible, + extra_attrs=extra_attrs + ) + + content = "" + if self.child(): + content = self.child().render() + + return f'
{content}
' diff --git a/manatools/aui/backends/web/checkboxframeweb.py b/manatools/aui/backends/web/checkboxframeweb.py new file mode 100755 index 0000000..562030f --- /dev/null +++ b/manatools/aui/backends/web/checkboxframeweb.py @@ -0,0 +1,56 @@ +"""Web backend CheckBoxFrame implementation.""" +from ...yui_common import YSingleChildContainerWidget +from .commonweb import widget_attrs, escape_html, format_label_with_shortcut + +class YCheckBoxFrameWeb(YSingleChildContainerWidget): + """Frame with a checkbox in the legend that enables/disables content.""" + def __init__(self, parent=None, label: str = "", checked: bool = False): + super().__init__(parent) + self._label = label + self._checked = checked + + def widgetClass(self): + return "YCheckBoxFrame" + + def label(self) -> str: + return self._label + + def setLabel(self, label: str): + self._label = label + self._notify_update() + + def isChecked(self) -> bool: + return self._checked + + def setChecked(self, checked: bool = True): + self._checked = bool(checked) + self._notify_update() + + def value(self) -> bool: + return self._checked + + def setValue(self, checked: bool): + self.setChecked(checked) + + def _notify_update(self): + dialog = self.findDialog() + if dialog and hasattr(dialog, '_schedule_update'): + dialog._schedule_update(self) + + def render(self) -> str: + attrs = widget_attrs(self.id(), "YCheckBoxFrame", self._enabled, self._visible) + + checked_attr = " checked" if self._checked else "" + label_html = format_label_with_shortcut(self._label) + + legend = f''' + + {label_html} + ''' + + content = "" + if self.child(): + content = self.child().render() + + disabled_class = "" if self._checked else " mana-disabled" + return f'
{legend}
{content}
' diff --git a/manatools/aui/backends/web/checkboxweb.py b/manatools/aui/backends/web/checkboxweb.py new file mode 100755 index 0000000..5a924db --- /dev/null +++ b/manatools/aui/backends/web/checkboxweb.py @@ -0,0 +1,71 @@ +""" +Web backend CheckBox implementation. +""" + +from ...yui_common import YWidget, YCheckBoxState +from .commonweb import widget_attrs, escape_html, format_label_with_shortcut + + +class YCheckBoxWeb(YWidget): + """Checkbox widget.""" + + def __init__(self, parent=None, label: str = "", is_checked: bool = False): + super().__init__(parent) + self._label = label + self._checked = is_checked + self._tri_state = False + + def widgetClass(self): + return "YCheckBox" + + def label(self) -> str: + return self._label + + def setLabel(self, label: str): + self._label = label + self._notify_update() + + def isChecked(self) -> bool: + return self._checked + + def setChecked(self, checked: bool = True): + self._checked = bool(checked) + self._notify_update() + + def value(self) -> YCheckBoxState: + if self._checked: + return YCheckBoxState.YCheckBox_on + return YCheckBoxState.YCheckBox_off + + def setValue(self, state): + if isinstance(state, YCheckBoxState): + self._checked = state == YCheckBoxState.YCheckBox_on + else: + self._checked = bool(state) + self._notify_update() + + def _notify_update(self): + dialog = self.findDialog() + if dialog and hasattr(dialog, '_schedule_update'): + dialog._schedule_update(self) + + def render(self) -> str: + extra_attrs = { + "type": "checkbox", + "checked": self._checked, + } + + attrs = widget_attrs( + self.id(), + "YCheckBox", + self._enabled, + self._visible, + extra_attrs=extra_attrs + ) + + label_html = format_label_with_shortcut(self._label) + + return f'''''' diff --git a/manatools/aui/backends/web/comboboxweb.py b/manatools/aui/backends/web/comboboxweb.py new file mode 100755 index 0000000..64f479f --- /dev/null +++ b/manatools/aui/backends/web/comboboxweb.py @@ -0,0 +1,183 @@ +# -*- coding: utf-8 -*- +""" +Web backend ComboBox implementation. + +Author: Matteo Pasotti + +License: LGPLv2+ + +""" +import html as _html +from ...yui_common import YSelectionWidget, YItem +from .commonweb import widget_attrs, escape_html, format_label_with_shortcut + + +class YComboBoxWeb(YSelectionWidget): + """Dropdown combo box widget.""" + + def __init__(self, parent=None, label: str = "", editable: bool = False): + super().__init__(parent) + self._label = label + self._editable = editable + self._suppress_notify = False # batch guard + + def widgetClass(self): + return "YComboBox" + + def isEditable(self) -> bool: + return self._editable + + def editable(self) -> bool: + return self._editable + + def addItem(self, item, notify=True): + """Add a single item and optionally push a UI update.""" + super().addItem(item) + # Honor pre-selected state set by the caller before addItem(). + if hasattr(item, 'selected') and item.selected(): + if item not in self._selected_items: + # ComboBox is single-selection: replace any existing selection. + self._selected_items = [item] + if notify and not self._suppress_notify: + self._notify_update() + return item + + def value(self) -> str: + """Return the currently selected/entered value.""" + if self._selected_items: + return self._selected_items[0].label() + return "" + + def setValue(self, value: str): + """Set the value (selects matching item or sets text if editable).""" + for item in self._items: + if item.label() == value: + self._selected_items = [item] + self._notify_update() + return + if self._editable: + self._notify_update() + + def _handle_selection_change(self, index: int, value: str = None): + """Handle selection change from browser. + + Prefers label-based lookup via ``value`` (the option's value attribute, + which equals the item label) to avoid any index misalignment. Falls + back to positional lookup when ``value`` is None or unmatched. + """ + if value is not None: + for item in self._items: + if item.label() == value: + self._selected_items = [item] + return + # value sent but no match (should not happen with label-based options) + # fall through to index-based lookup below + + if 0 <= index < len(self._items): + self._selected_items = [self._items[index]] + + def updating(self): + """Context manager: batch multiple mutations into a single broadcast. + + Usage:: + + with combo.updating(): + combo.deleteAllItems() + combo.setLabel("New label") + combo.addItems(new_items) + # one broadcast fires here + """ + return _ComboUpdateContext(self) + + def setLabel(self, new_label: str): + """Set the combo label and push a re-render to the browser.""" + super().setLabel(new_label) + self._notify_update() + + def deleteAllItems(self): + """Clear all items and push a re-render to the browser.""" + super().deleteAllItems() + self._notify_update() + + def addItems(self, items): + """Add items and push a re-render to the browser.""" + super().addItems(items) + self._notify_update() + + def _set_backend_enabled(self, enabled: bool): + self._notify_update() + + def setVisible(self, visible: bool = True): + self._visible = bool(visible) + self._notify_update() + + def _notify_update(self): + if self._suppress_notify: + return + dialog = self.findDialog() + if dialog and hasattr(dialog, '_schedule_update'): + dialog._schedule_update(self) + + def render(self) -> str: + # The id goes on the outer container so that _schedule_update's + # querySelector("#widget_N") + replaceWith() swaps the whole widget + # (label + select) in one shot, preventing label duplication. + # Use a neutral container class (not mana-ycombobox) to avoid + # inheriting border/background CSS rules intended for the ' + ) + + return ( + f'' + f'{inner}' + f'' + ) + + +class _ComboUpdateContext: + """Context manager returned by YComboBoxWeb.updating().""" + __slots__ = ("_widget",) + + def __init__(self, widget): + self._widget = widget + + def __enter__(self): + self._widget._suppress_notify = True + return self._widget + + def __exit__(self, *_): + self._widget._suppress_notify = False + self._widget._notify_update() \ No newline at end of file diff --git a/manatools/aui/backends/web/commonweb.py b/manatools/aui/backends/web/commonweb.py new file mode 100755 index 0000000..e4129ed --- /dev/null +++ b/manatools/aui/backends/web/commonweb.py @@ -0,0 +1,136 @@ +""" +Common utilities shared across all web backend widgets. + +Author: Matteo Pasotti + +License: LGPLv2+ + +""" + +import html +import re +import threading +from typing import Optional + +# --------------------------------------------------------------------------- +# Initial-render context flag +# --------------------------------------------------------------------------- + +_render_context = threading.local() + + +def set_initial_render(flag: bool): + """Mark the current thread as performing an initial HTTP page render. + + When True, widgets that support deferred loading (e.g. YTable) emit a + lightweight skeleton placeholder instead of their full content. The real + content is pushed to the browser via WebSocket once the connection opens. + """ + _render_context.initial = flag + + +def is_initial_render() -> bool: + """Return True if the current thread is performing an initial HTTP page render.""" + return getattr(_render_context, 'initial', False) + +def escape_html(text: str) -> str: + """Escape HTML special characters.""" + return html.escape(str(text), quote=False) if text else "" + + +def format_label_with_shortcut(label: str) -> str: + """ + Convert a raw (unescaped) label with '&X' shortcut notation to safe HTML. + '&X' -> 'X' (shortcut underline) + '&&' -> '&' (literal ampersand) + All other text is HTML-escaped. + + IMPORTANT: pass the *raw* label here, NOT pre-escaped text. + Escaping before calling this function will corrupt the output. + """ + if not label: + return "" + parts = [] + i = 0 + while i < len(label): + if label[i] == '&': + if i + 1 < len(label): + next_ch = label[i + 1] + if next_ch == '&': + parts.append('&') + i += 2 + else: + parts.append(f'{html.escape(next_ch, quote=False)}') + i += 2 + else: + parts.append('&') + i += 1 + else: + parts.append(html.escape(label[i], quote=False)) + i += 1 + return "".join(parts) + + +def extract_shortcut(label: str) -> Optional[str]: + """Extract the shortcut character from a label with &X notation.""" + if not label: + return None + match = re.search(r'&([^&])', label) + return match.group(1).lower() if match else None + + +def strip_shortcut(label: str) -> str: + """Remove &X shortcut notation from label, keeping the character.""" + if not label: + return "" + result = re.sub(r'&&', '\x00', label) + result = re.sub(r'&(.)', r'\1', result) + return result.replace('\x00', '&') + + +def build_css_classes(*classes: str) -> str: + """Build a CSS class string from multiple class names, filtering empty.""" + return " ".join(c for c in classes if c) + + +def build_style(**styles) -> str: + """Build an inline style string from keyword arguments.""" + parts = [] + for key, value in styles.items(): + if value is not None: + # Convert Python names to CSS (background_color -> background-color) + css_key = key.replace('_', '-') + parts.append(f"{css_key}: {value}") + return "; ".join(parts) if parts else "" + + +def widget_attrs(widget_id: str, widget_class: str, enabled: bool = True, + visible: bool = True, extra_classes: str = "", + extra_attrs: dict = None) -> str: + """ + Build common HTML attributes for a widget element. + + Returns a string like: id="..." class="..." data-widget-class="..." [disabled] [hidden] + """ + classes = build_css_classes(f"mana-{widget_class.lower()}", extra_classes) + + attrs = [ + f'id="{escape_html(widget_id)}"', + f'class="{classes}"', + f'data-widget-class="{escape_html(widget_class)}"', + ] + + if not enabled: + attrs.append('disabled') + + if not visible: + attrs.append('style="display: none"') + + if extra_attrs: + for key, value in extra_attrs.items(): + if value is True: + attrs.append(key) + elif value is not None and value is not False: + attrs.append(f'{key}="{escape_html(str(value))}"') + + return " ".join(attrs) \ No newline at end of file diff --git a/manatools/aui/backends/web/datefieldweb.py b/manatools/aui/backends/web/datefieldweb.py new file mode 100755 index 0000000..2b2a489 --- /dev/null +++ b/manatools/aui/backends/web/datefieldweb.py @@ -0,0 +1,48 @@ +"""Web backend DateField implementation.""" +from ...yui_common import YWidget +from .commonweb import widget_attrs, escape_html, format_label_with_shortcut + +class YDateFieldWeb(YWidget): + """Date input field widget.""" + def __init__(self, parent=None, label: str = ""): + super().__init__(parent) + self._label = label + self._value = "" # ISO format: YYYY-MM-DD + + def widgetClass(self): + return "YDateField" + + def label(self) -> str: + return self._label + + def setLabel(self, label: str): + self._label = label + self._notify_update() + + def value(self) -> str: + return self._value + + def setValue(self, val: str): + self._value = str(val) if val else "" + self._notify_update() + + def _notify_update(self): + dialog = self.findDialog() + if dialog and hasattr(dialog, '_schedule_update'): + dialog._schedule_update(self) + + def render(self) -> str: + extra_attrs = { + "type": "date", + "value": self._value, + } + + attrs = widget_attrs(self.id(), "YDateField", self._enabled, self._visible, extra_attrs=extra_attrs) + + html = "" + if self._label: + label_html = format_label_with_shortcut(self._label) + html += f'' + + html += f'' + return f'
{html}
' diff --git a/manatools/aui/backends/web/dialogweb.py b/manatools/aui/backends/web/dialogweb.py new file mode 100755 index 0000000..59e3a4e --- /dev/null +++ b/manatools/aui/backends/web/dialogweb.py @@ -0,0 +1,649 @@ +# -*- coding: utf-8 -*- +""" +Web backend dialog implementation. + +Author: Matteo Pasotti + +License: LGPLv2+ + +YDialogWeb is the main container for web-based UI. It manages an HTTP server, +WebSocket connections, and the event loop for user interaction. +""" + +import queue +import threading +import logging +import json +from typing import Optional, List, TYPE_CHECKING +from importlib.resources import files + +from ...yui_common import ( + YSingleChildContainerWidget, + YDialogType, + YDialogColorMode, + YEvent, + YWidgetEvent, + YCancelEvent, + YTimeoutEvent, + YKeyEvent, + YMenuEvent, + YEventReason, + YUINoDialogException, +) +from .commonweb import escape_html + +if TYPE_CHECKING: + from .server import WebSocketHandler, WebServer + +logger = logging.getLogger("manatools.aui.web.YDialogWeb") + +# --------------------------------------------------------------------------- +# Page builder +# --------------------------------------------------------------------------- + +class PageBuilder: + """ + Builds the full HTML page by loading ``templates/dialog.html`` from the + package and substituting the three runtime slots: + + * ``{{ id }}`` - dialog id + * ``{{ title }}`` - dialog title + * ``{{ classes }}`` - dialog classes + * ``{{ content }}`` - rendered content + + The template is read from disk exactly once (class-level cache) so + repeated requests pay no I/O cost. + """ + + _template: Optional[str] = None + _template_lock = threading.Lock() + + @classmethod + def _load_template(cls) -> str: + if cls._template is None: + with cls._template_lock: + if cls._template is None: + cls._template = ( + files("manatools.aui.backends.web") + .joinpath("templates/dialog.html") + .read_text(encoding="utf-8") + ) + return cls._template + + @classmethod + def build(cls, *, _id: str, title: str, classes: str, content: str) -> str: + """Return the complete HTML dialog as a string.""" + return ( + cls._load_template() + .replace("{{ id }}", _id) + .replace("{{ classes }}", classes) + .replace("{{ title }}", title) + .replace("{{ content }}", content) + ) + + +class YDialogWeb(YSingleChildContainerWidget): + """ + Web-based dialog implementation. + + Manages an HTTP server to serve the dialog as HTML and uses WebSocket + for real-time event communication with the browser. + """ + + _open_dialogs: List["YDialogWeb"] = [] + _open_dialogs_lock: threading.RLock = threading.RLock() + + 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._event_queue: queue.Queue = queue.Queue() + self._server: Optional["WebServer"] = None + self._server_thread: Optional[threading.Thread] = None + self._websockets: List["WebSocketHandler"] = [] + self._websocket_lock = threading.Lock() + self._default_button = None + self._widget_registry: dict = {} # id -> widget mapping + self._pending_updates: dict = {} # widget_id -> threading.Timer + self._pending_lock = threading.Lock() + + with YDialogWeb._open_dialogs_lock: + YDialogWeb._open_dialogs.append(self) + logger.debug("YDialogWeb created: %s", self.debugLabel()) + + def widgetClass(self): + return "YDialog" + + @staticmethod + def currentDialog(doThrow=True) -> Optional["YDialogWeb"]: + """Return the topmost open dialog, or raise if none.""" + with YDialogWeb._open_dialogs_lock: + if YDialogWeb._open_dialogs: + return YDialogWeb._open_dialogs[-1] + if doThrow: + raise YUINoDialogException("No dialog is currently open") + return None + + @staticmethod + def topmostDialog(doThrow=True) -> Optional["YDialogWeb"]: + """Same as currentDialog.""" + return YDialogWeb.currentDialog(doThrow=doThrow) + + def isTopmostDialog(self) -> bool: + """Return whether this dialog is the topmost.""" + with YDialogWeb._open_dialogs_lock: + return YDialogWeb._open_dialogs[-1] == self if YDialogWeb._open_dialogs else False + + def render_modal_html(self) -> str: + """Render dialog content as a modal overlay fragment (no full page).""" + content = self.child().render() if self.child() else "" + return ( + f'
' + f'
{content}
' + f'
' + ) + + def open(self): + """ + Start the HTTP server (main dialog) or push a modal overlay (popup). + + This is non-blocking - call waitForEvent() to process events. + """ + if self._is_open: + return + + self._build_widget_registry() + + # Popup dialogs share the root dialog's server; they are rendered as + # modal overlays pushed via the existing WebSocket connection. + if self._dialog_type != YDialogType.YMainDialog: + with YDialogWeb._open_dialogs_lock: + root = next((d for d in YDialogWeb._open_dialogs if d._server is not None), None) + if root is not None: + self._is_open = True + self._broadcast({ + "type": "show_modal", + "dialog_id": self.id(), + "html": self.render_modal_html(), + }) + return + + from .server import WebServer + self._server = WebServer(self) + self._server_thread = threading.Thread(target=self._server.start, daemon=True) + self._server_thread.start() + + import time + for _ in range(50): + if self._server.is_running(): + break + time.sleep(0.1) + + self._is_open = True + print(f"\n{'='*50}") + print(f" Dialog available at: {self._server.get_url()}") + print(f" Open this URL in your web browser") + print(f"{'='*50}\n") + + def isOpen(self) -> bool: + return self._is_open + + def waitForEvent(self, timeout_millisec: int = 0) -> YEvent: + """ + Block until an event is received from the browser. + + Args: + timeout_millisec: Timeout in milliseconds (0 = no timeout) + + Returns: + YEvent (YWidgetEvent, YCancelEvent, YTimeoutEvent, etc.) + """ + if not self._is_open: + self.open() + + timeout = timeout_millisec / 1000.0 if timeout_millisec > 0 else None + + try: + event = self._event_queue.get(timeout=timeout) + return event + except queue.Empty: + return YTimeoutEvent() + + def destroy(self, doThrow=True) -> bool: + """Close the dialog and stop the server (or hide the modal overlay).""" + logger.debug("Destroying dialog: %s", self.debugLabel()) + + if self._server is None and self._is_open: + # Popup dialog: just hide the modal overlay in the browser. + try: + self._broadcast({ + "type": "hide_modal", + "dialog_id": self.id(), + }) + except Exception: + pass + self._is_open = False + with YDialogWeb._open_dialogs_lock: + if self in YDialogWeb._open_dialogs: + YDialogWeb._open_dialogs.remove(self) + return True + + # Main dialog: notify browsers, close connections, stop server. + try: + self._broadcast({ + "type": "shutdown", + "reason": "The application has closed.", + }) + except Exception: + pass + + with self._websocket_lock: + for ws in self._websockets: + try: + ws.close() + except Exception: + pass + self._websockets.clear() + + if self._server: + self._server.stop() + self._server = None + + self._is_open = False + + with YDialogWeb._open_dialogs_lock: + if self in YDialogWeb._open_dialogs: + YDialogWeb._open_dialogs.remove(self) + + return True + + @classmethod + def deleteTopmostDialog(cls, doThrow=True) -> bool: + """Delete the topmost dialog.""" + with cls._open_dialogs_lock: + if not cls._open_dialogs: + return False + dialog = cls._open_dialogs[-1] + return dialog.destroy(doThrow) + + @classmethod + def deleteAllDialogs(cls, doThrow=True) -> bool: + """Delete all open dialogs.""" + ok = True + while True: + with cls._open_dialogs_lock: + if not cls._open_dialogs: + break + dialog = cls._open_dialogs[-1] + try: + dialog.destroy(doThrow) + except Exception: + ok = False + with cls._open_dialogs_lock: + try: + cls._open_dialogs.remove(dialog) + except ValueError: + break + return ok + + def setDefaultButton(self, button) -> bool: + """Set the default button for this dialog.""" + if button is None: + self._default_button = None + return True + + try: + if button.widgetClass() != "YPushButton": + logger.error("Default button must be a YPushButton") + return False + except Exception: + return False + + self._default_button = button + return True + + def _post_event(self, event: YEvent): + """Post an event to the dialog's event queue.""" + self._event_queue.put(event) + + def _register_websocket(self, ws: "WebSocketHandler"): + """Register a new WebSocket connection.""" + with self._websocket_lock: + self._websockets.append(ws) + logger.debug("WebSocket connected, total: %d", len(self._websockets)) + + def _unregister_websocket(self, ws: "WebSocketHandler"): + """Unregister a WebSocket connection.""" + with self._websocket_lock: + if ws in self._websockets: + self._websockets.remove(ws) + logger.debug("WebSocket disconnected, remaining: %d", len(self._websockets)) + + def _broadcast(self, message: dict): + """Broadcast a message to all connected WebSocket clients. + + Popup dialogs own no server; they delegate to the first open dialog + that does own one (the root/main dialog). + """ + if self._server is None: + # Popup: route through the root dialog's connections. + with YDialogWeb._open_dialogs_lock: + root = next((d for d in YDialogWeb._open_dialogs if d._server is not None), None) + if root: + root._broadcast(message) + return + data = json.dumps(message) + with self._websocket_lock: + for ws in list(self._websockets): + try: + ws.send(data) + except Exception as e: + logger.debug("Failed to send to WebSocket: %s", e) + + def _handle_ws_message(self, data: dict): + """Handle a message received via WebSocket.""" + msg_type = data.get("type", "") + + if msg_type == "event": + self._handle_widget_event(data) + elif msg_type == "table_checkbox": + self._handle_table_checkbox(data) + elif msg_type == "link_activated": + self._handle_link_activation(data) + elif msg_type == "ready": + self._push_deferred_tables() + elif msg_type == "close": + self._post_event(YCancelEvent()) + elif msg_type == "key": + self._handle_key_event(data) + else: + logger.warning("Unknown WebSocket message type: %s", msg_type) + + def _handle_widget_event(self, data: dict): + """Handle a widget event from the browser.""" + widget_id = data.get("widget_id", "") + reason_str = data.get("reason", "Activated") + event_data = data.get("data", {}) + + widget = self._widget_registry.get(widget_id) + if not widget: + logger.warning("Widget not found: %s", widget_id) + return + + reason_map = { + "Activated": YEventReason.Activated, + "ValueChanged": YEventReason.ValueChanged, + "SelectionChanged": YEventReason.SelectionChanged, + } + reason = reason_map.get(reason_str, YEventReason.Activated) + + if "value" in event_data: + if hasattr(widget, "setValue"): + widget.setValue(event_data["value"]) + elif hasattr(widget, "_value"): + widget._value = event_data["value"] + + if "checked" in event_data: + if hasattr(widget, "setChecked"): + widget.setChecked(event_data["checked"]) + + # Tree item selection ? uses a stable item id instead of a + # flat index because tree items are nested. + if "itemId" in event_data: + if hasattr(widget, '_handle_item_click'): + widget._handle_item_click(event_data["itemId"]) + + # (existing selectedIndex / selectedValue block follows unchanged) + if "selectedIndex" in event_data or "selectedValue" in event_data: + if hasattr(widget, "_handle_selection_change"): + # Prefer value-based lookup: avoids off-by-one caused by a + # non-selectable label