gpui-bridge 0.2.0__py3-none-win_amd64.whl

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,695 @@
1
+ """gpui-bridge Python client: drives the gpui-bridge host over JSON Lines (stdlib only).
2
+
3
+ Same model as the Node client: build nodes, queue operations, `commit()` sends
4
+ them in one batch. `run()` blocks and calls event handlers on the calling thread
5
+ until the host exits.
6
+ """
7
+
8
+ import json
9
+ import os
10
+ import queue
11
+ import subprocess
12
+ import sys
13
+ import threading
14
+ import time
15
+ from collections import deque
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+
19
+ PROTOCOL_VERSION = 1
20
+ HANDSHAKE_TIMEOUT = 10.0
21
+ STDERR_TAIL_LINES = 20
22
+ # Window events a client can subscribe to (PROTOCOL.md section 3).
23
+ WINDOW_EVENTS = ("focus", "blur", "resize", "move", "visibility")
24
+
25
+
26
+ @dataclass
27
+ class HostExit:
28
+ code: int | None
29
+ # Last lines the host wrote to stderr, for crash reports.
30
+ stderr_tail: list[str]
31
+
32
+
33
+ @dataclass
34
+ class HostError:
35
+ """An `error` message from the host: the operation was rejected, the host keeps running."""
36
+
37
+ code: str
38
+ message: str
39
+ op: str | None = None
40
+
41
+
42
+ class HostExitedError(Exception):
43
+ def __init__(self, message: str, exit: HostExit):
44
+ super().__init__(message)
45
+ self.exit = exit
46
+
47
+
48
+ @dataclass
49
+ class _Record:
50
+ type: str
51
+ parent: int | None = None
52
+ children: set[int] = field(default_factory=set)
53
+ handlers: dict = field(default_factory=dict)
54
+
55
+
56
+ def default_host_path() -> str:
57
+ """`$GPUI_BRIDGE_HOST`, then the host bundled in this package, then a Cargo build in this repository."""
58
+ if env := os.environ.get("GPUI_BRIDGE_HOST"):
59
+ return env
60
+ exe = "gpui-bridge-host.exe" if sys.platform == "win32" else "gpui-bridge-host"
61
+ package = Path(__file__).resolve().parent
62
+ # build_wheel.py puts the optimized host in bin/; a checkout of this repository has none.
63
+ bundled = package / "bin" / exe
64
+ if bundled.exists():
65
+ return str(bundled)
66
+ # Walk up for the workspace `target/`: how many folders separate this client from the
67
+ # repository root is not part of the contract, so a move must not break the lookup.
68
+ for parent in package.parents:
69
+ for profile in ("debug", "release"):
70
+ candidate = parent / "target" / profile / exe
71
+ if candidate.exists():
72
+ return str(candidate)
73
+ return str(package.parents[2] / "target" / "debug" / exe)
74
+
75
+
76
+ def _props(kwargs: dict) -> dict:
77
+ # `class` is a Python keyword, so callers write `class_`.
78
+ return {key.rstrip("_"): value for key, value in kwargs.items()}
79
+
80
+
81
+ def _describe_exit(exit: HostExit) -> str:
82
+ tail = "\nLast host output:\n " + "\n ".join(exit.stderr_tail) if exit.stderr_tail else ""
83
+ return f"gpui-bridge host exited (code {exit.code}).{tail}"
84
+
85
+
86
+ class Node:
87
+ def __init__(self, app: "App", id: int, type: str):
88
+ self._app = app
89
+ self.id = id
90
+ self.type = type
91
+
92
+ def append(self, *children: "Node") -> "Node":
93
+ for child in children:
94
+ self._app._pending.append({"op": "append", "parent": self.id, "child": child.id})
95
+ self._app._attach(self.id, child.id)
96
+ return self
97
+
98
+ def insert_before(self, child: "Node", before: "Node") -> "Node":
99
+ self._app._pending.append({"op": "insertBefore", "parent": self.id, "child": child.id, "before": before.id})
100
+ self._app._attach(self.id, child.id)
101
+ return self
102
+
103
+ def set(self, **props) -> "Node":
104
+ """Merges props; `None` removes one."""
105
+ self._app._pending.append({"op": "setProps", "id": self.id, "props": _props(props)})
106
+ return self
107
+
108
+ def on(self, event: str, handler) -> "Node":
109
+ """Subscribes to an event. Handlers get the Input text, the Checkbox state, or nothing (click)."""
110
+ self._app._set_handler(self.id, event, handler)
111
+ return self
112
+
113
+ def off(self, event: str) -> "Node":
114
+ self._app._set_handler(self.id, event, None)
115
+ return self
116
+
117
+ def focus(self) -> "Node":
118
+ """Gives the node the keyboard focus on the next commit (an Input, a Textarea or a div with keyDown)."""
119
+ self._app._pending.append({"op": "focus", "id": self.id})
120
+ return self
121
+
122
+ def remove(self) -> None:
123
+ """Removes the node and all its descendants."""
124
+ self._app._remove(self.id)
125
+
126
+
127
+ class Window:
128
+ """A window other than the main one (`App.create_window`)."""
129
+
130
+ def __init__(self, app: "App", window_id: int):
131
+ self._app = app
132
+ self.id = window_id
133
+ self.on_closed = None # () -> None; the user closed the window
134
+ self._handlers: dict = {} # window event -> handler
135
+ self._events_sent: list[str] = []
136
+
137
+ def set(self, *, root: Node | None = None, **options) -> "Window":
138
+ """Updates the window; like `App.window`, it opens on the first commit that sets `root`."""
139
+ self._app._queue_window(self.id, root, options)
140
+ return self
141
+
142
+ def close(self) -> None:
143
+ """Closes the window on the next commit. `on_closed` is not called: it is for the user closing it."""
144
+ self._app._pending.append({"op": "closeWindow", "id": self.id})
145
+ self._app._open_windows.discard(self.id)
146
+
147
+ def on(self, event: str, handler) -> "Window":
148
+ """Subscribes to a window event (`focus`, `blur`, `resize`, `move`, `visibility`) or to
149
+ `closed`. Handlers get the message: bounds for `move`/`resize`, `visible` for `visibility`."""
150
+ if event == "closed":
151
+ self.on_closed = handler
152
+ return self
153
+ if event not in WINDOW_EVENTS:
154
+ expected = ", ".join(("closed", *WINDOW_EVENTS))
155
+ raise ValueError(f"unknown window event '{event}' (expected one of {expected})")
156
+ self._handlers[event] = handler
157
+ self._sync_events()
158
+ return self
159
+
160
+ def off(self, event: str) -> "Window":
161
+ """Unsubscribes; the last one removes the window's event subscription (no messages follow)."""
162
+ if event == "closed":
163
+ self.on_closed = None
164
+ return self
165
+ self._handlers.pop(event, None)
166
+ self._sync_events()
167
+ return self
168
+
169
+ def _sync_events(self) -> None:
170
+ """Sends the subscription list only when the set of subscribed events changes."""
171
+ events = list(self._handlers)
172
+ if events != self._events_sent:
173
+ self._events_sent = events
174
+ self._app._queue_window(self.id, None, {"events": events})
175
+
176
+ def _dispatch_window_event(self, message: dict) -> None:
177
+ handler = self._handlers.get(message.get("event"))
178
+ if handler:
179
+ handler(message)
180
+
181
+
182
+ class App:
183
+ """Starts the host and completes the handshake. Raises with a clear message if the host cannot start."""
184
+
185
+ def __init__(self, host_path: str | None = None, *, debug: bool = False, headless: bool = False):
186
+ self.on_error = None # (HostError) -> None; without it errors are printed to stderr
187
+ self.on_window_closed = None # (window_id) -> None; the user closed a window
188
+ self.on_window_event = None # (message) -> None; a window event the app subscribed to
189
+ self.on_tray = None # (event, item) -> None; tray "click" (item None) or "menu" (item id)
190
+ self.on_shortcut = None # (keys, window_id) -> None; a key registered with shortcuts()
191
+ self.on_notification = None # (id, window_id) -> None; the user clicked a toast that has an id
192
+ self._has_tray = False # a tray keeps the host running after the last window closes
193
+ self._next_request = 1
194
+ self._results: dict[int, dict] = {} # answers that arrived while waiting for another one
195
+ self._next_id = 1
196
+ self._nodes: dict[int, _Record] = {}
197
+ self._pending: list[dict] = []
198
+ self._inbox: queue.Queue = queue.Queue()
199
+ self._stderr_tail: deque[str] = deque(maxlen=STDERR_TAIL_LINES)
200
+ self._exit: HostExit | None = None
201
+ self._closing = False
202
+ self._next_window_id = 2
203
+ self._windows: dict[int, Window] = {}
204
+ # Windows given a root and not closed: the host exits when the user closes the last one.
205
+ self._open_windows: set[int] = set()
206
+
207
+ path = host_path or default_host_path()
208
+ args = [path, "--no-window"] if headless else [path]
209
+ try:
210
+ self._proc = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
211
+ except FileNotFoundError:
212
+ raise HostExitedError(
213
+ f"gpui-bridge host not found at {path}. Build it with `cargo build` in host/ or pass host_path.",
214
+ HostExit(None, []),
215
+ ) from None
216
+
217
+ self._stderr_thread = threading.Thread(target=self._read_stderr, args=(debug,), daemon=True)
218
+ self._stderr_thread.start()
219
+ threading.Thread(target=self._read_stdout, daemon=True).start()
220
+ self._write([{"op": "hello", "protocol": PROTOCOL_VERSION}])
221
+ self._handshake()
222
+
223
+ def div(self, **props) -> Node:
224
+ return self._create("div", props)
225
+
226
+ def text(self, **props) -> Node:
227
+ return self._create("text", props)
228
+
229
+ def button(self, **props) -> Node:
230
+ return self._create("Button", props)
231
+
232
+ def input(self, **props) -> Node:
233
+ return self._create("Input", props)
234
+
235
+ def checkbox(self, **props) -> Node:
236
+ return self._create("Checkbox", props)
237
+
238
+ def icon(self, **props) -> Node:
239
+ return self._create("Icon", props)
240
+
241
+ def image(self, **props) -> Node:
242
+ """An image from `src` (a local file) or `data` (standard base64 of the bytes,
243
+ for images the app downloads itself). `fit` is the CSS `object-fit`."""
244
+ return self._create("Image", props)
245
+
246
+ def switch(self, **props) -> Node:
247
+ return self._create("Switch", props)
248
+
249
+ def radio_group(self, **props) -> Node:
250
+ """`options=[{"value": ..., "label": ...}]`, `value`, `direction`."""
251
+ return self._create("RadioGroup", props)
252
+
253
+ def select(self, **props) -> Node:
254
+ """`options=[{"value": ..., "label": ...}]`, `value`, `placeholder`, `searchable`."""
255
+ return self._create("Select", props)
256
+
257
+ def slider(self, **props) -> Node:
258
+ """`value`, `min` (0), `max` (100), `step` (1); `change` passes a number."""
259
+ return self._create("Slider", props)
260
+
261
+ def textarea(self, **props) -> Node:
262
+ return self._create("Textarea", props)
263
+
264
+ def dialog(self, **props) -> Node:
265
+ """A modal dialog shown while `open=True`; append its body as children. Events `ok`, `cancel`."""
266
+ return self._create("Dialog", props)
267
+
268
+ def virtual_list(self, **props) -> Node:
269
+ """A list that draws only the rows in view (PROTOCOL.md section 15): append one child per
270
+ row; `rowHeight` (32). Give it a height (`class_="flex-1"`)."""
271
+ return self._create("VirtualList", props)
272
+
273
+ def table(self, **props) -> Node:
274
+ """A data table with text cells: `columns=[{"key": ..., "name": ...}]`, `rows=[{key: cell}]`,
275
+ `selected`, `stripe`, `bordered`. `change` passes a row index (None when cleared),
276
+ `doubleClick` a row index, `sort` a dict `{"column": ..., "direction": "asc" | "desc" | "none"}`."""
277
+ return self._create("Table", props)
278
+
279
+ def tabs(self, **props) -> Node:
280
+ """A tab bar: `tabs=[{"value": ..., "label": ...}]`, `value`, `variant`. Show the content yourself."""
281
+ return self._create("Tabs", props)
282
+
283
+ def tab(self, **props) -> Node:
284
+ """One tab of a `tabs` bar: `value` (required, not empty), `label` (its text, before the
285
+ children), `disabled` and `class` (of the chip). Append the nodes of its chip (an `icon`, a
286
+ `text`, a `tag`; a last `button` is drawn after them); a `Tabs` with `tab` children uses
287
+ them and ignores `tabs`."""
288
+ return self._create("Tab", props)
289
+
290
+ def menu_bar(self, **props) -> Node:
291
+ """A menu bar (PROTOCOL.md section 10): `menus=[{"label": ..., "items": [...]}]`."""
292
+ return self._create("MenuBar", props)
293
+
294
+ # gpui-kit's Basic, Form, Layout and Advanced groups (PROTOCOL.md section 17).
295
+
296
+ def alert(self, **props) -> Node:
297
+ """An inline message: `message`, `title`, `variant`, `banner`, `closable`. Event `close`."""
298
+ return self._create("Alert", props)
299
+
300
+ def badge(self, **props) -> Node:
301
+ """A count (`count`, `max`), a `dot` or an `icon`, over its children."""
302
+ return self._create("Badge", props)
303
+
304
+ def tag(self, **props) -> Node:
305
+ """`label`, `variant` (primary, secondary, danger, success, warning, info), `outline`, `icon`."""
306
+ return self._create("Tag", props)
307
+
308
+ def kbd(self, **props) -> Node:
309
+ """A key or shortcut, such as `keys="ctrl-s"`; `outline`."""
310
+ return self._create("Kbd", props)
311
+
312
+ def progress(self, **props) -> Node:
313
+ """A bar: `value` from 0 to 100, `loading`."""
314
+ return self._create("Progress", props)
315
+
316
+ def spinner(self, **props) -> Node:
317
+ """A spinner; size it with `class_` (`size-4`)."""
318
+ return self._create("Spinner", props)
319
+
320
+ def skeleton(self, **props) -> Node:
321
+ """A loading placeholder; size it with `class_`."""
322
+ return self._create("Skeleton", props)
323
+
324
+ def rating(self, **props) -> Node:
325
+ """Stars: `value`, `max` (5), `controlled`. `change` passes the chosen count."""
326
+ return self._create("Rating", props)
327
+
328
+ def pagination(self, **props) -> Node:
329
+ """Pages: `page` (1-based), `total`, `compact`, `controlled`. `change` passes the page."""
330
+ return self._create("Pagination", props)
331
+
332
+ def collapsible(self, **props) -> Node:
333
+ """Content shown while `open`; append it as children."""
334
+ return self._create("Collapsible", props)
335
+
336
+ def accordion(self, **props) -> Node:
337
+ """Sections as `items=[{"id", "title", "content", "open", "disabled"}]`, `multiple`,
338
+ `bordered`, `controlled`. `change` passes the id of the section toggled."""
339
+ return self._create("Accordion", props)
340
+
341
+ def avatar(self, **props) -> Node:
342
+ """A picture from `src`, or the initial of `name`."""
343
+ return self._create("Avatar", props)
344
+
345
+ def stepper(self, **props) -> Node:
346
+ """Steps as `items=[{"value", "label"}]`, `value`, `vertical`, `controlled`.
347
+ `change` passes the chosen step's value."""
348
+ return self._create("Stepper", props)
349
+
350
+ def number_input(self, **props) -> Node:
351
+ """A text field with increment and decrement buttons: `value`, `placeholder`, `controlled`."""
352
+ return self._create("NumberInput", props)
353
+
354
+ def otp_input(self, **props) -> Node:
355
+ """A one-time code: `length` (6), `value`, `masked`, `controlled`."""
356
+ return self._create("OtpInput", props)
357
+
358
+ def form(self, **props) -> Node:
359
+ """A form: `direction` ("vertical" or "horizontal"); append `field` nodes."""
360
+ return self._create("Form", props)
361
+
362
+ def field(self, **props) -> Node:
363
+ """A label, an optional `description`, and its children as the control."""
364
+ return self._create("Field", props)
365
+
366
+ def group_box(self, **props) -> Node:
367
+ """A titled box: `title`, `variant` (normal, fill, outline); append its content."""
368
+ return self._create("GroupBox", props)
369
+
370
+ def description_list(self, **props) -> Node:
371
+ """Key-value rows: `items=[{"label", "value", "span"}]`, `columns`, `bordered`, `horizontal`."""
372
+ return self._create("DescriptionList", props)
373
+
374
+ def status_bar(self, **props) -> Node:
375
+ """A status bar: `left` and `right` text."""
376
+ return self._create("StatusBar", props)
377
+
378
+ def toolbar(self, **props) -> Node:
379
+ """A toolbar; append its content."""
380
+ return self._create("Toolbar", props)
381
+
382
+ def resizable(self, **props) -> Node:
383
+ """Panels the user can resize: `axis` (horizontal or vertical), `sizes` in pixels. Append
384
+ one child per panel."""
385
+ return self._create("Resizable", props)
386
+
387
+ def calendar(self, **props) -> Node:
388
+ """A month view: `value` ("YYYY-MM-DD"), `months`, `weekStartsOn`, `controlled`.
389
+ `change` passes the chosen day."""
390
+ return self._create("Calendar", props)
391
+
392
+ def tree(self, **props) -> Node:
393
+ """Items in props: `items=[{"id", "label", "children", "expanded", "disabled"}]`, `value`
394
+ (the selected item id), `controlled`. `change` passes the chosen id; `expand` and
395
+ `collapse` pass a folder's id."""
396
+ return self._create("Tree", props)
397
+
398
+ def diff(self, **props) -> Node:
399
+ """A patch: `patch` (a unified or Git diff), `mode` (unified or split), `lineNumbers`,
400
+ `softWrap`, `header`. `change` passes the selected lines ({path, side, start, end,
401
+ endSide}) or None."""
402
+ return self._create("Diff", props)
403
+
404
+ def carousel(self, **props) -> Node:
405
+ """A carousel: `value` (the selected slide index), `axis`, `looping`, `controls`,
406
+ `pagination`, `controlled`. Append one child per slide; `change` passes the index."""
407
+ return self._create("Carousel", props)
408
+
409
+ def sidebar(self, **props) -> Node:
410
+ """A sidebar: `items` (sections: {"label", "items": [{"id", "label", "icon",
411
+ "disabled", "open", "children"}]}), `value` (the active entry id), `collapsed`,
412
+ `collapsible` (icon, offcanvas, none), `side` (left, right), `controlled`. `change`
413
+ passes the clicked entry's id."""
414
+ return self._create("Sidebar", props)
415
+
416
+ def chart(self, **props) -> Node:
417
+ """A chart: `chart` (line, bar, pie), `data` ([{"label", "value"}]), `color`, `name`,
418
+ plus the options of each plot (`curve`, `dot`, `horizontal`, `xAxis`, `xTicks`, `yAxis`, `yTicks`, `grid`,
419
+ `yMin`, `yMax`, `innerRadius`, `padAngle`, `interactive`). It has no events."""
420
+ return self._create("Chart", props)
421
+
422
+ def window(self, *, root: Node | None = None, **options) -> "App":
423
+ """Creates or updates the main window (window 1). It opens on the first commit that sets `root`."""
424
+ self._queue_window(None, root, options)
425
+ return self
426
+
427
+ def create_window(self, *, root: Node | None = None, **options) -> Window:
428
+ """Another window, with its own root; it opens on the first commit that sets `root`."""
429
+ window = Window(self, self._next_window_id)
430
+ self._next_window_id += 1
431
+ self._windows[window.id] = window
432
+ return window.set(root=root, **options)
433
+
434
+ def _queue_window(self, window_id: int | None, root: Node | None, options: dict) -> None:
435
+ message = {"op": "window", **({"id": window_id} if window_id is not None else {}), **options}
436
+ if root:
437
+ message["root"] = root.id
438
+ self._open_windows.add(window_id or 1)
439
+ self._pending.append(message)
440
+
441
+ def open_dialog(self, *, files: bool = True, directories: bool = False, multiple: bool = False, prompt: str | None = None):
442
+ """Native "open" dialog: the chosen paths, or None if cancelled. Handlers keep running meanwhile."""
443
+ options = {"files": files, "directories": directories, "multiple": multiple, **({"prompt": prompt} if prompt else {})}
444
+ return self._request({"op": "openDialog", **options}, "paths")
445
+
446
+ def save_dialog(self, *, directory: str | None = None, name: str | None = None):
447
+ """Native "save" dialog: the chosen path, or None if cancelled."""
448
+ options = {key: value for key, value in (("directory", directory), ("name", name)) if value is not None}
449
+ return self._request({"op": "saveDialog", **options}, "path")
450
+
451
+ def read_clipboard(self):
452
+ """The clipboard's text, or None if it holds none."""
453
+ return self._request({"op": "readClipboard"}, "text")
454
+
455
+ def notify(self, message: str, **options) -> None:
456
+ """Shows a toast now: `title`, `type`, `window`, `id`, `autohide`, `system`."""
457
+ self._write([{"op": "notify", "message": message, **options}])
458
+
459
+ def write_clipboard(self, text: str) -> None:
460
+ self._write([{"op": "writeClipboard", "text": text}])
461
+
462
+ def open_url(self, url: str) -> None:
463
+ """Opens a URL with the system's handler (the browser for http)."""
464
+ self._write([{"op": "openUrl", "url": url}])
465
+
466
+ def open_path(self, path: str) -> None:
467
+ """Opens a file or folder with its default app."""
468
+ self._write([{"op": "openPath", "path": path}])
469
+
470
+ def _request(self, message: dict, key: str):
471
+ """Sends a system action and waits for its result, dispatching other messages meanwhile."""
472
+ if self._exit:
473
+ raise HostExitedError(_describe_exit(self._exit), self._exit)
474
+ request = self._next_request
475
+ self._next_request += 1
476
+ self._write([{**message, "request": request}])
477
+ while request not in self._results:
478
+ try:
479
+ message = self._inbox.get(timeout=0.5)
480
+ except queue.Empty:
481
+ continue
482
+ if isinstance(message, HostExit):
483
+ self._inbox.put(message) # left for run()
484
+ raise HostExitedError(_describe_exit(message), message)
485
+ if message.get("type") in ("result", "error") and message.get("request") is not None:
486
+ self._results[message.get("request")] = message
487
+ else:
488
+ self._dispatch(message)
489
+ answer = self._results.pop(request)
490
+ if answer.get("type") == "error":
491
+ raise RuntimeError(f"gpui-bridge: host rejected '{answer.get('op')}': {answer.get('message')}")
492
+ return answer.get(key)
493
+
494
+ def shortcuts(self, keys: list[str]) -> "App":
495
+ """App-wide keyboard shortcuts ("ctrl-s", "f5"...), applied on the next commit; replaces the list."""
496
+ self._pending.append({"op": "shortcuts", "keys": list(keys)})
497
+ return self
498
+
499
+ def tray(self, **options) -> "App":
500
+ """Shows or updates the system tray icon (PROTOCOL.md section 11): `icon`, `tooltip`, `menu`."""
501
+ self._pending.append({"op": "tray", **options})
502
+ self._has_tray = True
503
+ return self
504
+
505
+ def remove_tray(self) -> "App":
506
+ """Removes the tray icon on the next commit; with no window open, the host then exits."""
507
+ self._pending.append({"op": "removeTray"})
508
+ self._has_tray = False
509
+ if not self._open_windows:
510
+ self._closing = True
511
+ return self
512
+
513
+ def commit(self) -> None:
514
+ """Sends every pending operation; the host renders once per commit."""
515
+ if self._exit:
516
+ raise HostExitedError(_describe_exit(self._exit), self._exit)
517
+ self._write([*self._pending, {"op": "commit"}])
518
+ self._pending.clear()
519
+
520
+ def run(self) -> HostExit:
521
+ """Calls event handlers until the host exits (window closed, `close()` or crash)."""
522
+ try:
523
+ while self._exit is None:
524
+ try:
525
+ # A timeout keeps Ctrl+C responsive on Windows.
526
+ message = self._inbox.get(timeout=0.5)
527
+ except queue.Empty:
528
+ continue
529
+ if isinstance(message, HostExit):
530
+ self._exit = message
531
+ try:
532
+ self._proc.stdin.close()
533
+ except OSError:
534
+ pass # unflushed data to a dead host
535
+ # An exit after close() or windowClosed is expected; anything else is a crash.
536
+ if not self._closing:
537
+ print(_describe_exit(message), file=sys.stderr)
538
+ else:
539
+ self._dispatch(message)
540
+ except BaseException:
541
+ self._proc.kill()
542
+ raise
543
+ return self._exit
544
+
545
+ def close(self) -> HostExit:
546
+ """Closes stdin (the host exits on EOF), handles remaining messages and waits for the exit."""
547
+ self._closing = True
548
+ try:
549
+ self._proc.stdin.close()
550
+ except OSError:
551
+ pass
552
+ return self.run()
553
+
554
+ def _create(self, type: str, props: dict) -> Node:
555
+ # Ids are positive, unique for the whole session and never reused.
556
+ id = self._next_id
557
+ self._next_id += 1
558
+ self._nodes[id] = _Record(type)
559
+ self._pending.append({"op": "create", "id": id, "type": type, "props": _props(props)})
560
+ return Node(self, id, type)
561
+
562
+ def _attach(self, parent: int, child: int) -> None:
563
+ # Mirrors the host's move semantics so `remove` can drop descendant handlers.
564
+ record = self._nodes.get(child)
565
+ if not record:
566
+ return
567
+ if record.parent is not None and record.parent in self._nodes:
568
+ self._nodes[record.parent].children.discard(child)
569
+ record.parent = parent
570
+ if parent in self._nodes:
571
+ self._nodes[parent].children.add(child)
572
+
573
+ def _remove(self, id: int) -> None:
574
+ record = self._nodes.get(id)
575
+ if not record:
576
+ return
577
+ if record.parent is not None and record.parent in self._nodes:
578
+ self._nodes[record.parent].children.discard(id)
579
+ stack = [id]
580
+ while stack:
581
+ node = self._nodes.pop(stack.pop(), None)
582
+ if node:
583
+ stack.extend(node.children)
584
+ self._pending.append({"op": "remove", "id": id})
585
+
586
+ def _set_handler(self, id: int, event: str, handler) -> None:
587
+ # Queues `setEvents` only when the subscription set changes.
588
+ record = self._nodes.get(id)
589
+ if not record:
590
+ return
591
+ had = event in record.handlers
592
+ if handler:
593
+ record.handlers[event] = handler
594
+ else:
595
+ record.handlers.pop(event, None)
596
+ if had != (event in record.handlers):
597
+ self._pending.append({"op": "setEvents", "id": id, "events": list(record.handlers)})
598
+
599
+ def _dispatch(self, message: dict) -> None:
600
+ match message.get("type"):
601
+ case "event":
602
+ record = self._nodes.get(message.get("id"))
603
+ handler = record and record.handlers.get(message.get("event"))
604
+ if not handler:
605
+ return
606
+ # A menu choice passes the item id; a Slider, a number.
607
+ if record.type in ("Checkbox", "Switch"):
608
+ handler(message.get("checked", False))
609
+ elif "paths" in message:
610
+ handler(message["paths"])
611
+ elif "value" in message or record.type in ("Input", "Textarea"):
612
+ handler(message.get("value", ""))
613
+ else:
614
+ handler()
615
+ case "error":
616
+ error = HostError(message.get("code"), message.get("message"), message.get("op"))
617
+ if self.on_error:
618
+ self.on_error(error)
619
+ else:
620
+ print(f"gpui-bridge: host rejected '{error.op}': {error.message}", file=sys.stderr)
621
+ case "windowClosed":
622
+ window_id = message.get("id", 1)
623
+ self._open_windows.discard(window_id)
624
+ # The host exits after the last window, unless there is a tray.
625
+ if not self._open_windows and not self._has_tray:
626
+ self._closing = True
627
+ window = self._windows.get(window_id)
628
+ if window and window.on_closed:
629
+ window.on_closed()
630
+ if self.on_window_closed:
631
+ self.on_window_closed(window_id)
632
+ case "windowEvent":
633
+ window = self._windows.get(message.get("id", 1))
634
+ if window:
635
+ window._dispatch_window_event(message)
636
+ if self.on_window_event:
637
+ self.on_window_event(message)
638
+ case "notification":
639
+ if self.on_notification:
640
+ self.on_notification(message.get("id"), message.get("window"))
641
+ case "shortcut":
642
+ if self.on_shortcut:
643
+ self.on_shortcut(message.get("keys"), message.get("window"))
644
+ case "tray":
645
+ if self.on_tray:
646
+ self.on_tray(message.get("event"), message.get("value"))
647
+
648
+ def _handshake(self) -> None:
649
+ deadline = time.monotonic() + HANDSHAKE_TIMEOUT
650
+ while True:
651
+ try:
652
+ message = self._inbox.get(timeout=max(0.0, deadline - time.monotonic()))
653
+ except queue.Empty:
654
+ self._proc.kill()
655
+ raise TimeoutError(f"gpui-bridge host did not answer the handshake within {HANDSHAKE_TIMEOUT:g} s") from None
656
+ if isinstance(message, HostExit):
657
+ raise HostExitedError(_describe_exit(message), message)
658
+ if message.get("type") == "ready":
659
+ return
660
+ if message.get("type") == "error":
661
+ raise RuntimeError(f"gpui-bridge handshake failed ({message.get('code')}): {message.get('message')}")
662
+
663
+ def _write(self, messages: list[dict]) -> None:
664
+ if self._proc.stdin.closed:
665
+ return
666
+ try:
667
+ for message in messages:
668
+ self._proc.stdin.write(json.dumps(message, separators=(",", ":")).encode() + b"\n")
669
+ self._proc.stdin.flush()
670
+ except OSError:
671
+ pass # Writes racing a dying host fail; run() reports the real cause.
672
+
673
+ def _read_stdout(self) -> None:
674
+ for raw in self._proc.stdout:
675
+ line = raw.decode("utf-8", "replace").rstrip("\r\n")
676
+ try:
677
+ message = json.loads(line)
678
+ except ValueError:
679
+ message = None
680
+ if isinstance(message, dict):
681
+ self._inbox.put(message)
682
+ else:
683
+ print(f"gpui-bridge: host wrote a non-protocol line to stdout: {line}", file=sys.stderr)
684
+ self._proc.stdout.close()
685
+ # Wait for stderr too, so the exit carries the complete tail.
686
+ self._stderr_thread.join()
687
+ self._inbox.put(HostExit(self._proc.wait(), list(self._stderr_tail)))
688
+
689
+ def _read_stderr(self, debug: bool) -> None:
690
+ for raw in self._proc.stderr:
691
+ line = raw.decode("utf-8", "replace").rstrip("\r\n")
692
+ self._stderr_tail.append(line)
693
+ if debug:
694
+ print(f"[host] {line}", file=sys.stderr)
695
+ self._proc.stderr.close()
Binary file
@@ -0,0 +1,6 @@
1
+ {
2
+ "version": "0.2.0",
3
+ "protocol": 1,
4
+ "target": "win32-x64",
5
+ "sha256": "fd45752626302a5bd2b71817f01dc6e1c2af6448aa606820d71d04959a94c2db"
6
+ }
@@ -0,0 +1,88 @@
1
+ Metadata-Version: 2.4
2
+ Name: gpui-bridge
3
+ Version: 0.2.0
4
+ Summary: Python client for the gpui-bridge native UI host
5
+ Author: gpui-bridge contributors
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/InDIOS/gpui-bridge#readme
8
+ Project-URL: Repository, https://github.com/InDIOS/gpui-bridge
9
+ Project-URL: Issues, https://github.com/InDIOS/gpui-bridge/issues
10
+ Project-URL: Protocol, https://github.com/InDIOS/gpui-bridge/blob/main/docs/PROTOCOL.md
11
+ Keywords: gpui,gpui-component,native,desktop,ui
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: MacOS X
14
+ Classifier: Environment :: Win32 (MS Windows)
15
+ Classifier: Environment :: X11 Applications
16
+ Classifier: Operating System :: MacOS :: MacOS X
17
+ Classifier: Operating System :: Microsoft :: Windows
18
+ Classifier: Operating System :: POSIX :: Linux
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Topic :: Software Development :: User Interfaces
25
+ Requires-Python: >=3.10
26
+ Description-Content-Type: text/markdown
27
+ License-File: LICENSE
28
+ Dynamic: license-file
29
+
30
+ # gpui-bridge (Python)
31
+
32
+ Native desktop UIs with [GPUI](https://www.gpui.rs/) and
33
+ [gpui-kit](https://gpui-kit.com/), driven from Python. A Rust host
34
+ owns the window, the event loop and the component tree; this client describes the UI as
35
+ operations on that tree and receives events. Standard library only.
36
+
37
+ ```python
38
+ from gpui_bridge import App
39
+
40
+ app = App()
41
+ count = 0
42
+
43
+ root = app.div(class_="flex flex-col items-center gap-4 p-6 size-full")
44
+ label = app.text(content="0", class_="text-xl font-bold")
45
+
46
+ def add(_event=None):
47
+ global count
48
+ count += 1
49
+ label.set(content=str(count))
50
+ app.commit()
51
+
52
+ root.append(label, app.button(label="Add", variant="primary").on("click", add))
53
+ app.window(title="Counter", width=320, height=200, root=root)
54
+ app.commit()
55
+ app.run()
56
+ ```
57
+
58
+ ## Install
59
+
60
+ ```sh
61
+ pip install gpui-bridge
62
+ ```
63
+
64
+ Requires Python 3.10+. The wheel is built per platform and carries an optimized
65
+ `gpui-bridge-host` binary, so its tag names that platform (`py3-none-win_amd64`,
66
+ `py3-none-macosx_11_0_arm64`, `py3-none-manylinux_2_35_x86_64`) and no Rust toolchain is
67
+ needed at runtime. Those three are the platforms with a wheel; the host has been drawn in a real
68
+ window only on Windows so far.
69
+
70
+ `class` is a Python keyword: write `class_="..."`, and the client strips the trailing underscore.
71
+ Props and events are keyword arguments, and every node has `append`, `insert_before`, `set`,
72
+ `remove`, `on` and `off`.
73
+
74
+ ## Host lookup
75
+
76
+ 1. `$GPUI_BRIDGE_HOST`
77
+ 2. `gpui_bridge/bin/gpui-bridge-host[.exe]` inside the installed package
78
+ 3. A Cargo build in `target/debug` or `target/release`, searched upwards (a checkout)
79
+
80
+ ## Links
81
+
82
+ - [Protocol specification](https://github.com/InDIOS/gpui-bridge/blob/main/docs/PROTOCOL.md)
83
+ and the [conformance transcripts](https://github.com/InDIOS/gpui-bridge/tree/main/conformance):
84
+ everything a client in another language needs.
85
+ - [Examples](https://github.com/InDIOS/gpui-bridge/tree/main/examples).
86
+ - Node.js and React clients: `gpui-bridge` and `gpui-bridge-react` on npm.
87
+
88
+ Protocol version 1. MIT licensed.
@@ -0,0 +1,8 @@
1
+ gpui_bridge/__init__.py,sha256=9eS9ED0N7gNDsVmbzNURAzDw7169ui6RyMmthFg5_KM,30211
2
+ gpui_bridge/bin/gpui-bridge-host.exe,sha256=_UV1JiYwKlvStxgX8B3G4cKvZEiqYGgg1x0ElZqUwts,19076608
3
+ gpui_bridge/bin/host.json,sha256=uxmHKJOO9mpxc5KtfZIkil9AYxWYSxRHXICdVcl_Nes,147
4
+ gpui_bridge-0.2.0.dist-info/licenses/LICENSE,sha256=u_pqOxKY12j1fgcsnE3ADgQi5BE_wgGzBb2kalkJc1k,1081
5
+ gpui_bridge-0.2.0.dist-info/METADATA,sha256=FkNYmL9lF9TBY76i7s6BlZ3Ai6nEdk9nD7-2t4LBQcM,3380
6
+ gpui_bridge-0.2.0.dist-info/WHEEL,sha256=bhMFZwp6VMtrbl2Ck6PlXryKaCggO1NzYn_HcFbBF1I,97
7
+ gpui_bridge-0.2.0.dist-info/top_level.txt,sha256=FO3OApNVj-wGLrFJYiY5MU8ubLUGlCvLgm6PBoOaIdU,12
8
+ gpui_bridge-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-win_amd64
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 gpui-bridge contributors
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 @@
1
+ gpui_bridge