glimpse-applet-sdk 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.
- glimpse_applet_sdk-0.1.0/LICENSE +21 -0
- glimpse_applet_sdk-0.1.0/PKG-INFO +97 -0
- glimpse_applet_sdk-0.1.0/README.md +73 -0
- glimpse_applet_sdk-0.1.0/glimpse_sdk/__init__.py +120 -0
- glimpse_applet_sdk-0.1.0/glimpse_sdk/app.py +217 -0
- glimpse_applet_sdk-0.1.0/glimpse_sdk/decorators.py +32 -0
- glimpse_applet_sdk-0.1.0/glimpse_sdk/events.py +77 -0
- glimpse_applet_sdk-0.1.0/glimpse_sdk/protocol.py +59 -0
- glimpse_applet_sdk-0.1.0/glimpse_sdk/widgets.py +698 -0
- glimpse_applet_sdk-0.1.0/pyproject.toml +35 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alex Oleshkevich
|
|
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,97 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: glimpse-applet-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Typed async framework for Glimpse exec applets
|
|
5
|
+
Keywords: glimpse,applet,panel,desktop,sdk
|
|
6
|
+
Author: Alex Oleshkevich
|
|
7
|
+
Author-email: Alex Oleshkevich <alex.oleshkevich@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Classifier: Topic :: Desktop Environment
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.14
|
|
19
|
+
Project-URL: Homepage, https://alex-oleshkevich.github.io/glimpse/
|
|
20
|
+
Project-URL: Documentation, https://alex-oleshkevich.github.io/glimpse/applets/exec-sdk
|
|
21
|
+
Project-URL: Repository, https://github.com/alex-oleshkevich/glimpse
|
|
22
|
+
Project-URL: Issues, https://github.com/alex-oleshkevich/glimpse/issues
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# Glimpse Applet Python SDK
|
|
26
|
+
|
|
27
|
+
Small async framework for building Glimpse `exec` applets without touching stdio or raw JSON.
|
|
28
|
+
|
|
29
|
+
## Goals
|
|
30
|
+
|
|
31
|
+
- typed protocol models
|
|
32
|
+
- typed widget builders
|
|
33
|
+
- async runtime
|
|
34
|
+
- decorator-based callbacks
|
|
35
|
+
- state-driven rendering via `await self.set_state(...)`
|
|
36
|
+
|
|
37
|
+
## Example
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from dataclasses import dataclass, field
|
|
41
|
+
|
|
42
|
+
from glimpse_sdk import (
|
|
43
|
+
Applet,
|
|
44
|
+
AppletState,
|
|
45
|
+
Box,
|
|
46
|
+
Button,
|
|
47
|
+
Hero,
|
|
48
|
+
Icon,
|
|
49
|
+
InputEvent,
|
|
50
|
+
Label,
|
|
51
|
+
RenderResult,
|
|
52
|
+
StatusItem,
|
|
53
|
+
click,
|
|
54
|
+
input,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class DeployState(AppletState):
|
|
60
|
+
version: str = "2026.04.07"
|
|
61
|
+
status: str = "Ready"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class DeployApplet(Applet[DeployState]):
|
|
65
|
+
def initial_state(self) -> DeployState:
|
|
66
|
+
return DeployState()
|
|
67
|
+
|
|
68
|
+
async def render(self) -> RenderResult:
|
|
69
|
+
return RenderResult(
|
|
70
|
+
status=[
|
|
71
|
+
StatusItem(
|
|
72
|
+
id="deploy",
|
|
73
|
+
icon=Icon.name("software-update-available-symbolic"),
|
|
74
|
+
label=self.state.status,
|
|
75
|
+
)
|
|
76
|
+
],
|
|
77
|
+
hero=Hero(
|
|
78
|
+
icon=Icon.name("software-update-available-symbolic"),
|
|
79
|
+
title="Deploy",
|
|
80
|
+
subtitle=self.state.version,
|
|
81
|
+
),
|
|
82
|
+
tree=Box.vertical(
|
|
83
|
+
[
|
|
84
|
+
Label("Version"),
|
|
85
|
+
Button(id="deploy_now", label="Deploy now"),
|
|
86
|
+
]
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
@click("deploy_now")
|
|
91
|
+
async def on_deploy(self, _event) -> None:
|
|
92
|
+
await self.set_state(status="Deploying")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
if __name__ == "__main__":
|
|
96
|
+
DeployApplet().run()
|
|
97
|
+
```
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Glimpse Applet Python SDK
|
|
2
|
+
|
|
3
|
+
Small async framework for building Glimpse `exec` applets without touching stdio or raw JSON.
|
|
4
|
+
|
|
5
|
+
## Goals
|
|
6
|
+
|
|
7
|
+
- typed protocol models
|
|
8
|
+
- typed widget builders
|
|
9
|
+
- async runtime
|
|
10
|
+
- decorator-based callbacks
|
|
11
|
+
- state-driven rendering via `await self.set_state(...)`
|
|
12
|
+
|
|
13
|
+
## Example
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
|
|
18
|
+
from glimpse_sdk import (
|
|
19
|
+
Applet,
|
|
20
|
+
AppletState,
|
|
21
|
+
Box,
|
|
22
|
+
Button,
|
|
23
|
+
Hero,
|
|
24
|
+
Icon,
|
|
25
|
+
InputEvent,
|
|
26
|
+
Label,
|
|
27
|
+
RenderResult,
|
|
28
|
+
StatusItem,
|
|
29
|
+
click,
|
|
30
|
+
input,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class DeployState(AppletState):
|
|
36
|
+
version: str = "2026.04.07"
|
|
37
|
+
status: str = "Ready"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class DeployApplet(Applet[DeployState]):
|
|
41
|
+
def initial_state(self) -> DeployState:
|
|
42
|
+
return DeployState()
|
|
43
|
+
|
|
44
|
+
async def render(self) -> RenderResult:
|
|
45
|
+
return RenderResult(
|
|
46
|
+
status=[
|
|
47
|
+
StatusItem(
|
|
48
|
+
id="deploy",
|
|
49
|
+
icon=Icon.name("software-update-available-symbolic"),
|
|
50
|
+
label=self.state.status,
|
|
51
|
+
)
|
|
52
|
+
],
|
|
53
|
+
hero=Hero(
|
|
54
|
+
icon=Icon.name("software-update-available-symbolic"),
|
|
55
|
+
title="Deploy",
|
|
56
|
+
subtitle=self.state.version,
|
|
57
|
+
),
|
|
58
|
+
tree=Box.vertical(
|
|
59
|
+
[
|
|
60
|
+
Label("Version"),
|
|
61
|
+
Button(id="deploy_now", label="Deploy now"),
|
|
62
|
+
]
|
|
63
|
+
),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
@click("deploy_now")
|
|
67
|
+
async def on_deploy(self, _event) -> None:
|
|
68
|
+
await self.set_state(status="Deploying")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
if __name__ == "__main__":
|
|
72
|
+
DeployApplet().run()
|
|
73
|
+
```
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from .app import Applet, AppletState, RenderResult
|
|
2
|
+
from .decorators import change, click, event, input, scroll, toggle
|
|
3
|
+
from .events import (
|
|
4
|
+
CallbackEvent,
|
|
5
|
+
ChangeEvent,
|
|
6
|
+
ClickEvent,
|
|
7
|
+
InitEvent,
|
|
8
|
+
InputEvent,
|
|
9
|
+
PopoverEvent,
|
|
10
|
+
ScrollEvent,
|
|
11
|
+
ToggleEvent,
|
|
12
|
+
)
|
|
13
|
+
from .protocol import Icon, MenuItem, StatusItem
|
|
14
|
+
from .widgets import (
|
|
15
|
+
ActionMenu,
|
|
16
|
+
ActionMenuItem,
|
|
17
|
+
ActionRow,
|
|
18
|
+
Align,
|
|
19
|
+
Badge,
|
|
20
|
+
Box,
|
|
21
|
+
Button,
|
|
22
|
+
Card,
|
|
23
|
+
Checkbox,
|
|
24
|
+
Collapsible,
|
|
25
|
+
CollapsibleItem,
|
|
26
|
+
Column,
|
|
27
|
+
Copyable,
|
|
28
|
+
DetailGrid,
|
|
29
|
+
DetailGridItem,
|
|
30
|
+
Dropdown,
|
|
31
|
+
DropdownItem,
|
|
32
|
+
EmptyState,
|
|
33
|
+
Grid,
|
|
34
|
+
GridChild,
|
|
35
|
+
Header,
|
|
36
|
+
Hero,
|
|
37
|
+
IconWidget,
|
|
38
|
+
Image,
|
|
39
|
+
Item,
|
|
40
|
+
Label,
|
|
41
|
+
Meter,
|
|
42
|
+
Orientation,
|
|
43
|
+
Progress,
|
|
44
|
+
Row,
|
|
45
|
+
Scale,
|
|
46
|
+
Scroll,
|
|
47
|
+
Section,
|
|
48
|
+
Separator,
|
|
49
|
+
Spinner,
|
|
50
|
+
StatusDot,
|
|
51
|
+
Switch,
|
|
52
|
+
Toast,
|
|
53
|
+
ToastAction,
|
|
54
|
+
TreeNode,
|
|
55
|
+
Variant,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
"ActionMenu",
|
|
60
|
+
"ActionMenuItem",
|
|
61
|
+
"ActionRow",
|
|
62
|
+
"Align",
|
|
63
|
+
"Applet",
|
|
64
|
+
"AppletState",
|
|
65
|
+
"Badge",
|
|
66
|
+
"Box",
|
|
67
|
+
"Button",
|
|
68
|
+
"CallbackEvent",
|
|
69
|
+
"Card",
|
|
70
|
+
"ChangeEvent",
|
|
71
|
+
"Checkbox",
|
|
72
|
+
"ClickEvent",
|
|
73
|
+
"Collapsible",
|
|
74
|
+
"CollapsibleItem",
|
|
75
|
+
"Column",
|
|
76
|
+
"Copyable",
|
|
77
|
+
"DetailGrid",
|
|
78
|
+
"DetailGridItem",
|
|
79
|
+
"Dropdown",
|
|
80
|
+
"DropdownItem",
|
|
81
|
+
"EmptyState",
|
|
82
|
+
"Grid",
|
|
83
|
+
"GridChild",
|
|
84
|
+
"Header",
|
|
85
|
+
"Hero",
|
|
86
|
+
"Icon",
|
|
87
|
+
"IconWidget",
|
|
88
|
+
"Image",
|
|
89
|
+
"InitEvent",
|
|
90
|
+
"InputEvent",
|
|
91
|
+
"Item",
|
|
92
|
+
"Label",
|
|
93
|
+
"Meter",
|
|
94
|
+
"Orientation",
|
|
95
|
+
"PopoverEvent",
|
|
96
|
+
"Progress",
|
|
97
|
+
"RenderResult",
|
|
98
|
+
"Row",
|
|
99
|
+
"Scale",
|
|
100
|
+
"Scroll",
|
|
101
|
+
"ScrollEvent",
|
|
102
|
+
"Section",
|
|
103
|
+
"Separator",
|
|
104
|
+
"Spinner",
|
|
105
|
+
"StatusItem",
|
|
106
|
+
"MenuItem",
|
|
107
|
+
"StatusDot",
|
|
108
|
+
"Switch",
|
|
109
|
+
"Toast",
|
|
110
|
+
"ToastAction",
|
|
111
|
+
"ToggleEvent",
|
|
112
|
+
"TreeNode",
|
|
113
|
+
"Variant",
|
|
114
|
+
"change",
|
|
115
|
+
"click",
|
|
116
|
+
"event",
|
|
117
|
+
"input",
|
|
118
|
+
"scroll",
|
|
119
|
+
"toggle",
|
|
120
|
+
]
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from dataclasses import dataclass, is_dataclass
|
|
7
|
+
from typing import Any, Generic, TypeVar
|
|
8
|
+
|
|
9
|
+
from .events import CallbackEvent, InitEvent, PopoverEvent, parse_callback_event, parse_init_event
|
|
10
|
+
from .protocol import StatusItem
|
|
11
|
+
from .widgets import TreeNode
|
|
12
|
+
|
|
13
|
+
StateT = TypeVar("StateT", bound="AppletState")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(slots=True)
|
|
17
|
+
class AppletState:
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(slots=True)
|
|
22
|
+
class RenderResult:
|
|
23
|
+
status: list[StatusItem] | None = None
|
|
24
|
+
tree: TreeNode | None = None
|
|
25
|
+
|
|
26
|
+
def __post_init__(self) -> None:
|
|
27
|
+
if self.status is None:
|
|
28
|
+
self.status = []
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Applet(Generic[StateT]):
|
|
32
|
+
def __init__(self) -> None:
|
|
33
|
+
self.state: StateT = self.initial_state()
|
|
34
|
+
self._incoming: asyncio.Queue[InitEvent | CallbackEvent] = asyncio.Queue()
|
|
35
|
+
self._outgoing: asyncio.Queue[tuple[str, dict[str, Any]]] = asyncio.Queue()
|
|
36
|
+
self._handler_map = self._collect_handlers()
|
|
37
|
+
self._render_task: asyncio.Task[None] | None = None
|
|
38
|
+
self._render_requested = False
|
|
39
|
+
self._last_status: list[dict[str, Any]] | None = None
|
|
40
|
+
self._last_tree: dict[str, Any] | None = None
|
|
41
|
+
self._popover_open = False
|
|
42
|
+
|
|
43
|
+
def initial_state(self) -> StateT:
|
|
44
|
+
raise NotImplementedError
|
|
45
|
+
|
|
46
|
+
async def on_start(self) -> None:
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
async def on_init(self, _event: InitEvent) -> None:
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
async def on_callback(self, _event: CallbackEvent) -> None:
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
async def render(self) -> RenderResult:
|
|
56
|
+
return RenderResult()
|
|
57
|
+
|
|
58
|
+
async def set_state(self, **kwargs: Any) -> None:
|
|
59
|
+
for key, value in kwargs.items():
|
|
60
|
+
if not hasattr(self.state, key):
|
|
61
|
+
raise AttributeError(f"Unknown state field: {key}")
|
|
62
|
+
setattr(self.state, key, value)
|
|
63
|
+
self._schedule_render()
|
|
64
|
+
await asyncio.sleep(0)
|
|
65
|
+
|
|
66
|
+
def is_popover_open(self) -> bool:
|
|
67
|
+
return self._popover_open
|
|
68
|
+
|
|
69
|
+
def _schedule_render(self) -> None:
|
|
70
|
+
self._render_requested = True
|
|
71
|
+
if self._render_task is None or self._render_task.done():
|
|
72
|
+
self._render_task = asyncio.create_task(self._flush_render())
|
|
73
|
+
self._render_task.add_done_callback(_log_render_exception)
|
|
74
|
+
|
|
75
|
+
async def _flush_render(self) -> None:
|
|
76
|
+
await asyncio.sleep(0)
|
|
77
|
+
while self._render_requested:
|
|
78
|
+
self._render_requested = False
|
|
79
|
+
rendered = await self.render()
|
|
80
|
+
status = [item.to_protocol() for item in rendered.status]
|
|
81
|
+
content = None if rendered.tree is None else rendered.tree.to_protocol()
|
|
82
|
+
tree = {"root": content}
|
|
83
|
+
|
|
84
|
+
if status != self._last_status:
|
|
85
|
+
self._last_status = status
|
|
86
|
+
await self._outgoing.put(("status", {"items": status}))
|
|
87
|
+
publish_popover = self._popover_open or self._last_tree is None or content is None
|
|
88
|
+
if publish_popover and tree != self._last_tree:
|
|
89
|
+
self._last_tree = tree
|
|
90
|
+
await self._outgoing.put(("popover", tree))
|
|
91
|
+
|
|
92
|
+
def _collect_handlers(self) -> dict[tuple[str, str], Any]:
|
|
93
|
+
handlers: dict[tuple[str, str], Any] = {}
|
|
94
|
+
for name in dir(self):
|
|
95
|
+
value = getattr(self, name)
|
|
96
|
+
handler_meta = getattr(value, "__glimpse_handler__", None)
|
|
97
|
+
if handler_meta is not None:
|
|
98
|
+
handlers[handler_meta] = value
|
|
99
|
+
return handlers
|
|
100
|
+
|
|
101
|
+
async def _dispatch_callback(self, event: CallbackEvent) -> None:
|
|
102
|
+
handler = self._handler_map.get((event.event, event.id))
|
|
103
|
+
if handler is not None:
|
|
104
|
+
await handler(event)
|
|
105
|
+
else:
|
|
106
|
+
await self.on_callback(event)
|
|
107
|
+
|
|
108
|
+
async def _reader_loop(self, eof: asyncio.Event) -> None:
|
|
109
|
+
try:
|
|
110
|
+
while True:
|
|
111
|
+
line = await asyncio.to_thread(sys.stdin.readline)
|
|
112
|
+
if line == "":
|
|
113
|
+
break
|
|
114
|
+
try:
|
|
115
|
+
parsed = _parse_line(line)
|
|
116
|
+
except (ValueError, json.JSONDecodeError) as exc:
|
|
117
|
+
print(f"glimpse-sdk: ignoring malformed input: {exc}", file=sys.stderr)
|
|
118
|
+
continue
|
|
119
|
+
if parsed is None:
|
|
120
|
+
continue
|
|
121
|
+
message_type, data = parsed
|
|
122
|
+
try:
|
|
123
|
+
if message_type == "init":
|
|
124
|
+
await self._incoming.put(parse_init_event(data))
|
|
125
|
+
elif message_type == "event":
|
|
126
|
+
await self._incoming.put(parse_callback_event(data))
|
|
127
|
+
except Exception as exc:
|
|
128
|
+
print(f"glimpse-sdk: ignoring malformed event: {exc}", file=sys.stderr)
|
|
129
|
+
continue
|
|
130
|
+
finally:
|
|
131
|
+
eof.set()
|
|
132
|
+
|
|
133
|
+
async def _writer_loop(self) -> None:
|
|
134
|
+
while True:
|
|
135
|
+
command, payload = await self._outgoing.get()
|
|
136
|
+
try:
|
|
137
|
+
sys.stdout.write(f"{command} {json.dumps(payload, separators=(',', ':'))}\n")
|
|
138
|
+
sys.stdout.flush()
|
|
139
|
+
except (BrokenPipeError, OSError):
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
async def _event_loop(self, eof: asyncio.Event) -> None:
|
|
143
|
+
await self.on_start()
|
|
144
|
+
self._schedule_render()
|
|
145
|
+
get_task: asyncio.Task[InitEvent | CallbackEvent] | None = None
|
|
146
|
+
eof_task = asyncio.create_task(eof.wait())
|
|
147
|
+
try:
|
|
148
|
+
while True:
|
|
149
|
+
if get_task is None:
|
|
150
|
+
get_task = asyncio.create_task(self._incoming.get())
|
|
151
|
+
done, _ = await asyncio.wait(
|
|
152
|
+
{get_task, eof_task}, return_when=asyncio.FIRST_COMPLETED
|
|
153
|
+
)
|
|
154
|
+
if eof_task in done and get_task not in done:
|
|
155
|
+
get_task.cancel()
|
|
156
|
+
return
|
|
157
|
+
event = get_task.result()
|
|
158
|
+
get_task = None
|
|
159
|
+
if isinstance(event, InitEvent):
|
|
160
|
+
await self.on_init(event)
|
|
161
|
+
self._schedule_render()
|
|
162
|
+
else:
|
|
163
|
+
if isinstance(event, PopoverEvent):
|
|
164
|
+
self._popover_open = event.open
|
|
165
|
+
await self._dispatch_callback(event)
|
|
166
|
+
self._schedule_render()
|
|
167
|
+
if self._render_task is not None and self._render_task.done():
|
|
168
|
+
exc = self._render_task.exception()
|
|
169
|
+
if exc is not None:
|
|
170
|
+
raise exc
|
|
171
|
+
await asyncio.sleep(0)
|
|
172
|
+
finally:
|
|
173
|
+
if get_task is not None:
|
|
174
|
+
get_task.cancel()
|
|
175
|
+
eof_task.cancel()
|
|
176
|
+
|
|
177
|
+
async def _run(self) -> None:
|
|
178
|
+
if not is_dataclass(self.state):
|
|
179
|
+
raise TypeError("Applet state must be a dataclass instance")
|
|
180
|
+
eof = asyncio.Event()
|
|
181
|
+
writer = asyncio.create_task(self._writer_loop())
|
|
182
|
+
reader = asyncio.create_task(self._reader_loop(eof))
|
|
183
|
+
try:
|
|
184
|
+
await self._event_loop(eof)
|
|
185
|
+
finally:
|
|
186
|
+
reader.cancel()
|
|
187
|
+
writer.cancel()
|
|
188
|
+
if self._render_task is not None and not self._render_task.done():
|
|
189
|
+
self._render_task.cancel()
|
|
190
|
+
|
|
191
|
+
def run(self) -> None:
|
|
192
|
+
asyncio.run(self._run())
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _log_render_exception(task: "asyncio.Task[None]") -> None:
|
|
196
|
+
if task.cancelled():
|
|
197
|
+
return
|
|
198
|
+
exc = task.exception()
|
|
199
|
+
if exc is None:
|
|
200
|
+
return
|
|
201
|
+
import traceback
|
|
202
|
+
|
|
203
|
+
print("glimpse-sdk: render error:", file=sys.stderr)
|
|
204
|
+
traceback.print_exception(type(exc), exc, exc.__traceback__, file=sys.stderr)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _parse_line(line: str) -> tuple[str, dict[str, Any]] | None:
|
|
208
|
+
stripped = line.strip()
|
|
209
|
+
if not stripped:
|
|
210
|
+
return None
|
|
211
|
+
command, _, payload = stripped.partition(" ")
|
|
212
|
+
if not payload:
|
|
213
|
+
raise ValueError("missing command payload")
|
|
214
|
+
data = json.loads(payload)
|
|
215
|
+
if not isinstance(data, dict):
|
|
216
|
+
raise ValueError("command payload must be an object")
|
|
217
|
+
return command, data
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def event(event_type: str, target_id: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
8
|
+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
9
|
+
setattr(func, "__glimpse_handler__", (event_type, target_id))
|
|
10
|
+
return func
|
|
11
|
+
|
|
12
|
+
return decorator
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def click(target_id: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
16
|
+
return event("click", target_id)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def input(target_id: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
20
|
+
return event("input", target_id)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def change(target_id: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
24
|
+
return event("change", target_id)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def toggle(target_id: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
28
|
+
return event("toggle", target_id)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def scroll(target_id: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
32
|
+
return event("scroll", target_id)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(slots=True)
|
|
8
|
+
class InitEvent:
|
|
9
|
+
instance: str
|
|
10
|
+
options: dict[str, Any]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(slots=True)
|
|
14
|
+
class CallbackEvent:
|
|
15
|
+
id: str
|
|
16
|
+
event: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(slots=True)
|
|
20
|
+
class ClickEvent(CallbackEvent):
|
|
21
|
+
button: str | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(slots=True)
|
|
25
|
+
class ScrollEvent(CallbackEvent):
|
|
26
|
+
delta_y: float | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(slots=True)
|
|
30
|
+
class InputEvent(CallbackEvent):
|
|
31
|
+
text: str = ""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(slots=True)
|
|
35
|
+
class ChangeEvent(CallbackEvent):
|
|
36
|
+
value: Any = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(slots=True)
|
|
40
|
+
class ToggleEvent(CallbackEvent):
|
|
41
|
+
value: bool = False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(slots=True)
|
|
45
|
+
class PopoverEvent(CallbackEvent):
|
|
46
|
+
open: bool = False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def parse_init_event(payload: dict[str, Any]) -> InitEvent:
|
|
50
|
+
return InitEvent(
|
|
51
|
+
instance=str(payload.get("instance", "")),
|
|
52
|
+
options=payload.get("options") or {},
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def parse_callback_event(payload: dict[str, Any]) -> CallbackEvent:
|
|
57
|
+
event_type = str(payload.get("type", payload.get("event", "")))
|
|
58
|
+
callback_id = str(payload.get("id", ""))
|
|
59
|
+
if event_type == "click":
|
|
60
|
+
return ClickEvent(id=callback_id, event=event_type, button=payload.get("button"))
|
|
61
|
+
if event_type == "scroll":
|
|
62
|
+
return ScrollEvent(id=callback_id, event=event_type, delta_y=payload.get("delta_y"))
|
|
63
|
+
if event_type == "input":
|
|
64
|
+
return InputEvent(id=callback_id, event=event_type, text=str(payload.get("text", "")))
|
|
65
|
+
if event_type == "toggle":
|
|
66
|
+
active = payload.get("active")
|
|
67
|
+
value = payload.get("value")
|
|
68
|
+
if isinstance(active, bool):
|
|
69
|
+
toggled = active
|
|
70
|
+
elif isinstance(value, bool):
|
|
71
|
+
toggled = value
|
|
72
|
+
else:
|
|
73
|
+
toggled = False
|
|
74
|
+
return ToggleEvent(id=callback_id, event=event_type, value=toggled)
|
|
75
|
+
if event_type in {"open", "close"}:
|
|
76
|
+
return PopoverEvent(id=callback_id, event=event_type, open=event_type == "open")
|
|
77
|
+
return ChangeEvent(id=callback_id, event=event_type, value=payload.get("value"))
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(slots=True)
|
|
7
|
+
class Icon:
|
|
8
|
+
kind: str
|
|
9
|
+
value: str
|
|
10
|
+
|
|
11
|
+
@classmethod
|
|
12
|
+
def name(cls, value: str) -> "Icon":
|
|
13
|
+
return cls(kind="name", value=value)
|
|
14
|
+
|
|
15
|
+
@classmethod
|
|
16
|
+
def path(cls, value: str) -> "Icon":
|
|
17
|
+
return cls(kind="path", value=value)
|
|
18
|
+
|
|
19
|
+
def to_protocol(self) -> dict[str, str]:
|
|
20
|
+
return {self.kind: self.value}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(slots=True)
|
|
24
|
+
class MenuItem:
|
|
25
|
+
id: str
|
|
26
|
+
label: str
|
|
27
|
+
visible: bool | None = None
|
|
28
|
+
enabled: bool | None = None
|
|
29
|
+
|
|
30
|
+
def to_protocol(self) -> dict[str, object]:
|
|
31
|
+
payload: dict[str, object] = {
|
|
32
|
+
"id": self.id,
|
|
33
|
+
"label": self.label,
|
|
34
|
+
}
|
|
35
|
+
if self.visible is not None:
|
|
36
|
+
payload["visible"] = self.visible
|
|
37
|
+
if self.enabled is not None:
|
|
38
|
+
payload["enabled"] = self.enabled
|
|
39
|
+
return payload
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(slots=True)
|
|
43
|
+
class StatusItem:
|
|
44
|
+
id: str | None = None
|
|
45
|
+
icon: Icon | None = None
|
|
46
|
+
label: str | None = None
|
|
47
|
+
tooltip: str | None = None
|
|
48
|
+
|
|
49
|
+
def to_protocol(self) -> dict[str, object]:
|
|
50
|
+
payload: dict[str, object] = {}
|
|
51
|
+
if self.id is not None:
|
|
52
|
+
payload["id"] = self.id
|
|
53
|
+
if self.icon is not None:
|
|
54
|
+
payload["icon"] = self.icon.to_protocol()
|
|
55
|
+
if self.label is not None:
|
|
56
|
+
payload["label"] = self.label
|
|
57
|
+
if self.tooltip is not None:
|
|
58
|
+
payload["tooltip"] = self.tooltip
|
|
59
|
+
return payload
|