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.
@@ -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