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,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
|