janelkit-desktop 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 d4ndres
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,129 @@
1
+ Metadata-Version: 2.4
2
+ Name: janelkit-desktop
3
+ Version: 0.1.0
4
+ Summary: Diálogos nativos, portapapeles y notificaciones del sistema para JanelKit
5
+ Author-email: d4ndres <d4ndres@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/janelkit/janelkit
8
+ Project-URL: Documentation, https://github.com/janelkit/janelkit/tree/main/packages/janelkit-desktop#readme
9
+ Project-URL: Repository, https://github.com/janelkit/janelkit
10
+ Project-URL: Issues, https://github.com/janelkit/janelkit/issues
11
+ Project-URL: Changelog, https://github.com/janelkit/janelkit/blob/main/packages/janelkit-desktop/README.md#cambios
12
+ Keywords: janelkit,dialogs,clipboard,notifications,desktop,qt,pyside6
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: Microsoft :: Windows
16
+ Classifier: Operating System :: MacOS
17
+ Classifier: Operating System :: POSIX :: Linux
18
+ Classifier: Environment :: X11 Applications :: Qt
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3 :: Only
21
+ Classifier: Programming Language :: Python :: 3.10
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
26
+ Classifier: Topic :: Software Development :: User Interfaces
27
+ Classifier: Topic :: Desktop Environment
28
+ Classifier: Natural Language :: Spanish
29
+ Requires-Python: >=3.10
30
+ Description-Content-Type: text/markdown
31
+ License-File: LICENSE
32
+ Requires-Dist: janelkit<0.2,>=0.1
33
+ Dynamic: license-file
34
+
35
+ # janelkit-desktop
36
+
37
+ [![PyPI](https://img.shields.io/pypi/v/janelkit-desktop)](https://pypi.org/project/janelkit-desktop/)
38
+ [![Python](https://img.shields.io/pypi/pyversions/janelkit-desktop)](https://pypi.org/project/janelkit-desktop/)
39
+ [![CI](https://github.com/janelkit/janelkit/actions/workflows/ci.yml/badge.svg)](https://github.com/janelkit/janelkit/actions/workflows/ci.yml)
40
+ [![Licencia: MIT](https://img.shields.io/badge/licencia-MIT-blue)](https://github.com/janelkit/janelkit/blob/main/LICENSE)
41
+
42
+ **Servicios del sistema para aplicaciones [JanelKit](https://pypi.org/project/janelkit/):** diálogos nativos, portapapeles y notificaciones, con `await` y sin bloquear la ventana.
43
+
44
+ ```bash
45
+ pip install janelkit-desktop
46
+ ```
47
+
48
+ ## En un minuto
49
+
50
+ ```python
51
+ from janelkit_desktop import clipboard, dialogs, notify
52
+
53
+ async def abrir():
54
+ ruta = await dialogs.open_file("Abrir nota", filters=["Texto (*.txt *.md)"])
55
+ if ruta is not None: # None si el usuario cancela
56
+ texto.value = ruta.read_text()
57
+
58
+ async def borrar():
59
+ if await dialogs.confirm("¿Borrar la nota?"):
60
+ ...
61
+
62
+ def copiar():
63
+ clipboard.copy(texto.value)
64
+ notify("Copiado", "La nota está en el portapapeles")
65
+ ```
66
+
67
+ ```html
68
+ <Button on:click={abrir}>Abrir…</Button>
69
+ <Text>En el portapapeles: {clipboard.paste()}</Text>
70
+ ```
71
+
72
+ ## Características
73
+
74
+ - **Diálogos nativos y asíncronos.** Abrir, guardar, elegir carpeta, mensaje y confirmación. Se esperan con `await`: la ventana sigue respondiendo y el resultado llega como `Path`, una lista, `bool` o `None` si se cancela.
75
+ - **Portapapeles reactivo.** `clipboard.paste()` en la plantilla se actualiza cuando cambia el portapapeles, también si cambia desde otra aplicación.
76
+ - **Notificaciones del sistema.** Con icono en la bandeja. Al pulsar una, la ventana pasa al frente y se llama a `on_click`.
77
+ - **Fácil de probar.** Añade métodos a `janelkit.inspect` para responder diálogos y leer notificaciones en las pruebas, sin ventanas reales.
78
+ - **Solo API pública.** Usa `janelkit` y `janelkit.ext`, nada interno del núcleo; una prueba del repositorio lo comprueba.
79
+
80
+ ## Referencia
81
+
82
+ | Nombre | Descripción |
83
+ |---|---|
84
+ | `dialogs` | Diálogos del sistema, todos asíncronos (`await`): `open_file`, `open_files`, `save_file` y `choose_folder` (devuelven `Path`, una lista o `None` si se cancela), `message` y `confirm` (devuelve `bool`). Para pruebas, `pending()` lista los abiertos y `kind(d)` dice el tipo de uno (`"MessageDialog"`, `"FileDialog"`, `"FolderDialog"`). |
85
+ | `clipboard` | Portapapeles del sistema (texto): `copy(texto)`, `paste()` (el texto que hay, o `""` si no hay nada; reactivo) y `clear()`. |
86
+ | `notify` | `notify(título, mensaje="", on_click=None)` muestra una notificación del sistema. Donde no hay bandeja no se ve, pero queda registrada. |
87
+
88
+ Las tres piezas necesitan una aplicación en marcha.
89
+
90
+ ## En pruebas
91
+
92
+ Al importarse, el paquete añade estos métodos a `MountedApp` de `janelkit.inspect`:
93
+
94
+ | Método | Descripción |
95
+ |---|---|
96
+ | `app.dialogs()` | Diálogos abiertos, del más antiguo al más reciente. |
97
+ | `app.answer_dialog(resultado)` | Responde al último: una ruta (archivos o carpeta), `True` (aceptar) o `False` (cancelar). |
98
+ | `app.notifications()` | Notificaciones enviadas (`{"title", "message"}`), de la más antigua a la más reciente. |
99
+
100
+ ```python
101
+ from janelkit import inspect
102
+
103
+ def test_borrar_pregunta_antes():
104
+ with inspect.mount("src/App.janel") as app:
105
+ app.find(text="Borrar").click()
106
+ app.answer_dialog(False) # el usuario cancela
107
+ assert app.find(text="Mi nota")
108
+ ```
109
+
110
+ Ejemplos: [`apps/examples/files`](https://github.com/janelkit/janelkit/tree/main/apps/examples/files) (elegir carpeta), [`apps/challenges/gestor`](https://github.com/janelkit/janelkit/tree/main/apps/challenges/gestor) (confirmaciones) y [`apps/challenges/chat`](https://github.com/janelkit/janelkit/tree/main/apps/challenges/chat) (notificaciones).
111
+
112
+ ## Compatibilidad
113
+
114
+ | janelkit-desktop | janelkit | Python |
115
+ |---|---|---|
116
+ | 0.1.x | ≥ 0.1, < 0.2 | ≥ 3.10 |
117
+
118
+ Sigue la misma [política de versiones](https://github.com/janelkit/janelkit/blob/main/docs/api.md#paquetes-del-ecosistema) que el núcleo, con versión propia.
119
+
120
+ ## Cambios
121
+
122
+ ### 0.1.0
123
+
124
+ - Primera versión publicada. Antes eran los módulos `janelkit.dialogs`, `janelkit.clipboard` y `janelkit.notifications` del núcleo: cambia `from janelkit import dialogs, clipboard, notify` por `from janelkit_desktop import dialogs, clipboard, notify`.
125
+ - Compatible con `janelkit` 0.1 (`janelkit>=0.1,<0.2`).
126
+
127
+ ## Licencia
128
+
129
+ [MIT](https://github.com/janelkit/janelkit/blob/main/LICENSE).
@@ -0,0 +1,95 @@
1
+ # janelkit-desktop
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/janelkit-desktop)](https://pypi.org/project/janelkit-desktop/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/janelkit-desktop)](https://pypi.org/project/janelkit-desktop/)
5
+ [![CI](https://github.com/janelkit/janelkit/actions/workflows/ci.yml/badge.svg)](https://github.com/janelkit/janelkit/actions/workflows/ci.yml)
6
+ [![Licencia: MIT](https://img.shields.io/badge/licencia-MIT-blue)](https://github.com/janelkit/janelkit/blob/main/LICENSE)
7
+
8
+ **Servicios del sistema para aplicaciones [JanelKit](https://pypi.org/project/janelkit/):** diálogos nativos, portapapeles y notificaciones, con `await` y sin bloquear la ventana.
9
+
10
+ ```bash
11
+ pip install janelkit-desktop
12
+ ```
13
+
14
+ ## En un minuto
15
+
16
+ ```python
17
+ from janelkit_desktop import clipboard, dialogs, notify
18
+
19
+ async def abrir():
20
+ ruta = await dialogs.open_file("Abrir nota", filters=["Texto (*.txt *.md)"])
21
+ if ruta is not None: # None si el usuario cancela
22
+ texto.value = ruta.read_text()
23
+
24
+ async def borrar():
25
+ if await dialogs.confirm("¿Borrar la nota?"):
26
+ ...
27
+
28
+ def copiar():
29
+ clipboard.copy(texto.value)
30
+ notify("Copiado", "La nota está en el portapapeles")
31
+ ```
32
+
33
+ ```html
34
+ <Button on:click={abrir}>Abrir…</Button>
35
+ <Text>En el portapapeles: {clipboard.paste()}</Text>
36
+ ```
37
+
38
+ ## Características
39
+
40
+ - **Diálogos nativos y asíncronos.** Abrir, guardar, elegir carpeta, mensaje y confirmación. Se esperan con `await`: la ventana sigue respondiendo y el resultado llega como `Path`, una lista, `bool` o `None` si se cancela.
41
+ - **Portapapeles reactivo.** `clipboard.paste()` en la plantilla se actualiza cuando cambia el portapapeles, también si cambia desde otra aplicación.
42
+ - **Notificaciones del sistema.** Con icono en la bandeja. Al pulsar una, la ventana pasa al frente y se llama a `on_click`.
43
+ - **Fácil de probar.** Añade métodos a `janelkit.inspect` para responder diálogos y leer notificaciones en las pruebas, sin ventanas reales.
44
+ - **Solo API pública.** Usa `janelkit` y `janelkit.ext`, nada interno del núcleo; una prueba del repositorio lo comprueba.
45
+
46
+ ## Referencia
47
+
48
+ | Nombre | Descripción |
49
+ |---|---|
50
+ | `dialogs` | Diálogos del sistema, todos asíncronos (`await`): `open_file`, `open_files`, `save_file` y `choose_folder` (devuelven `Path`, una lista o `None` si se cancela), `message` y `confirm` (devuelve `bool`). Para pruebas, `pending()` lista los abiertos y `kind(d)` dice el tipo de uno (`"MessageDialog"`, `"FileDialog"`, `"FolderDialog"`). |
51
+ | `clipboard` | Portapapeles del sistema (texto): `copy(texto)`, `paste()` (el texto que hay, o `""` si no hay nada; reactivo) y `clear()`. |
52
+ | `notify` | `notify(título, mensaje="", on_click=None)` muestra una notificación del sistema. Donde no hay bandeja no se ve, pero queda registrada. |
53
+
54
+ Las tres piezas necesitan una aplicación en marcha.
55
+
56
+ ## En pruebas
57
+
58
+ Al importarse, el paquete añade estos métodos a `MountedApp` de `janelkit.inspect`:
59
+
60
+ | Método | Descripción |
61
+ |---|---|
62
+ | `app.dialogs()` | Diálogos abiertos, del más antiguo al más reciente. |
63
+ | `app.answer_dialog(resultado)` | Responde al último: una ruta (archivos o carpeta), `True` (aceptar) o `False` (cancelar). |
64
+ | `app.notifications()` | Notificaciones enviadas (`{"title", "message"}`), de la más antigua a la más reciente. |
65
+
66
+ ```python
67
+ from janelkit import inspect
68
+
69
+ def test_borrar_pregunta_antes():
70
+ with inspect.mount("src/App.janel") as app:
71
+ app.find(text="Borrar").click()
72
+ app.answer_dialog(False) # el usuario cancela
73
+ assert app.find(text="Mi nota")
74
+ ```
75
+
76
+ Ejemplos: [`apps/examples/files`](https://github.com/janelkit/janelkit/tree/main/apps/examples/files) (elegir carpeta), [`apps/challenges/gestor`](https://github.com/janelkit/janelkit/tree/main/apps/challenges/gestor) (confirmaciones) y [`apps/challenges/chat`](https://github.com/janelkit/janelkit/tree/main/apps/challenges/chat) (notificaciones).
77
+
78
+ ## Compatibilidad
79
+
80
+ | janelkit-desktop | janelkit | Python |
81
+ |---|---|---|
82
+ | 0.1.x | ≥ 0.1, < 0.2 | ≥ 3.10 |
83
+
84
+ Sigue la misma [política de versiones](https://github.com/janelkit/janelkit/blob/main/docs/api.md#paquetes-del-ecosistema) que el núcleo, con versión propia.
85
+
86
+ ## Cambios
87
+
88
+ ### 0.1.0
89
+
90
+ - Primera versión publicada. Antes eran los módulos `janelkit.dialogs`, `janelkit.clipboard` y `janelkit.notifications` del núcleo: cambia `from janelkit import dialogs, clipboard, notify` por `from janelkit_desktop import dialogs, clipboard, notify`.
91
+ - Compatible con `janelkit` 0.1 (`janelkit>=0.1,<0.2`).
92
+
93
+ ## Licencia
94
+
95
+ [MIT](https://github.com/janelkit/janelkit/blob/main/LICENSE).
@@ -0,0 +1,50 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "janelkit-desktop"
7
+ version = "0.1.0"
8
+ description = "Diálogos nativos, portapapeles y notificaciones del sistema para JanelKit"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "d4ndres", email = "d4ndres@gmail.com" }]
14
+ keywords = ["janelkit", "dialogs", "clipboard", "notifications", "desktop", "qt", "pyside6"]
15
+ dependencies = ["janelkit>=0.1,<0.2"] # rango del núcleo probado (docs/api.md, política de versiones)
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Developers",
19
+ "Operating System :: Microsoft :: Windows",
20
+ "Operating System :: MacOS",
21
+ "Operating System :: POSIX :: Linux",
22
+ "Environment :: X11 Applications :: Qt",
23
+ "Programming Language :: Python :: 3",
24
+ "Programming Language :: Python :: 3 :: Only",
25
+ "Programming Language :: Python :: 3.10",
26
+ "Programming Language :: Python :: 3.11",
27
+ "Programming Language :: Python :: 3.12",
28
+ "Programming Language :: Python :: 3.13",
29
+ "Topic :: Software Development :: Libraries :: Application Frameworks",
30
+ "Topic :: Software Development :: User Interfaces",
31
+ "Topic :: Desktop Environment",
32
+ "Natural Language :: Spanish",
33
+ ]
34
+
35
+ [project.urls]
36
+ Homepage = "https://github.com/janelkit/janelkit"
37
+ Documentation = "https://github.com/janelkit/janelkit/tree/main/packages/janelkit-desktop#readme"
38
+ Repository = "https://github.com/janelkit/janelkit"
39
+ Issues = "https://github.com/janelkit/janelkit/issues"
40
+ Changelog = "https://github.com/janelkit/janelkit/blob/main/packages/janelkit-desktop/README.md#cambios"
41
+
42
+ [tool.setuptools.packages.find]
43
+ where = ["src"]
44
+
45
+ [tool.pytest.ini_options]
46
+ testpaths = ["tests"]
47
+ addopts = "--import-mode=importlib"
48
+
49
+ [tool.setuptools.package-data]
50
+ janelkit_desktop = ["icon.png"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,12 @@
1
+ """Servicios del sistema para JanelKit: diálogos nativos, portapapeles y notificaciones.
2
+
3
+ from janelkit_desktop import clipboard, dialogs, notify
4
+ """
5
+ from __future__ import annotations
6
+
7
+ from . import clipboard, dialogs
8
+ from .notifications import notify
9
+
10
+ __version__ = "0.1.0"
11
+
12
+ __all__ = ["__version__", "clipboard", "dialogs", "notify"]
@@ -0,0 +1,62 @@
1
+ """Portapapeles del sistema (texto).
2
+
3
+ from janelkit_desktop import clipboard
4
+
5
+ clipboard.copy(enlace) # copia un texto
6
+ texto = clipboard.paste() # el texto que hay ("" si no hay)
7
+
8
+ `paste()` es reactivo: un binding o un `computed` que lo lee se actualiza cuando el
9
+ portapapeles cambia, también desde otra aplicación. Por ejemplo, un botón «Pegar» que
10
+ solo se activa si hay algo que pegar: `<Button enabled={bool(clipboard.paste())} …>`.
11
+
12
+ Necesita la aplicación en marcha (el script de un componente, o después de `run()`).
13
+ Los campos de texto ya copian y pegan solos con Ctrl+C y Ctrl+V; esto es para lo demás
14
+ (copiar un enlace, una celda, el contenido de una tarjeta).
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from typing import Any
19
+
20
+ from janelkit import State
21
+
22
+ _text: State[str] = State("")
23
+ _board: Any = None # QClipboard vigilado (se avisa a _text cuando cambia)
24
+
25
+
26
+ def copy(text: str) -> None:
27
+ """Pone `text` en el portapapeles."""
28
+ board = _clipboard()
29
+ board.setText(str(text))
30
+ _text.value = board.text()
31
+
32
+
33
+ def paste() -> str:
34
+ """El texto del portapapeles ("" si no hay texto). Reactivo."""
35
+ _clipboard()
36
+ return _text.value
37
+
38
+
39
+ def clear() -> None:
40
+ """Vacía el portapapeles."""
41
+ _clipboard().clear()
42
+ _text.value = ""
43
+
44
+
45
+ def _clipboard() -> Any:
46
+ global _board
47
+ from PySide6.QtGui import QGuiApplication
48
+
49
+ app = QGuiApplication.instance()
50
+ if app is None:
51
+ raise RuntimeError("el portapapeles necesita la aplicación en marcha: úsalo en el script de un componente o después de run()")
52
+ board = QGuiApplication.clipboard()
53
+ if board is not _board:
54
+ _board = board
55
+ board.dataChanged.connect(_changed)
56
+ _text.value = board.text()
57
+ return board
58
+
59
+
60
+ def _changed() -> None:
61
+ if _board is not None:
62
+ _text.value = _board.text()
@@ -0,0 +1,210 @@
1
+ """Diálogos nativos del sistema: abrir y guardar archivos, elegir carpeta y mensajes.
2
+
3
+ Son funciones asíncronas: se usan con `await` desde un manejador `async def`
4
+ (la ventana sigue viva mientras el diálogo está abierto):
5
+
6
+ from janelkit_desktop import dialogs
7
+
8
+ async def abrir():
9
+ ruta = await dialogs.open_file("Abrir nota", filters=["Texto (*.txt *.md)"])
10
+ if ruta is not None: # None si el usuario cancela
11
+ texto.value = ruta.read_text()
12
+
13
+ async def borrar():
14
+ if await dialogs.confirm("¿Borrar la nota?"):
15
+ ...
16
+
17
+ Usan QtQuick.Dialogs: diálogos nativos cuando la plataforma los ofrece y,
18
+ si no, una versión QML equivalente. Necesitan una aplicación en marcha (`run`).
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ from pathlib import Path
24
+ from typing import Any, Sequence
25
+
26
+ from janelkit.ext import app as running_app, inspector
27
+
28
+ _open: list[Any] = [] # diálogos abiertos (para pruebas: ver `pending`)
29
+
30
+
31
+ def _app() -> Any:
32
+ app = running_app()
33
+ if app is None:
34
+ raise RuntimeError("los diálogos necesitan una aplicación en marcha (janelkit.run)")
35
+ return app
36
+
37
+
38
+ def _create(qml_type: str, props: dict[str, Any]) -> Any:
39
+ from PySide6.QtCore import QUrl
40
+ from PySide6.QtQml import QQmlComponent
41
+
42
+ app = _app()
43
+ component = QQmlComponent(app.engine)
44
+ component.setData(f"import QtQuick\nimport QtQuick.Dialogs\n{qml_type} {{}}\n".encode(), QUrl())
45
+ dialog = component.create()
46
+ if dialog is None:
47
+ raise RuntimeError("; ".join(e.toString() for e in component.errors()) or f"no se pudo crear {qml_type}")
48
+ window = app.main_window()
49
+ if window is not None:
50
+ dialog.setProperty("parentWindow", window)
51
+ for name, value in props.items():
52
+ if value is not None:
53
+ dialog.setProperty(name, value)
54
+ # Propiedad explícita de Qt: con el motor como padre, ni Python ni el recolector de
55
+ # QML lo borran antes de que el usuario responda (se borra en _run al terminar).
56
+ dialog.setParent(app.engine)
57
+ component.setParent(dialog)
58
+ # El tipo, para `kind` (desde Python el diálogo puede llegar como un QObject genérico).
59
+ dialog.setProperty("jkDialogType", qml_type)
60
+ return dialog
61
+
62
+
63
+ async def _run(dialog: Any, on_accept, on_close=None) -> Any:
64
+ """Abre el diálogo y espera. `on_close` (si se da) decide el resultado al cerrarse de cualquier forma."""
65
+ loop = asyncio.get_running_loop()
66
+ future: asyncio.Future = loop.create_future()
67
+
68
+ def done(result: Any) -> None:
69
+ if not future.done():
70
+ future.set_result(result)
71
+
72
+ # Conexiones y llamadas por nombre: según la versión de PySide6, el diálogo llega
73
+ # como su tipo QML o como un QObject genérico sin esos atributos en Python.
74
+ from PySide6.QtCore import SIGNAL, QMetaObject, QObject
75
+
76
+ if on_close is not None:
77
+ QObject.connect(dialog, SIGNAL("accepted()"), lambda: done(on_close(dialog)))
78
+ QObject.connect(dialog, SIGNAL("rejected()"), lambda: done(on_close(dialog)))
79
+ else:
80
+ QObject.connect(dialog, SIGNAL("accepted()"), lambda: done(on_accept(dialog)))
81
+ QObject.connect(dialog, SIGNAL("rejected()"), lambda: done(None))
82
+ _open.append(dialog)
83
+ try:
84
+ QMetaObject.invokeMethod(dialog, "open")
85
+ return await future
86
+ finally:
87
+ _open.remove(dialog)
88
+ if not future.done(): # cancelado (p. ej. al desmontar el componente)
89
+ QMetaObject.invokeMethod(dialog, "close")
90
+ dialog.deleteLater()
91
+
92
+
93
+ def _url(path: str | Path | None) -> Any:
94
+ if path is None:
95
+ return None
96
+ from PySide6.QtCore import QUrl
97
+
98
+ return QUrl.fromLocalFile(str(Path(path).resolve()))
99
+
100
+
101
+ def _path(url: Any) -> Path | None:
102
+ local = url.toLocalFile() if hasattr(url, "toLocalFile") else str(url)
103
+ return Path(local) if local else None
104
+
105
+
106
+ def _filters(filters: Sequence[str] | None) -> list[str] | None:
107
+ return list(filters) if filters else None
108
+
109
+
110
+ async def open_file(
111
+ title: str = "Abrir", filters: Sequence[str] | None = None, folder: str | Path | None = None
112
+ ) -> Path | None:
113
+ """Pide un archivo existente. `filters`: p. ej. `["Imágenes (*.png *.jpg)", "Todos (*)"]`. None si se cancela."""
114
+ dialog = _create("FileDialog", {"title": title, "nameFilters": _filters(filters), "currentFolder": _url(folder)})
115
+ dialog.setProperty("fileMode", 0) # FileDialog.OpenFile
116
+ return await _run(dialog, lambda d: _path(d.property("selectedFile")))
117
+
118
+
119
+ async def open_files(
120
+ title: str = "Abrir", filters: Sequence[str] | None = None, folder: str | Path | None = None
121
+ ) -> list[Path]:
122
+ """Pide uno o varios archivos existentes. Lista vacía si se cancela."""
123
+ dialog = _create("FileDialog", {"title": title, "nameFilters": _filters(filters), "currentFolder": _url(folder)})
124
+ dialog.setProperty("fileMode", 1) # FileDialog.OpenFiles
125
+ result = await _run(dialog, lambda d: [p for p in map(_path, d.property("selectedFiles") or []) if p])
126
+ return result or []
127
+
128
+
129
+ async def save_file(
130
+ title: str = "Guardar",
131
+ filters: Sequence[str] | None = None,
132
+ folder: str | Path | None = None,
133
+ name: str | None = None,
134
+ ) -> Path | None:
135
+ """Pide dónde guardar. `name`: nombre propuesto. None si se cancela."""
136
+ props = {"title": title, "nameFilters": _filters(filters), "currentFolder": _url(folder)}
137
+ if name:
138
+ base = Path(folder) if folder else Path.cwd()
139
+ props["selectedFile"] = _url(base / name)
140
+ dialog = _create("FileDialog", props)
141
+ dialog.setProperty("fileMode", 2) # FileDialog.SaveFile
142
+ return await _run(dialog, lambda d: _path(d.property("selectedFile")))
143
+
144
+
145
+ async def choose_folder(title: str = "Elegir carpeta", folder: str | Path | None = None) -> Path | None:
146
+ """Pide una carpeta. None si se cancela."""
147
+ dialog = _create("FolderDialog", {"title": title, "currentFolder": _url(folder)})
148
+ return await _run(dialog, lambda d: _path(d.property("selectedFolder")))
149
+
150
+
151
+ _OK, _CANCEL = 0x00000400, 0x00400000 # MessageDialog.Ok, MessageDialog.Cancel
152
+ # En MessageDialog, `result` es el botón pulsado (no Accepted/Rejected).
153
+
154
+
155
+ async def message(text: str, title: str = "", details: str | None = None) -> None:
156
+ """Muestra un mensaje con un botón Aceptar y espera a que se cierre."""
157
+ dialog = _create("MessageDialog", {"title": title, "text": text, "informativeText": details, "buttons": _OK})
158
+ await _run(dialog, None, on_close=lambda d: None)
159
+
160
+
161
+ async def confirm(text: str, title: str = "", details: str | None = None) -> bool:
162
+ """Pregunta Aceptar / Cancelar. True si se acepta."""
163
+ dialog = _create(
164
+ "MessageDialog", {"title": title, "text": text, "informativeText": details, "buttons": _OK | _CANCEL}
165
+ )
166
+ return bool(await _run(dialog, None, on_close=lambda d: d.property("result") == _OK))
167
+
168
+
169
+ def pending() -> list[Any]:
170
+ """Diálogos abiertos ahora mismo (objetos Qt). Útil en pruebas para aceptarlos o cancelarlos."""
171
+ return list(_open)
172
+
173
+
174
+ def kind(dialog: Any) -> str:
175
+ """El tipo QML de un diálogo abierto: "MessageDialog", "FileDialog" o "FolderDialog"."""
176
+ return str(dialog.property("jkDialogType") or "")
177
+
178
+
179
+ # -- en pruebas: `app.dialogs()` y `app.answer_dialog(...)` de janelkit.inspect ----------
180
+ @inspector("dialogs")
181
+ def _inspect_pending(app: Any) -> list[Any]:
182
+ """Diálogos abiertos, del más antiguo al más reciente."""
183
+ return pending()
184
+
185
+
186
+ @inspector("answer_dialog")
187
+ def _inspect_answer(app: Any, result: Any = True) -> None:
188
+ """Responde al diálogo abierto: una ruta (archivos), True/False (aceptar/cancelar)."""
189
+ from PySide6.QtCore import Q_ARG, QMetaObject, QUrl
190
+
191
+ from janelkit.inspect import InspectError
192
+
193
+ def invoke(method: str, *args: Any) -> None:
194
+ if not QMetaObject.invokeMethod(dialog, method, *args):
195
+ raise InspectError(f"no se pudo invocar {method} en {dialog.metaObject().className()}")
196
+
197
+ if not _open:
198
+ raise InspectError("no hay ningún diálogo abierto")
199
+ dialog = _open[-1]
200
+ if result is False or result is None:
201
+ invoke("reject")
202
+ elif isinstance(result, (str, Path)):
203
+ prop = "selectedFolder" if kind(dialog) == "FolderDialog" else "selectedFile"
204
+ dialog.setProperty(prop, QUrl.fromLocalFile(str(Path(result).resolve())))
205
+ invoke("done", Q_ARG(int, 1))
206
+ elif kind(dialog) == "MessageDialog":
207
+ invoke("done", Q_ARG(int, _OK)) # Aceptar
208
+ else:
209
+ invoke("done", Q_ARG(int, 1))
210
+ app.wait(0.01)
@@ -0,0 +1,101 @@
1
+ """Notificaciones del sistema (el globo o aviso del área de notificaciones).
2
+
3
+ from janelkit_desktop import notify
4
+
5
+ def al_recibir(mensaje):
6
+ if not ventana_activa.value:
7
+ notify(mensaje["autor"], mensaje["texto"], on_click=abrir_conversacion)
8
+
9
+ La primera notificación muestra el icono de la aplicación en la bandeja del
10
+ sistema; pulsar la notificación trae la ventana principal al frente (y llama a
11
+ `on_click`, si se pasa). Donde la plataforma no tiene bandeja, `notify` no hace
12
+ nada visible, pero la notificación queda registrada (`sent()`, para pruebas).
13
+
14
+ Usa la bandeja nativa de Qt (Qt.labs.platform): no necesita QtWidgets.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from pathlib import Path
19
+ from typing import Any, Callable
20
+
21
+ from janelkit.ext import app as running_app, inspector
22
+
23
+ _sent: list[dict[str, str]] = [] # notificaciones enviadas en este proceso (ver `sent`)
24
+ _tray: Any = None
25
+ _on_click: Callable[[], Any] | None = None
26
+
27
+ _ICON = Path(__file__).parent / "icon.png"
28
+
29
+
30
+ def notify(title: str, message: str = "", *, on_click: Callable[[], Any] | None = None) -> None:
31
+ """Muestra una notificación del sistema con `title` y `message`."""
32
+ global _on_click
33
+ _sent.append({"title": str(title), "message": str(message)})
34
+ _on_click = on_click
35
+ tray = _tray_icon()
36
+ if tray is not None and tray.property("available") and tray.property("supportsMessages"):
37
+ tray.setProperty("visible", True)
38
+ from PySide6.QtCore import Q_ARG, QMetaObject
39
+
40
+ QMetaObject.invokeMethod(tray, "showMessage", Q_ARG("QString", str(title)), Q_ARG("QString", str(message)))
41
+
42
+
43
+ def sent() -> list[dict[str, str]]:
44
+ """Notificaciones enviadas hasta ahora, de la más antigua a la más reciente (para pruebas)."""
45
+ return list(_sent)
46
+
47
+
48
+ def _tray_icon() -> Any:
49
+ global _tray
50
+ from shiboken6 import Shiboken
51
+
52
+ app = running_app()
53
+ if app is None:
54
+ return None
55
+ if _tray is not None and Shiboken.isValid(_tray) and _tray.parent() is app.engine:
56
+ return _tray
57
+ from PySide6.QtCore import QUrl
58
+ from PySide6.QtQml import QQmlComponent
59
+
60
+ component = QQmlComponent(app.engine)
61
+ component.setData(
62
+ (
63
+ "import QtQuick\nimport Qt.labs.platform as P\n"
64
+ "P.SystemTrayIcon {\n"
65
+ f' icon.source: "{QUrl.fromLocalFile(str(_ICON)).toString()}"\n'
66
+ " signal clicked()\n"
67
+ " onMessageClicked: clicked()\n"
68
+ " onActivated: clicked()\n"
69
+ "}\n"
70
+ ).encode(),
71
+ QUrl(),
72
+ )
73
+ tray = component.create()
74
+ if tray is None: # sin Qt.labs.platform: se registran pero no se muestran
75
+ import logging
76
+
77
+ logging.getLogger("janelkit").warning("notificaciones no disponibles: %s", "; ".join(e.toString() for e in component.errors()))
78
+ return None
79
+ tray.setParent(app.engine)
80
+ component.setParent(tray)
81
+ tray.clicked.connect(_clicked)
82
+ app.qt_app.aboutToQuit.connect(lambda: tray.setProperty("visible", False))
83
+ _tray = tray
84
+ return tray
85
+
86
+
87
+ def _clicked() -> None:
88
+ app = running_app()
89
+ window = app.main_window() if app is not None else None
90
+ if window is not None:
91
+ window.show()
92
+ window.raise_()
93
+ window.requestActivate()
94
+ if _on_click is not None:
95
+ _on_click()
96
+
97
+
98
+ @inspector("notifications")
99
+ def _inspect_sent(app: Any) -> list[dict[str, str]]:
100
+ """Notificaciones enviadas con `notify` (`{"title", "message"}`), de la más antigua a la más reciente."""
101
+ return sent()
@@ -0,0 +1,129 @@
1
+ Metadata-Version: 2.4
2
+ Name: janelkit-desktop
3
+ Version: 0.1.0
4
+ Summary: Diálogos nativos, portapapeles y notificaciones del sistema para JanelKit
5
+ Author-email: d4ndres <d4ndres@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/janelkit/janelkit
8
+ Project-URL: Documentation, https://github.com/janelkit/janelkit/tree/main/packages/janelkit-desktop#readme
9
+ Project-URL: Repository, https://github.com/janelkit/janelkit
10
+ Project-URL: Issues, https://github.com/janelkit/janelkit/issues
11
+ Project-URL: Changelog, https://github.com/janelkit/janelkit/blob/main/packages/janelkit-desktop/README.md#cambios
12
+ Keywords: janelkit,dialogs,clipboard,notifications,desktop,qt,pyside6
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: Microsoft :: Windows
16
+ Classifier: Operating System :: MacOS
17
+ Classifier: Operating System :: POSIX :: Linux
18
+ Classifier: Environment :: X11 Applications :: Qt
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3 :: Only
21
+ Classifier: Programming Language :: Python :: 3.10
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
26
+ Classifier: Topic :: Software Development :: User Interfaces
27
+ Classifier: Topic :: Desktop Environment
28
+ Classifier: Natural Language :: Spanish
29
+ Requires-Python: >=3.10
30
+ Description-Content-Type: text/markdown
31
+ License-File: LICENSE
32
+ Requires-Dist: janelkit<0.2,>=0.1
33
+ Dynamic: license-file
34
+
35
+ # janelkit-desktop
36
+
37
+ [![PyPI](https://img.shields.io/pypi/v/janelkit-desktop)](https://pypi.org/project/janelkit-desktop/)
38
+ [![Python](https://img.shields.io/pypi/pyversions/janelkit-desktop)](https://pypi.org/project/janelkit-desktop/)
39
+ [![CI](https://github.com/janelkit/janelkit/actions/workflows/ci.yml/badge.svg)](https://github.com/janelkit/janelkit/actions/workflows/ci.yml)
40
+ [![Licencia: MIT](https://img.shields.io/badge/licencia-MIT-blue)](https://github.com/janelkit/janelkit/blob/main/LICENSE)
41
+
42
+ **Servicios del sistema para aplicaciones [JanelKit](https://pypi.org/project/janelkit/):** diálogos nativos, portapapeles y notificaciones, con `await` y sin bloquear la ventana.
43
+
44
+ ```bash
45
+ pip install janelkit-desktop
46
+ ```
47
+
48
+ ## En un minuto
49
+
50
+ ```python
51
+ from janelkit_desktop import clipboard, dialogs, notify
52
+
53
+ async def abrir():
54
+ ruta = await dialogs.open_file("Abrir nota", filters=["Texto (*.txt *.md)"])
55
+ if ruta is not None: # None si el usuario cancela
56
+ texto.value = ruta.read_text()
57
+
58
+ async def borrar():
59
+ if await dialogs.confirm("¿Borrar la nota?"):
60
+ ...
61
+
62
+ def copiar():
63
+ clipboard.copy(texto.value)
64
+ notify("Copiado", "La nota está en el portapapeles")
65
+ ```
66
+
67
+ ```html
68
+ <Button on:click={abrir}>Abrir…</Button>
69
+ <Text>En el portapapeles: {clipboard.paste()}</Text>
70
+ ```
71
+
72
+ ## Características
73
+
74
+ - **Diálogos nativos y asíncronos.** Abrir, guardar, elegir carpeta, mensaje y confirmación. Se esperan con `await`: la ventana sigue respondiendo y el resultado llega como `Path`, una lista, `bool` o `None` si se cancela.
75
+ - **Portapapeles reactivo.** `clipboard.paste()` en la plantilla se actualiza cuando cambia el portapapeles, también si cambia desde otra aplicación.
76
+ - **Notificaciones del sistema.** Con icono en la bandeja. Al pulsar una, la ventana pasa al frente y se llama a `on_click`.
77
+ - **Fácil de probar.** Añade métodos a `janelkit.inspect` para responder diálogos y leer notificaciones en las pruebas, sin ventanas reales.
78
+ - **Solo API pública.** Usa `janelkit` y `janelkit.ext`, nada interno del núcleo; una prueba del repositorio lo comprueba.
79
+
80
+ ## Referencia
81
+
82
+ | Nombre | Descripción |
83
+ |---|---|
84
+ | `dialogs` | Diálogos del sistema, todos asíncronos (`await`): `open_file`, `open_files`, `save_file` y `choose_folder` (devuelven `Path`, una lista o `None` si se cancela), `message` y `confirm` (devuelve `bool`). Para pruebas, `pending()` lista los abiertos y `kind(d)` dice el tipo de uno (`"MessageDialog"`, `"FileDialog"`, `"FolderDialog"`). |
85
+ | `clipboard` | Portapapeles del sistema (texto): `copy(texto)`, `paste()` (el texto que hay, o `""` si no hay nada; reactivo) y `clear()`. |
86
+ | `notify` | `notify(título, mensaje="", on_click=None)` muestra una notificación del sistema. Donde no hay bandeja no se ve, pero queda registrada. |
87
+
88
+ Las tres piezas necesitan una aplicación en marcha.
89
+
90
+ ## En pruebas
91
+
92
+ Al importarse, el paquete añade estos métodos a `MountedApp` de `janelkit.inspect`:
93
+
94
+ | Método | Descripción |
95
+ |---|---|
96
+ | `app.dialogs()` | Diálogos abiertos, del más antiguo al más reciente. |
97
+ | `app.answer_dialog(resultado)` | Responde al último: una ruta (archivos o carpeta), `True` (aceptar) o `False` (cancelar). |
98
+ | `app.notifications()` | Notificaciones enviadas (`{"title", "message"}`), de la más antigua a la más reciente. |
99
+
100
+ ```python
101
+ from janelkit import inspect
102
+
103
+ def test_borrar_pregunta_antes():
104
+ with inspect.mount("src/App.janel") as app:
105
+ app.find(text="Borrar").click()
106
+ app.answer_dialog(False) # el usuario cancela
107
+ assert app.find(text="Mi nota")
108
+ ```
109
+
110
+ Ejemplos: [`apps/examples/files`](https://github.com/janelkit/janelkit/tree/main/apps/examples/files) (elegir carpeta), [`apps/challenges/gestor`](https://github.com/janelkit/janelkit/tree/main/apps/challenges/gestor) (confirmaciones) y [`apps/challenges/chat`](https://github.com/janelkit/janelkit/tree/main/apps/challenges/chat) (notificaciones).
111
+
112
+ ## Compatibilidad
113
+
114
+ | janelkit-desktop | janelkit | Python |
115
+ |---|---|---|
116
+ | 0.1.x | ≥ 0.1, < 0.2 | ≥ 3.10 |
117
+
118
+ Sigue la misma [política de versiones](https://github.com/janelkit/janelkit/blob/main/docs/api.md#paquetes-del-ecosistema) que el núcleo, con versión propia.
119
+
120
+ ## Cambios
121
+
122
+ ### 0.1.0
123
+
124
+ - Primera versión publicada. Antes eran los módulos `janelkit.dialogs`, `janelkit.clipboard` y `janelkit.notifications` del núcleo: cambia `from janelkit import dialogs, clipboard, notify` por `from janelkit_desktop import dialogs, clipboard, notify`.
125
+ - Compatible con `janelkit` 0.1 (`janelkit>=0.1,<0.2`).
126
+
127
+ ## Licencia
128
+
129
+ [MIT](https://github.com/janelkit/janelkit/blob/main/LICENSE).
@@ -0,0 +1,15 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/janelkit_desktop/__init__.py
5
+ src/janelkit_desktop/clipboard.py
6
+ src/janelkit_desktop/dialogs.py
7
+ src/janelkit_desktop/icon.png
8
+ src/janelkit_desktop/notifications.py
9
+ src/janelkit_desktop.egg-info/PKG-INFO
10
+ src/janelkit_desktop.egg-info/SOURCES.txt
11
+ src/janelkit_desktop.egg-info/dependency_links.txt
12
+ src/janelkit_desktop.egg-info/requires.txt
13
+ src/janelkit_desktop.egg-info/top_level.txt
14
+ tests/test_clipboard.py
15
+ tests/test_dialogs.py
@@ -0,0 +1 @@
1
+ janelkit<0.2,>=0.1
@@ -0,0 +1 @@
1
+ janelkit_desktop
@@ -0,0 +1,51 @@
1
+ """Portapapeles (janelkit_desktop.clipboard): copiar, pegar y pegar reactivo."""
2
+ import tempfile
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+ from PySide6.QtGui import QGuiApplication
7
+
8
+ from janelkit.inspect import mount
9
+ from janelkit_desktop import clipboard
10
+
11
+
12
+ @pytest.fixture
13
+ def app_from():
14
+ apps = []
15
+
16
+ def run(source: str):
17
+ d = Path(tempfile.mkdtemp())
18
+ (d / "App.janel").write_text(source, encoding="utf-8")
19
+ ctx = mount(d / "App.janel")
20
+ app = ctx.__enter__()
21
+ apps.append(ctx)
22
+ app.window.resize(400, 300)
23
+ app.wait(0.05)
24
+ return app
25
+
26
+ yield run
27
+ for ctx in apps:
28
+ ctx.__exit__(None, None, None)
29
+
30
+
31
+ def test_copy_paste_and_paste_is_reactive(app_from):
32
+ app = app_from("""<script>
33
+ from janelkit_desktop import clipboard
34
+ </script>
35
+ <Column>
36
+ <Button text="Copiar" on:click={clipboard.copy("https://ejemplo.com/42")} />
37
+ <Button text="Pegar" enabled={bool(clipboard.paste())} />
38
+ <Text id="pegado">{clipboard.paste() or "(vacío)"}</Text>
39
+ </Column>""")
40
+ clipboard.clear()
41
+ app.process()
42
+ assert app.find("Text", id="pegado").text == "(vacío)"
43
+ assert app.find("Button", text="Pegar").item.property("enabled") is False
44
+ app.find("Button", text="Copiar").click()
45
+ assert QGuiApplication.clipboard().text() == "https://ejemplo.com/42"
46
+ assert clipboard.paste() == "https://ejemplo.com/42"
47
+ assert app.find("Text", id="pegado").text == "https://ejemplo.com/42"
48
+ assert app.find("Button", text="Pegar").item.property("enabled") is True
49
+ QGuiApplication.clipboard().setText("desde otra aplicación") # cambio que no pasa por JanelKit
50
+ app.process()
51
+ assert app.find("Text", id="pegado").text == "desde otra aplicación"
@@ -0,0 +1,102 @@
1
+ """Diálogos nativos (janelkit_desktop.dialogs), también respondidos desde janelkit.inspect."""
2
+ import asyncio
3
+
4
+ from PySide6.QtCore import QMetaObject, QUrl, Q_ARG
5
+
6
+ from janelkit.inspect import mount as mount_app
7
+ from janelkit_desktop import dialogs
8
+
9
+
10
+ def test_open_file_dialog_returns_the_selected_path(janel_app, mount, tmp_path):
11
+ mount("<Text>x</Text>")
12
+ target = tmp_path / "nota.txt"
13
+ target.write_text("x", encoding="utf-8") # FileDialog solo acepta archivos que existen
14
+
15
+ async def scenario():
16
+ task = asyncio.ensure_future(dialogs.open_file("Abrir", filters=["Texto (*.txt)"], folder=tmp_path))
17
+ await asyncio.sleep(0.05)
18
+ (dialog,) = dialogs.pending()
19
+ assert dialog.property("title") == "Abrir" and dialog.property("nameFilters") == ["Texto (*.txt)"]
20
+ dialog.setProperty("selectedFile", QUrl.fromLocalFile(str(target)))
21
+ QMetaObject.invokeMethod(dialog, "done", Q_ARG(int, 1)) # como si el usuario aceptara
22
+ return await task
23
+
24
+ assert janel_app.run_until(scenario()) == target
25
+ assert dialogs.pending() == []
26
+
27
+
28
+ def test_cancelled_dialogs_return_none_or_false(janel_app, mount):
29
+ mount("<Text>x</Text>")
30
+
31
+ async def cancel_next(coro):
32
+ task = asyncio.ensure_future(coro)
33
+ await asyncio.sleep(0.05)
34
+ QMetaObject.invokeMethod(dialogs.pending()[0], "reject")
35
+ return await task
36
+
37
+ assert janel_app.run_until(cancel_next(dialogs.save_file(name="a.txt"))) is None
38
+ assert janel_app.run_until(cancel_next(dialogs.choose_folder())) is None
39
+ assert janel_app.run_until(cancel_next(dialogs.confirm("¿Seguro?"))) is False
40
+ assert janel_app.run_until(cancel_next(dialogs.open_files())) == []
41
+
42
+
43
+ def test_confirm_accepts(janel_app, mount):
44
+ mount("<Text>x</Text>")
45
+
46
+ async def scenario():
47
+ task = asyncio.ensure_future(dialogs.confirm("¿Borrar?", title="Notas"))
48
+ await asyncio.sleep(0.05)
49
+ dialog = dialogs.pending()[0]
50
+ assert dialog.property("text") == "¿Borrar?"
51
+ QMetaObject.invokeMethod(dialog, "done", Q_ARG(int, 0x400)) # botón Aceptar
52
+ return await task
53
+
54
+ assert janel_app.run_until(scenario()) is True
55
+
56
+
57
+ def test_inspect_answers_a_file_dialog(tmp_path):
58
+ app_file = tmp_path / "App.janel"
59
+ app_file.write_text(
60
+ """<script>
61
+ from janelkit import state
62
+ from janelkit_desktop import dialogs
63
+ chosen = state(None)
64
+
65
+ async def pick():
66
+ chosen.value = await dialogs.open_file()
67
+ </script>
68
+ <Column><Button on:click={pick}>Abrir</Button></Column>
69
+ """,
70
+ encoding="utf-8",
71
+ )
72
+ target = tmp_path / "a.txt"
73
+ target.write_text("x", encoding="utf-8")
74
+ with mount_app(app_file) as app:
75
+ app.find(text="Abrir").click()
76
+ app.wait(0.02)
77
+ assert len(app.dialogs()) == 1
78
+ app.answer_dialog(target)
79
+ assert app.get("chosen") == target
80
+
81
+
82
+ def test_answer_dialog_accepts_or_cancels_a_confirm(tmp_path):
83
+ app_file = tmp_path / "App.janel"
84
+ app_file.write_text(
85
+ """<script lang="python">
86
+ from janelkit import state
87
+ from janelkit_desktop import dialogs
88
+ answers = state([])
89
+
90
+ async def ask():
91
+ answers.value = answers.value + [await dialogs.confirm("¿Seguro?")]
92
+ </script>
93
+ <Window><Button text="Preguntar" on:click={ask()} /></Window>
94
+ """,
95
+ encoding="utf-8",
96
+ )
97
+ with mount_app(app_file) as app:
98
+ for result in (True, False):
99
+ app.find(text="Preguntar").click()
100
+ app.wait(0.02)
101
+ app.answer_dialog(result)
102
+ assert list(app.get("answers")) == [True, False]