urkit 0.1.0__py3-none-any.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.
urkit/cli/points.py ADDED
@@ -0,0 +1,293 @@
1
+ """Points explorer — interactive terminal UI for browsing saved points.
2
+
3
+ Usage:
4
+ urkit points
5
+ Opens an interactive explorer with real-time search filtering.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import difflib
11
+ import math
12
+ import select
13
+ import sys
14
+ import termios
15
+ from pathlib import Path
16
+
17
+ from rich.console import Console
18
+ from rich.table import Table
19
+
20
+ from urkit import load_config
21
+ from urkit.cli.colors import blue, cyan, dim, yellow
22
+ from urkit.points import Points
23
+
24
+
25
+ def points_command(args) -> None:
26
+ """Execute the points command.
27
+
28
+ Args:
29
+ args: Parsed arguments from argparse (with points subcommand attributes).
30
+ """
31
+ _explore_points(args)
32
+
33
+
34
+ def _euclidean_distance(pose1: list, pose2: list) -> float:
35
+ """Calculate Euclidean distance between two poses (using XYZ only)."""
36
+ return math.sqrt(sum((pose1[i] - pose2[i]) ** 2 for i in range(3)))
37
+
38
+
39
+ def _sort_points_by_proximity(
40
+ points_db: Points, point_names: list[str], reference_name: str = "home"
41
+ ) -> list[str]:
42
+ """Sort points by distance from a reference point, then alphabetically.
43
+
44
+ Args:
45
+ points_db: Loaded Points database.
46
+ point_names: List of point names to sort.
47
+ reference_name: Name of the reference point (default: "home").
48
+
49
+ Returns:
50
+ Sorted list of point names.
51
+ """
52
+ try:
53
+ ref_pose = points_db[reference_name].pose
54
+ except KeyError:
55
+ # If reference point doesn't exist, use the first point
56
+ if point_names:
57
+ ref_pose = points_db[point_names[0]].pose
58
+ else:
59
+ return point_names
60
+
61
+ def sort_key(name: str):
62
+ try:
63
+ pose = points_db[name].pose
64
+ distance = _euclidean_distance(ref_pose, pose)
65
+ return (distance, name) # Sort by distance, then by name
66
+ except KeyError:
67
+ return (float("inf"), name)
68
+
69
+ return sorted(point_names, key=sort_key)
70
+
71
+
72
+ def _sort_filtered_points(filtered: list[str], search_str: str) -> list[str]:
73
+ """Sort filtered points by match quality: exact prefix, exact substring, then fuzzy.
74
+
75
+ Args:
76
+ filtered: List of point names matching the search.
77
+ search_str: The search string.
78
+
79
+ Returns:
80
+ Sorted list with best matches first.
81
+ """
82
+ if not search_str:
83
+ return filtered
84
+
85
+ search_lower = search_str.lower()
86
+
87
+ # Separate into: starts with, contains, and fuzzy matches
88
+ starts_with = [p for p in filtered if p.lower().startswith(search_lower)]
89
+ contains = [p for p in filtered if search_lower in p.lower() and not p.lower().startswith(search_lower)]
90
+ fuzzy = [p for p in filtered if search_lower not in p.lower() and not p.lower().startswith(search_lower)]
91
+
92
+ # Sort each group alphabetically
93
+ starts_with.sort()
94
+ contains.sort()
95
+
96
+ # Sort fuzzy matches by similarity score
97
+ fuzzy_scored = [(p, difflib.SequenceMatcher(None, search_lower, p.lower()).ratio()) for p in fuzzy]
98
+ fuzzy_scored.sort(key=lambda x: (-x[1], x[0])) # Sort by score (desc), then by name
99
+ fuzzy = [p for p, _ in fuzzy_scored]
100
+
101
+ return starts_with + contains + fuzzy
102
+
103
+
104
+ def _explore_points(args) -> None:
105
+ """Launch interactive point explorer with real-time filtering.
106
+
107
+ Args:
108
+ args: Parsed arguments with points_path option.
109
+ """
110
+ # Resolve points path
111
+ config = load_config()
112
+ points_path = args.points_path or config.get("points_path") or "points.db"
113
+ points_path = Path(points_path).resolve()
114
+
115
+ if not points_path.exists():
116
+ print(f"Error: Points database not found: {points_path}")
117
+ sys.exit(1)
118
+
119
+ try:
120
+ points_db = Points(str(points_path))
121
+ all_points = points_db.list()
122
+ except Exception as e:
123
+ print(f"Error loading points: {e}")
124
+ sys.exit(1)
125
+
126
+ if not all_points:
127
+ print("No points saved yet.")
128
+ return
129
+
130
+ _interactive_points_filter(points_db, all_points)
131
+
132
+
133
+ def _interactive_points_filter(points_db: Points, all_points: list[str]) -> None:
134
+ """Interactive point explorer with real-time table filtering.
135
+
136
+ User types to filter, arrow keys scroll, ESC to quit.
137
+
138
+ Args:
139
+ points_db: Loaded Points database.
140
+ all_points: List of all saved point names.
141
+ """
142
+ # Check if stdin is a TTY (interactive terminal)
143
+ if not sys.stdin.isatty():
144
+ print("Error: Interactive mode requires a terminal.")
145
+ print("Run 'urkit points' in an interactive shell.")
146
+ sys.exit(1)
147
+
148
+ # Sort all points by proximity
149
+ all_points_sorted = _sort_points_by_proximity(points_db, all_points)
150
+
151
+ filter_str = ""
152
+ scroll = 0 # Scroll offset for viewing
153
+ needs_redraw = True # Flag to redraw only when needed
154
+
155
+ # Set terminal to raw mode
156
+ old_settings = termios.tcgetattr(sys.stdin)
157
+ new_settings = termios.tcgetattr(sys.stdin)
158
+ new_settings[3] = new_settings[3] & ~(termios.ICANON | termios.ECHO)
159
+ termios.tcsetattr(sys.stdin, termios.TCSADRAIN, new_settings)
160
+
161
+ fd = sys.stdin.fileno()
162
+ # Rich auto-detects dark/light terminal theme
163
+ console = Console(force_terminal=True)
164
+
165
+ try:
166
+ while True:
167
+ # Only redraw if something changed
168
+ if needs_redraw:
169
+ # Filter points: exact matches first, then fuzzy
170
+ search_lower = filter_str.lower()
171
+
172
+ # First get all exact matches (starts with or contains)
173
+ exact_matches = [p for p in all_points_sorted if filter_str == "" or search_lower in p.lower()]
174
+
175
+ # Then add fuzzy matches (not already in exact)
176
+ if filter_str:
177
+ fuzzy_matches = [p for p in all_points_sorted if search_lower not in p.lower()]
178
+ fuzzy_scored = [(p, difflib.SequenceMatcher(None, search_lower, p.lower()).ratio()) for p in fuzzy_matches]
179
+ fuzzy_scored = [(p, score) for p, score in fuzzy_scored if score > 0.6] # Only good matches
180
+ fuzzy_scored.sort(key=lambda x: -x[1]) # Sort by score
181
+ fuzzy_matches = [p for p, _ in fuzzy_scored]
182
+ filtered = exact_matches + fuzzy_matches
183
+ else:
184
+ filtered = exact_matches
185
+
186
+ # Sort by match quality (starts with, contains, fuzzy)
187
+ filtered = _sort_filtered_points(filtered, filter_str)
188
+
189
+ # Clamp scroll to valid range
190
+ if not filtered:
191
+ scroll = 0
192
+ elif scroll >= len(filtered):
193
+ scroll = len(filtered) - 1
194
+ elif scroll < 0:
195
+ scroll = 0
196
+
197
+ # Clear screen and draw header
198
+ sys.stdout.write("\033[2J\033[1;1H")
199
+ sys.stdout.write(cyan(" === POINT EXPLORER ===") + "\n")
200
+ sys.stdout.write(dim(" Type to search · ↑↓ scroll · ESC quit") + "\n\n")
201
+ sys.stdout.write(f" {blue('Search:')} {filter_str}\n\n")
202
+
203
+ if not filtered:
204
+ sys.stdout.write(yellow(f" No points match '{filter_str}'") + "\n")
205
+ else:
206
+ # Build rich table with theme-aware colors
207
+ table = Table(show_header=True, header_style="bold", padding=(0, 1))
208
+ table.add_column("Name")
209
+ table.add_column("X (m)", justify="right")
210
+ table.add_column("Y (m)", justify="right")
211
+ table.add_column("Z (m)", justify="right")
212
+ table.add_column("RX (rad)", justify="right")
213
+ table.add_column("RY (rad)", justify="right")
214
+ table.add_column("RZ (rad)", justify="right")
215
+
216
+ for point_name in filtered:
217
+ try:
218
+ point = points_db[point_name]
219
+ pose = point.pose
220
+ table.add_row(
221
+ point_name,
222
+ f"{pose[0]:7.4f}",
223
+ f"{pose[1]:7.4f}",
224
+ f"{pose[2]:7.4f}",
225
+ f"{pose[3]:7.4f}",
226
+ f"{pose[4]:7.4f}",
227
+ f"{pose[5]:7.4f}",
228
+ )
229
+ except Exception:
230
+ table.add_row(point_name, *["ERROR"] * 6)
231
+
232
+ # Capture and print table with indentation
233
+ import io
234
+ buffer = io.StringIO()
235
+ temp_console = Console(file=buffer, force_terminal=True)
236
+ temp_console.print(table)
237
+ table_output = buffer.getvalue()
238
+ for line in table_output.splitlines():
239
+ sys.stdout.write(" " + line + "\n")
240
+
241
+ sys.stdout.flush()
242
+ needs_redraw = False
243
+
244
+ # Check for input (blocking, no timeout)
245
+ rlist, _, _ = select.select([fd], [], [])
246
+ if not rlist:
247
+ continue
248
+
249
+ # Read input
250
+ try:
251
+ ch = sys.stdin.read(1)
252
+ except Exception:
253
+ break
254
+
255
+ if ch == "\x1b": # ESC or arrow key
256
+ # Use longer timeout to reliably detect arrow sequences
257
+ rlist, _, _ = select.select([fd], [], [], 0.2)
258
+ if rlist:
259
+ # There's more input, likely an arrow sequence
260
+ try:
261
+ ch2 = sys.stdin.read(1)
262
+ if ch2 == "[":
263
+ ch3 = sys.stdin.read(1)
264
+ if ch3 == "A": # Up arrow - scroll up
265
+ scroll = max(0, scroll - 1)
266
+ needs_redraw = True
267
+ elif ch3 == "B": # Down arrow - scroll down
268
+ filtered = [p for p in all_points_sorted if filter_str == "" or filter_str.lower() in p.lower()]
269
+ scroll = min(len(filtered) - 1, scroll + 1) if filtered else 0
270
+ needs_redraw = True
271
+ except Exception:
272
+ break
273
+ else:
274
+ # No more input, so this was just ESC — quit
275
+ break
276
+ elif ch == "\x7f" or ch == "\x08": # Backspace
277
+ if filter_str:
278
+ filter_str = filter_str[:-1]
279
+ scroll = 0
280
+ needs_redraw = True
281
+ elif ch.isprintable():
282
+ filter_str += ch
283
+ scroll = 0
284
+ needs_redraw = True
285
+
286
+ except KeyboardInterrupt:
287
+ # Ctrl+C — exit gracefully without traceback
288
+ pass
289
+ finally:
290
+ termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
291
+ sys.stdout.write("\n")
292
+
293
+