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.
- janelkit_desktop-0.1.0/LICENSE +21 -0
- janelkit_desktop-0.1.0/PKG-INFO +129 -0
- janelkit_desktop-0.1.0/README.md +95 -0
- janelkit_desktop-0.1.0/pyproject.toml +50 -0
- janelkit_desktop-0.1.0/setup.cfg +4 -0
- janelkit_desktop-0.1.0/src/janelkit_desktop/__init__.py +12 -0
- janelkit_desktop-0.1.0/src/janelkit_desktop/clipboard.py +62 -0
- janelkit_desktop-0.1.0/src/janelkit_desktop/dialogs.py +210 -0
- janelkit_desktop-0.1.0/src/janelkit_desktop/icon.png +0 -0
- janelkit_desktop-0.1.0/src/janelkit_desktop/notifications.py +101 -0
- janelkit_desktop-0.1.0/src/janelkit_desktop.egg-info/PKG-INFO +129 -0
- janelkit_desktop-0.1.0/src/janelkit_desktop.egg-info/SOURCES.txt +15 -0
- janelkit_desktop-0.1.0/src/janelkit_desktop.egg-info/dependency_links.txt +1 -0
- janelkit_desktop-0.1.0/src/janelkit_desktop.egg-info/requires.txt +1 -0
- janelkit_desktop-0.1.0/src/janelkit_desktop.egg-info/top_level.txt +1 -0
- janelkit_desktop-0.1.0/tests/test_clipboard.py +51 -0
- janelkit_desktop-0.1.0/tests/test_dialogs.py +102 -0
|
@@ -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
|
+
[](https://pypi.org/project/janelkit-desktop/)
|
|
38
|
+
[](https://pypi.org/project/janelkit-desktop/)
|
|
39
|
+
[](https://github.com/janelkit/janelkit/actions/workflows/ci.yml)
|
|
40
|
+
[](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
|
+
[](https://pypi.org/project/janelkit-desktop/)
|
|
4
|
+
[](https://pypi.org/project/janelkit-desktop/)
|
|
5
|
+
[](https://github.com/janelkit/janelkit/actions/workflows/ci.yml)
|
|
6
|
+
[](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,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)
|
|
Binary file
|
|
@@ -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
|
+
[](https://pypi.org/project/janelkit-desktop/)
|
|
38
|
+
[](https://pypi.org/project/janelkit-desktop/)
|
|
39
|
+
[](https://github.com/janelkit/janelkit/actions/workflows/ci.yml)
|
|
40
|
+
[](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
|
+
|
|
@@ -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]
|