hill-client 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,5 @@
1
+ Metadata-Version: 2.3
2
+ Name: hill-client
3
+ Version: 0.1.0
4
+ Summary: Join hill-ops's channel from a Python app: say hello, open settings, hear about changes
5
+ Requires-Python: >=3.11
@@ -0,0 +1,10 @@
1
+ [project]
2
+ name = "hill-client"
3
+ version = "0.1.0"
4
+ description = "Join hill-ops's channel from a Python app: say hello, open settings, hear about changes"
5
+ requires-python = ">=3.11"
6
+ dependencies = []
7
+
8
+ [build-system]
9
+ requires = ["uv_build>=0.12.19,<0.13.0"]
10
+ build-backend = "uv_build"
@@ -0,0 +1,10 @@
1
+ [project]
2
+ name = "hill-client"
3
+ version = "0.1.0"
4
+ description = "Join hill-ops's channel from a Python app: say hello, open settings, hear about changes"
5
+ requires-python = ">=3.11"
6
+ dependencies = []
7
+
8
+ [build-system]
9
+ requires = ["uv_build>=0.12.19,<0.13.0"]
10
+ build-backend = "uv_build"
@@ -0,0 +1,251 @@
1
+ """Join hill-ops's channel from a Python app.
2
+
3
+ hill-ops runs an app above a strip for its settings, help, command line and
4
+ questions. Over the channel the app says hello (its name, settings profile,
5
+ app-wide keys, commands, help, README and key tree), asks for any of those, reports
6
+ its errors for hill-ops's event log, and hears when a setting changed, a hint
7
+ was clicked, a command was entered or a question answered.
8
+ Messages are JSON-RPC notifications, one per line, over the local socket
9
+ whose path is in $HILL_SOCKET.
10
+
11
+ A program in hill-ops's window can also take the focus when the mouse moves
12
+ over it (`take_focus`), for focus that follows the mouse, lazily (`Hover`).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import inspect
19
+ import json
20
+ import os
21
+ import subprocess
22
+ import time
23
+ from collections.abc import Callable, Iterable
24
+ from typing import Any
25
+
26
+ __all__ = ["SOCKET", "Client", "Hover", "connect", "line", "take_focus"]
27
+
28
+ SOCKET = "HILL_SOCKET"
29
+ """The environment variable holding the channel's path, set for every
30
+ program started inside hill-ops."""
31
+
32
+
33
+ def take_focus(pane: str | None = None) -> bool:
34
+ """Give the focus in hill-ops's window to `pane`, the tmux pane this program
35
+ runs in unless another is named, as a click on it would: for a program
36
+ whose focus follows the mouse. Not while hill-ops's strip keeps the focus,
37
+ which it does until you're done with the help, the command line or a
38
+ question there; the panel lets it go. Whether `pane` has the focus now;
39
+ False outside tmux."""
40
+ pane = pane or os.environ.get("TMUX_PANE")
41
+ if not pane or not os.environ.get("TMUX"):
42
+ return False
43
+ # One call: ":" is the active pane of the window, @hill-strip the
44
+ # strip's, and @hill-keep 0 while the strip lets the focus go.
45
+ try:
46
+ done = subprocess.run(
47
+ ["tmux", "if", "-F", "-t", ":", "#{||:#{!=:#{pane_id},#{@hill-strip}},#{==:#{@hill-keep},0}}",
48
+ f"select-pane -t {pane}", ";", "display", "-p", "-t", ":", "#{pane_id}"],
49
+ capture_output=True, text=True, timeout=2,
50
+ )
51
+ except (OSError, subprocess.SubprocessError):
52
+ return False
53
+ return done.stdout.strip() == pane
54
+
55
+
56
+ class Hover:
57
+ """Focus that follows the mouse, lazily: the part the mouse moves over
58
+ takes the focus, but where the focus left from under the mouse, by a
59
+ key say, a nudge doesn't take it back: the mouse has to move a few
60
+ cells first. So a hand brushing the mouse or the trackpad while you
61
+ type never sends your keys elsewhere. A part is whatever the program
62
+ says it is: its pane as a whole, or a part of it, such as palace's list
63
+ or calendar."""
64
+
65
+ NUDGE = (2, 1)
66
+ """How far the mouse may move, in columns and rows, from where it rested
67
+ when the focus left from under it, without taking the focus back."""
68
+ RETRY = 0.25
69
+ """Seconds before asking hill-ops for the focus again after it said no,
70
+ while its strip keeps the focus."""
71
+
72
+ def __init__(self) -> None:
73
+ self.seen: tuple[int, int] | None = None
74
+ """Where the mouse was last seen, as (x, y) in the program's pane."""
75
+ self.part: object = None
76
+ """What it was over then; None for nothing that takes the focus."""
77
+ self.resting = False
78
+ """Whether the mouse rests where the focus left from under it."""
79
+ self.rest: tuple[int, int] | None = None
80
+ """Where it rests; None until it's seen."""
81
+ self.refused = 0.0
82
+ """When hill-ops last said no to taking the focus."""
83
+
84
+ def moved(self, at: tuple[int, int], part: object, focused: bool) -> bool:
85
+ """The mouse moved to `at`, (x, y) such as a Textual event's
86
+ `screen_offset`, over `part`, which has the focus or not: whether
87
+ `part` should take it now."""
88
+ self.seen, self.part = at, part
89
+ if part is None:
90
+ return False
91
+ if focused:
92
+ self.resting = False
93
+ return False
94
+ if self.resting:
95
+ if self.rest is None:
96
+ self.rest = at
97
+ if abs(at[0] - self.rest[0]) <= self.NUDGE[0] and abs(at[1] - self.rest[1]) <= self.NUDGE[1]:
98
+ return False
99
+ self.resting = False
100
+ return True
101
+
102
+ def left(self, part: object) -> None:
103
+ """The focus left `part`. If the mouse was over it, or hasn't been
104
+ seen over anything since, it rests there until it moves away."""
105
+ if self.part is None or part is self.part:
106
+ self.resting = True
107
+ self.rest = self.seen if self.part is not None else None
108
+
109
+ def take_focus(self) -> bool:
110
+ """Take hill-ops's focus for the pane this program runs in (see
111
+ `take_focus`); hill-ops says no while its strip keeps the focus, and
112
+ isn't asked again for a moment."""
113
+ if time.monotonic() - self.refused < self.RETRY:
114
+ return False
115
+ if take_focus():
116
+ return True
117
+ self.refused = time.monotonic()
118
+ return False
119
+
120
+
121
+ def line(method: str, **params: Any) -> bytes:
122
+ """One message: a JSON-RPC notification on a line of its own."""
123
+ return (json.dumps({"jsonrpc": "2.0", "method": method, "params": params}) + "\n").encode()
124
+
125
+
126
+ async def connect(path: str, wait: float = 5.0) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
127
+ """Open the channel, retrying every 50 ms for up to `wait` seconds while
128
+ hill-ops's strip starts."""
129
+ loop = asyncio.get_running_loop()
130
+ give_up = loop.time() + wait
131
+ while True:
132
+ try:
133
+ return await asyncio.open_unix_connection(path)
134
+ except (FileNotFoundError, ConnectionRefusedError):
135
+ if loop.time() >= give_up:
136
+ raise
137
+ await asyncio.sleep(0.05)
138
+
139
+
140
+ def _tree(nodes: Iterable[tuple]) -> list[list]:
141
+ """A key tree as lists: `[key, label, line]` for a command, `[key, label,
142
+ [nodes...]]` for a group."""
143
+ return [[key, label, line if isinstance(line, str) else _tree(line)] for key, label, line in nodes]
144
+
145
+
146
+ class Client:
147
+ """A connection to hill-ops. It says hello once connected, and again after
148
+ reconnecting if the strip restarts; messages sent while it isn't
149
+ connected wait until it is. Handlers get each message's params."""
150
+
151
+ def __init__(
152
+ self,
153
+ path: str,
154
+ app: str,
155
+ profile: str | None = None,
156
+ keys: Iterable[tuple] = (),
157
+ commands: Iterable[tuple] = (),
158
+ help: Iterable[tuple] = (),
159
+ docs: str | None = None,
160
+ tree: Iterable[tuple] = (),
161
+ ) -> None:
162
+ self.path = path
163
+ self.hello = {
164
+ "app": app,
165
+ "profile": profile,
166
+ "keys": [list(k) for k in keys],
167
+ "commands": [list(c) for c in commands],
168
+ "help": [[title, [list(e) for e in entries]] for title, entries in help],
169
+ "docs": docs,
170
+ "tree": _tree(tree),
171
+ "pane": os.environ.get("TMUX_PANE"),
172
+ }
173
+ self.handlers: dict[str, Callable[[dict], Any]] = {}
174
+ self._writer: asyncio.StreamWriter | None = None
175
+ self._waiting: list[bytes] = []
176
+ self._closed = False
177
+
178
+ @classmethod
179
+ def from_env(
180
+ cls,
181
+ app: str,
182
+ profile: str | None = None,
183
+ keys: Iterable[tuple] = (),
184
+ commands: Iterable[tuple] = (),
185
+ help: Iterable[tuple] = (),
186
+ docs: str | None = None,
187
+ tree: Iterable[tuple] = (),
188
+ ) -> Client | None:
189
+ """A client for the channel in $HILL_SOCKET, or None outside hill."""
190
+ path = os.environ.get(SOCKET)
191
+ return cls(path, app, profile, keys, commands, help, docs, tree) if path else None
192
+
193
+ @property
194
+ def connected(self) -> bool:
195
+ return self._writer is not None
196
+
197
+ def on(self, method: str, handler: Callable[[dict], Any]) -> None:
198
+ """Call `handler(params)` for each `method` message from hill-ops."""
199
+ self.handlers[method] = handler
200
+
201
+ def send(self, method: str, **params: Any) -> None:
202
+ """Send a message now, or once connected. Never waits."""
203
+ message = line(method, **params)
204
+ if self._writer is not None:
205
+ self._writer.write(message)
206
+ else:
207
+ self._waiting = [*self._waiting[-99:], message]
208
+
209
+ async def run(self) -> None:
210
+ """Stay connected until closed: say hello, pass on what hill-ops sends,
211
+ and reconnect when the connection drops."""
212
+ delay = 0.05
213
+ while not self._closed:
214
+ try:
215
+ reader, writer = await connect(self.path, wait=0)
216
+ except OSError:
217
+ await asyncio.sleep(delay)
218
+ delay = min(delay * 2, 1.0)
219
+ continue
220
+ delay = 0.05
221
+ self._writer = writer
222
+ writer.write(line("hello", **self.hello))
223
+ for message in self._waiting:
224
+ writer.write(message)
225
+ self._waiting = []
226
+ try:
227
+ while raw := await reader.readline():
228
+ await self._dispatch(raw)
229
+ except (OSError, ValueError):
230
+ pass # dropped, or a line too long: connect again
231
+ finally:
232
+ self._writer = None
233
+ writer.close()
234
+
235
+ def close(self) -> None:
236
+ """Stop `run()` and close the connection."""
237
+ self._closed = True
238
+ if self._writer is not None:
239
+ self._writer.close()
240
+
241
+ async def _dispatch(self, raw: bytes) -> None:
242
+ try:
243
+ message = json.loads(raw)
244
+ handler = self.handlers.get(message["method"])
245
+ params = message.get("params") or {}
246
+ except (ValueError, KeyError, TypeError):
247
+ return
248
+ if handler is not None:
249
+ result = handler(params)
250
+ if inspect.isawaitable(result):
251
+ await result