usecomputer 0.1.2 → 0.1.3

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.
package/zig/src/main.zig CHANGED
@@ -3,6 +3,8 @@
3
3
  const std = @import("std");
4
4
  const zeke = @import("zeke");
5
5
  const lib = @import("usecomputer_lib");
6
+ const table = @import("table.zig");
7
+ const kitty_graphics = @import("kitty-graphics.zig");
6
8
 
7
9
  const File = std.fs.File;
8
10
  const Writer = File.DeprecatedWriter;
@@ -15,6 +17,85 @@ fn getStderr() Writer {
15
17
  return File.stderr().deprecatedWriter();
16
18
  }
17
19
 
20
+ // ─── Coord-map ───
21
+ // Port of src/coord-map.ts — maps screenshot-space pixels to desktop coordinates.
22
+
23
+ const CoordMap = struct {
24
+ captureX: f64,
25
+ captureY: f64,
26
+ captureWidth: f64,
27
+ captureHeight: f64,
28
+ imageWidth: f64,
29
+ imageHeight: f64,
30
+ };
31
+
32
+ fn parseCoordMap(s: []const u8) ?CoordMap {
33
+ var iter = std.mem.splitScalar(u8, s, ',');
34
+ const cx_str = iter.next() orelse return null;
35
+ const cy_str = iter.next() orelse return null;
36
+ const cw_str = iter.next() orelse return null;
37
+ const ch_str = iter.next() orelse return null;
38
+ const iw_str = iter.next() orelse return null;
39
+ const ih_str = iter.next() orelse return null;
40
+ const cx = std.fmt.parseFloat(f64, cx_str) catch return null;
41
+ const cy = std.fmt.parseFloat(f64, cy_str) catch return null;
42
+ const cw = std.fmt.parseFloat(f64, cw_str) catch return null;
43
+ const ch = std.fmt.parseFloat(f64, ch_str) catch return null;
44
+ const iw = std.fmt.parseFloat(f64, iw_str) catch return null;
45
+ const ih = std.fmt.parseFloat(f64, ih_str) catch return null;
46
+ if (cw <= 0 or ch <= 0 or iw <= 0 or ih <= 0) return null;
47
+ return .{
48
+ .captureX = cx,
49
+ .captureY = cy,
50
+ .captureWidth = cw,
51
+ .captureHeight = ch,
52
+ .imageWidth = iw,
53
+ .imageHeight = ih,
54
+ };
55
+ }
56
+
57
+ fn mapPointFromCoordMap(point: lib.Point, cm: ?CoordMap) lib.Point {
58
+ const m = cm orelse return point;
59
+ const iw_span = @max(m.imageWidth - 1, 1);
60
+ const ih_span = @max(m.imageHeight - 1, 1);
61
+ const cw_span = @max(m.captureWidth - 1, 0);
62
+ const ch_span = @max(m.captureHeight - 1, 0);
63
+ const max_cx = m.captureX + cw_span;
64
+ const max_cy = m.captureY + ch_span;
65
+ const mapped_x = m.captureX + (point.x / iw_span) * cw_span;
66
+ const mapped_y = m.captureY + (point.y / ih_span) * ch_span;
67
+ return .{
68
+ .x = @round(std.math.clamp(mapped_x, m.captureX, max_cx)),
69
+ .y = @round(std.math.clamp(mapped_y, m.captureY, max_cy)),
70
+ };
71
+ }
72
+
73
+ fn mapPointToCoordMap(point: lib.Point, cm: ?CoordMap) lib.Point {
74
+ const m = cm orelse return point;
75
+ const cw_span = @max(m.captureWidth - 1, 1);
76
+ const ch_span = @max(m.captureHeight - 1, 1);
77
+ const iw_span = @max(m.imageWidth - 1, 0);
78
+ const ih_span = @max(m.imageHeight - 1, 0);
79
+ const rel_x = (point.x - m.captureX) / cw_span;
80
+ const rel_y = (point.y - m.captureY) / ch_span;
81
+ const mapped_x = rel_x * iw_span;
82
+ const mapped_y = rel_y * ih_span;
83
+ return .{
84
+ .x = @round(std.math.clamp(mapped_x, 0, iw_span)),
85
+ .y = @round(std.math.clamp(mapped_y, 0, ih_span)),
86
+ };
87
+ }
88
+
89
+ fn getRegionFromCoordMap(cm: ?CoordMap) ?lib.ScreenshotRegion {
90
+ const m = cm orelse return null;
91
+ return .{
92
+ .x = m.captureX,
93
+ .y = m.captureY,
94
+ .width = m.captureWidth,
95
+ .height = m.captureHeight,
96
+ };
97
+ }
98
+
18
99
  // ─── Helpers ───
19
100
 
20
101
  fn parseF64(s: []const u8) ?f64 {
@@ -45,11 +126,11 @@ fn printError(result: anytype) void {
45
126
  }
46
127
  }
47
128
 
48
- fn printScreenshotJson(data: lib.ScreenshotOutput) void {
129
+ fn printScreenshotJson(data: lib.ScreenshotOutput, agent_graphics: bool) void {
49
130
  const stdout = getStdout();
50
131
  stdout.print(
51
- "{{\"path\":\"{s}\",\"desktopIndex\":{d:.0},\"captureX\":{d:.0},\"captureY\":{d:.0},\"captureWidth\":{d:.0},\"captureHeight\":{d:.0},\"imageWidth\":{d:.0},\"imageHeight\":{d:.0}}}\n",
52
- .{ data.path, data.desktopIndex, data.captureX, data.captureY, data.captureWidth, data.captureHeight, data.imageWidth, data.imageHeight },
132
+ "{{\"path\":\"{s}\",\"desktopIndex\":{d:.0},\"captureX\":{d:.0},\"captureY\":{d:.0},\"captureWidth\":{d:.0},\"captureHeight\":{d:.0},\"imageWidth\":{d:.0},\"imageHeight\":{d:.0},\"agentGraphics\":{s}}}\n",
133
+ .{ data.path, data.desktopIndex, data.captureX, data.captureY, data.captureWidth, data.captureHeight, data.imageWidth, data.imageHeight, if (agent_graphics) "true" else "false" },
53
134
  ) catch {};
54
135
  }
55
136
 
@@ -63,19 +144,26 @@ const Screenshot = zeke.cmd("screenshot [path]", "Take a screenshot")
63
144
  .option("--json", "Output as JSON");
64
145
 
65
146
  const Click = zeke.cmd("click [target]", "Click at coordinates or target")
66
- .option("-x <x>", "X coordinate")
67
- .option("-y <y>", "Y coordinate")
147
+ .option("-x [x]", "X coordinate")
148
+ .option("-y [y]", "Y coordinate")
68
149
  .option("--button [button]", "Mouse button: left, right, middle")
69
- .option("--count [count]", "Click count");
150
+ .option("--count [count]", "Click count")
151
+ .option("--modifiers [modifiers]", "Modifiers as ctrl,shift,alt,meta")
152
+ .option("--coord-map [map]", "Map screenshot-space pixels to desktop coordinates");
70
153
 
71
154
  const DebugPoint = zeke.cmd("debug-point [target]", "Validate click coordinates visually")
72
155
  .option("-x [x]", "X coordinate")
73
156
  .option("-y [y]", "Y coordinate")
157
+ .option("--coord-map [map]", "Map input coordinates from screenshot space")
74
158
  .option("--output [path]", "Save annotated screenshot")
75
159
  .option("--json", "Output as JSON");
76
160
 
77
161
  const TypeText = zeke.cmd("type [text]", "Type text using keyboard")
78
- .option("--delay [ms]", "Delay between keystrokes in ms");
162
+ .option("--stdin", "Read text from stdin instead of [text] argument")
163
+ .option("--delay [ms]", "Delay between keystrokes in ms")
164
+ .option("--chunk-size [n]", "Split text into fixed-size chunks before typing")
165
+ .option("--chunk-delay [ms]", "Delay in milliseconds between chunks")
166
+ .option("--max-length [n]", "Fail when input text exceeds this maximum length");
79
167
 
80
168
  const Press = zeke.cmd("press <key>", "Press a key or key combination")
81
169
  .option("--count [n]", "Number of times to press")
@@ -86,15 +174,18 @@ const Scroll = zeke.cmd("scroll <direction> [amount]", "Scroll in a direction")
86
174
 
87
175
  const Drag = zeke.cmd("drag <from> <to>", "Drag from one point to another")
88
176
  .option("--duration [ms]", "Drag duration in ms")
89
- .option("--button [button]", "Mouse button");
177
+ .option("--button [button]", "Mouse button")
178
+ .option("--coord-map [map]", "Map input coordinates from screenshot space");
90
179
 
91
- const Hover = zeke.cmd("hover", "Move mouse without clicking")
92
- .option("-x <x>", "X coordinate")
93
- .option("-y <y>", "Y coordinate");
180
+ const Hover = zeke.cmd("hover [target]", "Move mouse without clicking")
181
+ .option("-x [x]", "X coordinate")
182
+ .option("-y [y]", "Y coordinate")
183
+ .option("--coord-map [map]", "Map input coordinates from screenshot space");
94
184
 
95
185
  const MouseMove = zeke.cmd("mouse move", "Move to absolute coordinates")
96
- .option("-x <x>", "X coordinate")
97
- .option("-y <y>", "Y coordinate");
186
+ .option("-x [x]", "X coordinate")
187
+ .option("-y [y]", "Y coordinate")
188
+ .option("--coord-map [map]", "Map input coordinates from screenshot space");
98
189
 
99
190
  const MouseDown = zeke.cmd("mouse down", "Press and hold mouse button")
100
191
  .option("--button [button]", "Mouse button");
@@ -111,6 +202,10 @@ const DisplayList = zeke.cmd("display list", "List connected displays")
111
202
  const WindowList = zeke.cmd("window list", "List open windows")
112
203
  .option("--json", "Output as JSON");
113
204
 
205
+ const DesktopList = zeke.cmd("desktop list", "List desktops as display indexes and sizes")
206
+ .option("--windows", "Include available windows grouped by desktop index")
207
+ .option("--json", "Output as JSON");
208
+
114
209
  const ClipboardGet = zeke.cmd("clipboard get", "Print clipboard text");
115
210
 
116
211
  const ClipboardSet = zeke.cmd("clipboard set <text>", "Set clipboard text");
@@ -129,9 +224,18 @@ fn screenshotAction(args: Screenshot.Args, opts: Screenshot.Options) !void {
129
224
  printError(result);
130
225
  return error.CommandFailed;
131
226
  }
227
+
228
+ // Attempt Kitty Graphics Protocol emission when AGENT_GRAPHICS=kitty is set.
229
+ // Track whether emission actually succeeded so JSON reports the real state
230
+ // (not just that it was requested).
231
+ const agent_graphics_emitted = if (kitty_graphics.canEmitAgentGraphics())
232
+ emitScreenshotKittyGraphics(result.data)
233
+ else
234
+ false;
235
+
132
236
  if (opts.json) {
133
237
  if (result.data) |data| {
134
- printScreenshotJson(data);
238
+ printScreenshotJson(data, agent_graphics_emitted);
135
239
  }
136
240
  } else {
137
241
  const stdout = getStdout();
@@ -140,14 +244,63 @@ fn screenshotAction(args: Screenshot.Args, opts: Screenshot.Options) !void {
140
244
  data.path, data.imageWidth, data.imageHeight,
141
245
  });
142
246
  }
247
+ if (agent_graphics_emitted) {
248
+ try stdout.print("The screenshot image is in your context. No need to read the file.\n", .{});
249
+ }
143
250
  }
144
251
  }
145
252
 
146
- fn clickAction(_: Click.Args, opts: Click.Options) !void {
147
- const x = parseF64(opts.x) orelse return error.InvalidCoordinate;
148
- const y = parseF64(opts.y) orelse return error.InvalidCoordinate;
253
+ /// Read a screenshot PNG and emit it as Kitty Graphics Protocol escape sequences.
254
+ /// Handles both absolute and relative paths. Returns true if emission succeeded.
255
+ fn emitScreenshotKittyGraphics(data: ?lib.ScreenshotOutput) bool {
256
+ const d = data orelse return false;
257
+ const path = d.path;
258
+
259
+ // Open file — handle absolute vs relative paths
260
+ const file = if (path.len > 0 and path[0] == '/')
261
+ std.fs.openFileAbsolute(path, .{})
262
+ else
263
+ std.fs.cwd().openFile(path, .{});
264
+
265
+ const f = file catch |err| {
266
+ const stderr = getStderr();
267
+ stderr.print("warning: could not open screenshot for kitty graphics: {}\n", .{err}) catch {};
268
+ return false;
269
+ };
270
+ defer f.close();
271
+
272
+ const bytes = f.readToEndAlloc(std.heap.page_allocator, 50 * 1024 * 1024) catch |err| {
273
+ const stderr = getStderr();
274
+ stderr.print("warning: could not read screenshot for kitty graphics: {}\n", .{err}) catch {};
275
+ return false;
276
+ };
277
+ defer std.heap.page_allocator.free(bytes);
278
+
279
+ if (bytes.len == 0) return false;
280
+
281
+ const stdout = getStdout();
282
+ kitty_graphics.emitKittyGraphics(bytes, stdout) catch |err| {
283
+ const stderr = getStderr();
284
+ stderr.print("warning: kitty graphics emission failed: {}\n", .{err}) catch {};
285
+ return false;
286
+ };
287
+ return true;
288
+ }
289
+
290
+ fn clickAction(args: Click.Args, opts: Click.Options) !void {
291
+ const raw_point = resolvePoint(args.target, opts.x, opts.y) orelse {
292
+ const stderr = getStderr();
293
+ try stderr.print("error: coordinates required (-x and -y, or positional x,y)\n", .{});
294
+ return error.InvalidCoordinate;
295
+ };
296
+ const cm = if (opts.coord_map) |s| (parseCoordMap(s) orelse {
297
+ const stderr = getStderr();
298
+ try stderr.print("error: invalid --coord-map, expected x,y,width,height,imageWidth,imageHeight\n", .{});
299
+ return error.CommandFailed;
300
+ }) else null;
301
+ const point = mapPointFromCoordMap(raw_point, cm);
149
302
  const result = lib.click(.{
150
- .point = .{ .x = x, .y = y },
303
+ .point = point,
151
304
  .button = opts.button,
152
305
  .count = if (opts.count) |c| parseF64(c) else null,
153
306
  });
@@ -157,25 +310,198 @@ fn clickAction(_: Click.Args, opts: Click.Options) !void {
157
310
  }
158
311
  }
159
312
 
160
- fn debugPointAction(_: DebugPoint.Args, _: DebugPoint.Options) !void {
313
+ fn debugPointAction(args: DebugPoint.Args, opts: DebugPoint.Options) !void {
161
314
  const stderr = getStderr();
162
- try stderr.print("debug-point: TODO\n", .{});
163
- }
315
+ const stdout = getStdout();
164
316
 
165
- fn typeTextAction(args: TypeText.Args, opts: TypeText.Options) !void {
166
- const text = args.text orelse {
167
- const stderr = getStderr();
168
- try stderr.print("error: text argument required\n", .{});
169
- return error.MissingArgument;
317
+ // Resolve input point
318
+ const input_point = resolvePoint(args.target, opts.x, opts.y) orelse {
319
+ try stderr.print("error: coordinates required (-x and -y, or positional x,y)\n", .{});
320
+ return error.InvalidCoordinate;
170
321
  };
171
- const result = lib.typeText(.{
172
- .text = text,
173
- .delayMs = if (opts.delay) |d| parseF64(d) else null,
322
+
323
+ // Parse coord-map and compute desktop point
324
+ const cm = if (opts.coord_map) |s| parseCoordMap(s) else null;
325
+ const desktop_point = mapPointFromCoordMap(input_point, cm);
326
+
327
+ // Take screenshot (using coord-map region if provided)
328
+ const output_path = opts.output orelse "./tmp/debug-point.png";
329
+ const screenshot_result = lib.screenshot(.{
330
+ .path = output_path,
331
+ .region = getRegionFromCoordMap(cm),
174
332
  });
175
- if (!result.ok) {
176
- printError(result);
333
+ if (!screenshot_result.ok) {
334
+ printError(screenshot_result);
177
335
  return error.CommandFailed;
178
336
  }
337
+ const data = screenshot_result.data orelse {
338
+ try stderr.print("error: screenshot returned no data\n", .{});
339
+ return error.CommandFailed;
340
+ };
341
+
342
+ // Compute screenshot-space point for the marker
343
+ const screenshot_cm = CoordMap{
344
+ .captureX = data.captureX,
345
+ .captureY = data.captureY,
346
+ .captureWidth = data.captureWidth,
347
+ .captureHeight = data.captureHeight,
348
+ .imageWidth = data.imageWidth,
349
+ .imageHeight = data.imageHeight,
350
+ };
351
+ const screenshot_point = mapPointToCoordMap(desktop_point, screenshot_cm);
352
+
353
+ // Draw marker on the screenshot
354
+ const draw_result = lib.drawMarkerOnPng(.{
355
+ .path = data.path,
356
+ .x = screenshot_point.x,
357
+ .y = screenshot_point.y,
358
+ .imageWidth = data.imageWidth,
359
+ .imageHeight = data.imageHeight,
360
+ });
361
+ if (!draw_result.ok) {
362
+ // Non-fatal: print warning but still output coordinates
363
+ try stderr.print("warning: could not draw marker on screenshot\n", .{});
364
+ }
365
+
366
+ if (opts.json) {
367
+ stdout.print(
368
+ "{{\"path\":\"{s}\",\"inputPoint\":{{\"x\":{d:.0},\"y\":{d:.0}}},\"desktopPoint\":{{\"x\":{d:.0},\"y\":{d:.0}}},\"screenshotPoint\":{{\"x\":{d:.0},\"y\":{d:.0}}}}}\n",
369
+ .{
370
+ data.path,
371
+ input_point.x,
372
+ input_point.y,
373
+ desktop_point.x,
374
+ desktop_point.y,
375
+ screenshot_point.x,
376
+ screenshot_point.y,
377
+ },
378
+ ) catch {};
379
+ } else {
380
+ try stdout.print("{s}\n", .{data.path});
381
+ try stdout.print("input-point={d:.0},{d:.0}\n", .{ input_point.x, input_point.y });
382
+ try stdout.print("desktop-point={d:.0},{d:.0}\n", .{ desktop_point.x, desktop_point.y });
383
+ try stdout.print("screenshot-point={d:.0},{d:.0}\n", .{ screenshot_point.x, screenshot_point.y });
384
+ }
385
+ }
386
+
387
+ fn readAllStdin(allocator: std.mem.Allocator) ![]const u8 {
388
+ const stdin = std.fs.File.stdin();
389
+ var buf: [8192]u8 = undefined;
390
+ var list: std.ArrayListUnmanaged(u8) = .empty;
391
+ errdefer list.deinit(allocator);
392
+ while (true) {
393
+ const n = stdin.read(&buf) catch return error.StdinReadFailed;
394
+ if (n == 0) break;
395
+ list.appendSlice(allocator, buf[0..n]) catch return error.StdinReadFailed;
396
+ if (list.items.len > 10 * 1024 * 1024) return error.StdinReadFailed;
397
+ }
398
+ return list.toOwnedSlice(allocator) catch return error.StdinReadFailed;
399
+ }
400
+
401
+ fn typeTextAction(args: TypeText.Args, opts: TypeText.Options) !void {
402
+ const stderr = getStderr();
403
+ const from_stdin = opts.stdin;
404
+
405
+ if (from_stdin and args.text != null) {
406
+ try stderr.print("error: use either [text] or --stdin, not both\n", .{});
407
+ return error.MissingArgument;
408
+ }
409
+
410
+ // Get the text to type
411
+ var gpa = std.heap.GeneralPurposeAllocator(.{}){};
412
+ defer _ = gpa.deinit();
413
+ const allocator = gpa.allocator();
414
+
415
+ const text: []const u8 = if (from_stdin)
416
+ readAllStdin(allocator) catch {
417
+ try stderr.print("error: failed to read from stdin\n", .{});
418
+ return error.StdinReadFailed;
419
+ }
420
+ else
421
+ args.text orelse {
422
+ try stderr.print("error: text argument or --stdin required\n", .{});
423
+ return error.MissingArgument;
424
+ };
425
+ defer if (from_stdin) allocator.free(text);
426
+
427
+ // Check max-length
428
+ if (opts.max_length) |ml_str| {
429
+ const max_len = parseF64(ml_str) orelse {
430
+ try stderr.print("error: --max-length must be a positive number\n", .{});
431
+ return error.CommandFailed;
432
+ };
433
+ if (max_len <= 0) {
434
+ try stderr.print("error: --max-length must be a positive number\n", .{});
435
+ return error.CommandFailed;
436
+ }
437
+ if (@as(f64, @floatFromInt(text.len)) > max_len) {
438
+ try stderr.print("error: input text length {d} exceeds --max-length {d:.0}\n", .{ text.len, max_len });
439
+ return error.CommandFailed;
440
+ }
441
+ }
442
+
443
+ // Determine chunk size
444
+ const chunk_size: ?usize = if (opts.chunk_size) |cs_str| blk: {
445
+ const cs = parseF64(cs_str) orelse {
446
+ try stderr.print("error: --chunk-size must be a positive number\n", .{});
447
+ return error.CommandFailed;
448
+ };
449
+ if (cs <= 0) {
450
+ try stderr.print("error: --chunk-size must be a positive number\n", .{});
451
+ return error.CommandFailed;
452
+ }
453
+ break :blk @as(usize, @intFromFloat(cs));
454
+ } else null;
455
+
456
+ const chunk_delay_ns: ?u64 = if (opts.chunk_delay) |cd_str| blk: {
457
+ const cd = parseF64(cd_str) orelse {
458
+ try stderr.print("error: --chunk-delay must be a positive number\n", .{});
459
+ return error.CommandFailed;
460
+ };
461
+ if (cd < 0) {
462
+ try stderr.print("error: --chunk-delay must be a non-negative number\n", .{});
463
+ return error.CommandFailed;
464
+ }
465
+ break :blk @as(u64, @intFromFloat(cd * 1_000_000));
466
+ } else null;
467
+
468
+ if (chunk_size) |cs| {
469
+ // Type in chunks (split on UTF-8 boundaries to avoid breaking codepoints)
470
+ var offset: usize = 0;
471
+ while (offset < text.len) {
472
+ var end = @min(offset + cs, text.len);
473
+ // Walk back to a UTF-8 character boundary if we split mid-codepoint
474
+ while (end < text.len and end > offset and (text[end] & 0xC0) == 0x80) {
475
+ end -= 1;
476
+ }
477
+ if (end == offset) end = @min(offset + cs, text.len); // fallback if all continuation bytes
478
+ const chunk = text[offset..end];
479
+ const result = lib.typeText(.{
480
+ .text = chunk,
481
+ .delayMs = if (opts.delay) |d| parseF64(d) else null,
482
+ });
483
+ if (!result.ok) {
484
+ printError(result);
485
+ return error.CommandFailed;
486
+ }
487
+ offset = end;
488
+ if (offset < text.len) {
489
+ if (chunk_delay_ns) |delay| {
490
+ std.Thread.sleep(delay);
491
+ }
492
+ }
493
+ }
494
+ } else {
495
+ // Type all at once
496
+ const result = lib.typeText(.{
497
+ .text = text,
498
+ .delayMs = if (opts.delay) |d| parseF64(d) else null,
499
+ });
500
+ if (!result.ok) {
501
+ printError(result);
502
+ return error.CommandFailed;
503
+ }
504
+ }
179
505
  }
180
506
 
181
507
  fn pressAction(args: Press.Args, opts: Press.Options) !void {
@@ -215,11 +541,12 @@ fn scrollAction(args: Scroll.Args, opts: Scroll.Options) !void {
215
541
 
216
542
  fn dragAction(args: Drag.Args, opts: Drag.Options) !void {
217
543
  // Parse "x,y" format for from and to
218
- const from = parsePointArg(args.from) orelse return error.InvalidCoordinate;
219
- const to = parsePointArg(args.to) orelse return error.InvalidCoordinate;
544
+ const from_raw = parsePointArg(args.from) orelse return error.InvalidCoordinate;
545
+ const to_raw = parsePointArg(args.to) orelse return error.InvalidCoordinate;
546
+ const cm = if (opts.coord_map) |s| parseCoordMap(s) else null;
220
547
  const result = lib.drag(.{
221
- .from = from,
222
- .to = to,
548
+ .from = mapPointFromCoordMap(from_raw, cm),
549
+ .to = mapPointFromCoordMap(to_raw, cm),
223
550
  .durationMs = if (opts.duration) |d| parseF64(d) else null,
224
551
  .button = opts.button,
225
552
  });
@@ -229,6 +556,18 @@ fn dragAction(args: Drag.Args, opts: Drag.Options) !void {
229
556
  }
230
557
  }
231
558
 
559
+ fn resolvePoint(target: ?[]const u8, opt_x: ?[]const u8, opt_y: ?[]const u8) ?lib.Point {
560
+ if (opt_x) |x_str| {
561
+ if (opt_y) |y_str| {
562
+ const x = parseF64(x_str) orelse return null;
563
+ const y = parseF64(y_str) orelse return null;
564
+ return .{ .x = x, .y = y };
565
+ }
566
+ }
567
+ if (target) |t| return parsePointArg(t);
568
+ return null;
569
+ }
570
+
232
571
  fn parsePointArg(s: []const u8) ?lib.Point {
233
572
  var iter = std.mem.splitScalar(u8, s, ',');
234
573
  const x_str = iter.next() orelse return null;
@@ -239,10 +578,10 @@ fn parsePointArg(s: []const u8) ?lib.Point {
239
578
  };
240
579
  }
241
580
 
242
- fn hoverAction(_: Hover.Args, opts: Hover.Options) !void {
243
- const x = parseF64(opts.x) orelse return error.InvalidCoordinate;
244
- const y = parseF64(opts.y) orelse return error.InvalidCoordinate;
245
- const result = lib.hover(.{ .x = x, .y = y });
581
+ fn hoverAction(args: Hover.Args, opts: Hover.Options) !void {
582
+ const point = resolvePoint(args.target, opts.x, opts.y) orelse return error.InvalidCoordinate;
583
+ const cm = if (opts.coord_map) |s| parseCoordMap(s) else null;
584
+ const result = lib.hover(mapPointFromCoordMap(point, cm));
246
585
  if (!result.ok) {
247
586
  printError(result);
248
587
  return error.CommandFailed;
@@ -250,9 +589,13 @@ fn hoverAction(_: Hover.Args, opts: Hover.Options) !void {
250
589
  }
251
590
 
252
591
  fn mouseMoveAction(_: MouseMove.Args, opts: MouseMove.Options) !void {
253
- const x = parseF64(opts.x) orelse return error.InvalidCoordinate;
254
- const y = parseF64(opts.y) orelse return error.InvalidCoordinate;
255
- const result = lib.mouseMove(.{ .x = x, .y = y });
592
+ const x_str = opts.x orelse return error.InvalidCoordinate;
593
+ const y_str = opts.y orelse return error.InvalidCoordinate;
594
+ const x = parseF64(x_str) orelse return error.InvalidCoordinate;
595
+ const y = parseF64(y_str) orelse return error.InvalidCoordinate;
596
+ const cm = if (opts.coord_map) |s| parseCoordMap(s) else null;
597
+ const point = mapPointFromCoordMap(.{ .x = x, .y = y }, cm);
598
+ const result = lib.mouseMove(point);
256
599
  if (!result.ok) {
257
600
  printError(result);
258
601
  return error.CommandFailed;
@@ -291,6 +634,228 @@ fn mousePositionAction(_: MousePosition.Args, opts: MousePosition.Options) !void
291
634
  }
292
635
  }
293
636
 
637
+ // ─── Table rendering for list commands ───
638
+
639
+ fn jsonStr(value: std.json.Value) []const u8 {
640
+ return switch (value) {
641
+ .string => |s| s,
642
+ else => "",
643
+ };
644
+ }
645
+
646
+ fn jsonIntAlloc(allocator: std.mem.Allocator, value: std.json.Value) ![]u8 {
647
+ return switch (value) {
648
+ .integer => |n| try std.fmt.allocPrint(allocator, "{d}", .{n}),
649
+ .float => |f| try std.fmt.allocPrint(allocator, "{d:.0}", .{f}),
650
+ else => try allocator.dupe(u8, "?"),
651
+ };
652
+ }
653
+
654
+ fn jsonBool(value: std.json.Value) []const u8 {
655
+ return switch (value) {
656
+ .bool => |b| if (b) "yes" else "no",
657
+ else => "no",
658
+ };
659
+ }
660
+
661
+ fn printDisplayTable(allocator: std.mem.Allocator, json_data: []const u8) !void {
662
+ const stdout = getStdout();
663
+ const parsed = std.json.parseFromSlice(std.json.Value, allocator, json_data, .{}) catch {
664
+ try stdout.print("{s}\n", .{json_data});
665
+ return;
666
+ };
667
+ defer parsed.deinit();
668
+
669
+ const items = switch (parsed.value) {
670
+ .array => |a| a.items,
671
+ else => {
672
+ try stdout.print("{s}\n", .{json_data});
673
+ return;
674
+ },
675
+ };
676
+
677
+ if (items.len == 0) {
678
+ try stdout.print("no displays\n", .{});
679
+ return;
680
+ }
681
+
682
+ const columns = &[_]table.Column{
683
+ .{ .header = "desktop" },
684
+ .{ .header = "primary" },
685
+ .{ .header = "size", .alignment = .right },
686
+ .{ .header = "position", .alignment = .right },
687
+ .{ .header = "id", .alignment = .right },
688
+ .{ .header = "scale", .alignment = .right },
689
+ .{ .header = "name" },
690
+ };
691
+
692
+ // Build rows — each row is an array of cell strings
693
+ var rows = std.ArrayListUnmanaged([]const []const u8).empty;
694
+ defer {
695
+ for (rows.items) |row| allocator.free(row);
696
+ rows.deinit(allocator);
697
+ }
698
+
699
+ // Buffers for formatted strings that outlive the loop iteration
700
+ var string_bufs = std.ArrayListUnmanaged([]u8).empty;
701
+ defer {
702
+ for (string_bufs.items) |buf| allocator.free(buf);
703
+ string_bufs.deinit(allocator);
704
+ }
705
+
706
+ for (items) |item| {
707
+ const obj = switch (item) {
708
+ .object => |o| o,
709
+ else => continue,
710
+ };
711
+
712
+ const index_str = try jsonIntAlloc(allocator, obj.get("index") orelse continue);
713
+ try string_bufs.append(allocator, index_str);
714
+ const desktop_str = try std.fmt.allocPrint(allocator, "#{s}", .{index_str});
715
+ try string_bufs.append(allocator, desktop_str);
716
+
717
+ const w_str = try jsonIntAlloc(allocator, obj.get("width") orelse continue);
718
+ try string_bufs.append(allocator, w_str);
719
+ const h_str = try jsonIntAlloc(allocator, obj.get("height") orelse continue);
720
+ try string_bufs.append(allocator, h_str);
721
+ const size_str = try std.fmt.allocPrint(allocator, "{s}x{s}", .{ w_str, h_str });
722
+ try string_bufs.append(allocator, size_str);
723
+
724
+ const x_str = try jsonIntAlloc(allocator, obj.get("x") orelse continue);
725
+ try string_bufs.append(allocator, x_str);
726
+ const y_str = try jsonIntAlloc(allocator, obj.get("y") orelse continue);
727
+ try string_bufs.append(allocator, y_str);
728
+ const pos_str = try std.fmt.allocPrint(allocator, "{s},{s}", .{ x_str, y_str });
729
+ try string_bufs.append(allocator, pos_str);
730
+
731
+ const id_str = try jsonIntAlloc(allocator, obj.get("id") orelse continue);
732
+ try string_bufs.append(allocator, id_str);
733
+
734
+ const scale_str = try jsonIntAlloc(allocator, obj.get("scale") orelse continue);
735
+ try string_bufs.append(allocator, scale_str);
736
+
737
+ const name_val = obj.get("name") orelse continue;
738
+
739
+ const row = try allocator.alloc([]const u8, 7);
740
+ row[0] = desktop_str;
741
+ row[1] = jsonBool(obj.get("isPrimary") orelse .{ .bool = false });
742
+ row[2] = size_str;
743
+ row[3] = pos_str;
744
+ row[4] = id_str;
745
+ row[5] = scale_str;
746
+ row[6] = jsonStr(name_val);
747
+ try rows.append(allocator, row);
748
+ }
749
+
750
+ const lines = try table.render(allocator, columns, rows.items);
751
+ defer {
752
+ for (lines) |line| allocator.free(line);
753
+ allocator.free(lines);
754
+ }
755
+
756
+ for (lines) |line| {
757
+ try stdout.print("{s}\n", .{line});
758
+ }
759
+ }
760
+
761
+ fn printWindowTable(allocator: std.mem.Allocator, json_data: []const u8) !void {
762
+ const stdout = getStdout();
763
+ const parsed = std.json.parseFromSlice(std.json.Value, allocator, json_data, .{}) catch {
764
+ try stdout.print("{s}\n", .{json_data});
765
+ return;
766
+ };
767
+ defer parsed.deinit();
768
+
769
+ const items = switch (parsed.value) {
770
+ .array => |a| a.items,
771
+ else => {
772
+ try stdout.print("{s}\n", .{json_data});
773
+ return;
774
+ },
775
+ };
776
+
777
+ if (items.len == 0) {
778
+ try stdout.print("no windows\n", .{});
779
+ return;
780
+ }
781
+
782
+ const columns = &[_]table.Column{
783
+ .{ .header = "id", .alignment = .right },
784
+ .{ .header = "desktop", .alignment = .right },
785
+ .{ .header = "app" },
786
+ .{ .header = "pid", .alignment = .right },
787
+ .{ .header = "size", .alignment = .right },
788
+ .{ .header = "position", .alignment = .right },
789
+ .{ .header = "title" },
790
+ };
791
+
792
+ var rows = std.ArrayListUnmanaged([]const []const u8).empty;
793
+ defer {
794
+ for (rows.items) |row| allocator.free(row);
795
+ rows.deinit(allocator);
796
+ }
797
+
798
+ var string_bufs = std.ArrayListUnmanaged([]u8).empty;
799
+ defer {
800
+ for (string_bufs.items) |buf| allocator.free(buf);
801
+ string_bufs.deinit(allocator);
802
+ }
803
+
804
+ for (items) |item| {
805
+ const obj = switch (item) {
806
+ .object => |o| o,
807
+ else => continue,
808
+ };
809
+
810
+ const id_str = try jsonIntAlloc(allocator, obj.get("id") orelse continue);
811
+ try string_bufs.append(allocator, id_str);
812
+
813
+ const di_str = try jsonIntAlloc(allocator, obj.get("desktopIndex") orelse continue);
814
+ try string_bufs.append(allocator, di_str);
815
+ const desktop_str = try std.fmt.allocPrint(allocator, "#{s}", .{di_str});
816
+ try string_bufs.append(allocator, desktop_str);
817
+
818
+ const pid_str = try jsonIntAlloc(allocator, obj.get("ownerPid") orelse continue);
819
+ try string_bufs.append(allocator, pid_str);
820
+
821
+ const w_str = try jsonIntAlloc(allocator, obj.get("width") orelse continue);
822
+ try string_bufs.append(allocator, w_str);
823
+ const h_str = try jsonIntAlloc(allocator, obj.get("height") orelse continue);
824
+ try string_bufs.append(allocator, h_str);
825
+ const size_str = try std.fmt.allocPrint(allocator, "{s}x{s}", .{ w_str, h_str });
826
+ try string_bufs.append(allocator, size_str);
827
+
828
+ const x_str = try jsonIntAlloc(allocator, obj.get("x") orelse continue);
829
+ try string_bufs.append(allocator, x_str);
830
+ const y_str = try jsonIntAlloc(allocator, obj.get("y") orelse continue);
831
+ try string_bufs.append(allocator, y_str);
832
+ const pos_str = try std.fmt.allocPrint(allocator, "{s},{s}", .{ x_str, y_str });
833
+ try string_bufs.append(allocator, pos_str);
834
+
835
+ const row = try allocator.alloc([]const u8, 7);
836
+ row[0] = id_str;
837
+ row[1] = desktop_str;
838
+ row[2] = jsonStr(obj.get("ownerName") orelse .{ .string = "" });
839
+ row[3] = pid_str;
840
+ row[4] = size_str;
841
+ row[5] = pos_str;
842
+ row[6] = jsonStr(obj.get("title") orelse .{ .string = "" });
843
+ try rows.append(allocator, row);
844
+ }
845
+
846
+ const lines = try table.render(allocator, columns, rows.items);
847
+ defer {
848
+ for (lines) |line| allocator.free(line);
849
+ allocator.free(lines);
850
+ }
851
+
852
+ for (lines) |line| {
853
+ try stdout.print("{s}\n", .{line});
854
+ }
855
+ }
856
+
857
+ // ─── List command actions ───
858
+
294
859
  fn displayListAction(_: DisplayList.Args, opts: DisplayList.Options) !void {
295
860
  const result = lib.displayList();
296
861
  if (!result.ok) {
@@ -298,11 +863,16 @@ fn displayListAction(_: DisplayList.Args, opts: DisplayList.Options) !void {
298
863
  return error.CommandFailed;
299
864
  }
300
865
  if (result.data) |data| {
301
- const stdout = getStdout();
302
866
  if (opts.json) {
867
+ const stdout = getStdout();
303
868
  try stdout.print("{s}\n", .{data});
304
869
  } else {
305
- try stdout.print("{s}\n", .{data});
870
+ var gpa = std.heap.GeneralPurposeAllocator(.{}){};
871
+ defer _ = gpa.deinit();
872
+ printDisplayTable(gpa.allocator(), data) catch {
873
+ const stdout = getStdout();
874
+ try stdout.print("{s}\n", .{data});
875
+ };
306
876
  }
307
877
  }
308
878
  }
@@ -314,11 +884,60 @@ fn windowListAction(_: WindowList.Args, opts: WindowList.Options) !void {
314
884
  return error.CommandFailed;
315
885
  }
316
886
  if (result.data) |data| {
317
- const stdout = getStdout();
318
887
  if (opts.json) {
888
+ const stdout = getStdout();
319
889
  try stdout.print("{s}\n", .{data});
320
890
  } else {
321
- try stdout.print("{s}\n", .{data});
891
+ var gpa = std.heap.GeneralPurposeAllocator(.{}){};
892
+ defer _ = gpa.deinit();
893
+ printWindowTable(gpa.allocator(), data) catch {
894
+ const stdout = getStdout();
895
+ try stdout.print("{s}\n", .{data});
896
+ };
897
+ }
898
+ }
899
+ }
900
+
901
+ fn desktopListAction(_: DesktopList.Args, opts: DesktopList.Options) !void {
902
+ const display_result = lib.displayList();
903
+ if (!display_result.ok) {
904
+ printError(display_result);
905
+ return error.CommandFailed;
906
+ }
907
+ const stdout = getStdout();
908
+
909
+ if (opts.windows) {
910
+ const window_result = lib.windowList();
911
+ if (!window_result.ok) {
912
+ printError(window_result);
913
+ return error.CommandFailed;
914
+ }
915
+ if (opts.json) {
916
+ try stdout.print("{{\"displays\":{s},\"windows\":{s}}}\n", .{
917
+ if (display_result.data) |d| d else "[]",
918
+ if (window_result.data) |w| w else "[]",
919
+ });
920
+ } else {
921
+ var gpa = std.heap.GeneralPurposeAllocator(.{}){};
922
+ defer _ = gpa.deinit();
923
+ const allocator = gpa.allocator();
924
+ if (display_result.data) |d| {
925
+ printDisplayTable(allocator, d) catch try stdout.print("{s}\n", .{d});
926
+ }
927
+ try stdout.print("\n", .{});
928
+ if (window_result.data) |w| {
929
+ printWindowTable(allocator, w) catch try stdout.print("{s}\n", .{w});
930
+ }
931
+ }
932
+ } else {
933
+ if (opts.json) {
934
+ if (display_result.data) |d| try stdout.print("{s}\n", .{d});
935
+ } else {
936
+ var gpa = std.heap.GeneralPurposeAllocator(.{}){};
937
+ defer _ = gpa.deinit();
938
+ if (display_result.data) |d| {
939
+ printDisplayTable(gpa.allocator(), d) catch try stdout.print("{s}\n", .{d});
940
+ }
322
941
  }
323
942
  }
324
943
  }
@@ -363,12 +982,13 @@ pub fn main() !void {
363
982
  MouseUp.bind(mouseUpAction),
364
983
  MousePosition.bind(mousePositionAction),
365
984
  DisplayList.bind(displayListAction),
985
+ DesktopList.bind(desktopListAction),
366
986
  WindowList.bind(windowListAction),
367
987
  ClipboardGet.bind(clipboardGetAction),
368
988
  ClipboardSet.bind(clipboardSetAction),
369
989
  }).init(gpa.allocator(), "usecomputer");
370
990
 
371
- app.setVersion("0.0.4");
991
+ app.setVersion("0.1.2");
372
992
  app.run() catch |err| {
373
993
  switch (err) {
374
994
  error.CommandFailed, error.InvalidCoordinate, error.MissingArgument => {},