Skip to content

Commit c37c117

Browse files
committed
feat(geisterhand): add /key, /scroll endpoints, widget filtering, iOS parity, accessibility bridge
Baked-in API improvements: - POST /key: fire menu callbacks by shortcut string (case-insensitive) - POST /scroll/:handle: programmatic scrollview scrolling with x,y offsets - GET /widgets?label=&type=: query parameter filtering on widget list - Registry now includes shortcut field in JSON output iOS widget registration parity (previously only buttons): - TextField, Slider, Toggle, Picker, Menu items now registered with geisterhand - ScrollView registered as new type 8 Accessibility improvements: - iOS: setAccessibilityLabel on Button, Text, TextField, Toggle - Both platforms: accessibilityIdentifier set to "gh-{handle}" on all widgets (bridges baked-in handles to external CLI accessibility tree) macOS menu shortcuts now registered via register_with_shortcut for /key lookup
1 parent c60c4de commit c37c117

17 files changed

Lines changed: 395 additions & 27 deletions

File tree

crates/perry-runtime/src/geisterhand_registry.rs

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ pub const WIDGET_PICKER: u8 = 4;
1414
pub const WIDGET_MENU: u8 = 5;
1515
pub const WIDGET_SHORTCUT: u8 = 6;
1616
pub const WIDGET_TABLE: u8 = 7;
17+
pub const WIDGET_SCROLLVIEW: u8 = 8;
1718

1819
/// Callback kind identifiers
1920
pub const CB_ON_CLICK: u8 = 0;
@@ -30,6 +31,7 @@ pub struct RegisteredWidget {
3031
pub callback_kind: u8,
3132
pub closure_f64: f64,
3233
pub label: String,
34+
pub shortcut: String,
3335
}
3436

3537
/// An action queued for main-thread execution
@@ -38,6 +40,7 @@ pub enum PendingAction {
3840
SetState { handle: i64, value: f64 },
3941
CaptureScreenshot,
4042
SetText { handle: i64, text: String },
43+
ScrollTo { handle: i64, x: f64, y: f64 },
4144
}
4245

4346
static REGISTRY: Mutex<Vec<RegisteredWidget>> = Mutex::new(Vec::new());
@@ -64,6 +67,7 @@ use std::sync::atomic::{AtomicPtr, Ordering};
6467
static UI_STATE_SET_FN: AtomicPtr<()> = AtomicPtr::new(std::ptr::null_mut());
6568
static UI_SCREENSHOT_CAPTURE_FN: AtomicPtr<()> = AtomicPtr::new(std::ptr::null_mut());
6669
static UI_TEXTFIELD_SET_STRING_FN: AtomicPtr<()> = AtomicPtr::new(std::ptr::null_mut());
70+
static UI_SCROLL_SET_FN: AtomicPtr<()> = AtomicPtr::new(std::ptr::null_mut());
6771

6872
/// Register the platform UI crate's state_set function.
6973
#[no_mangle]
@@ -85,6 +89,12 @@ pub extern "C" fn perry_geisterhand_register_textfield_set_string(f: extern "C"
8589
UI_TEXTFIELD_SET_STRING_FN.store(f as *mut (), Ordering::Release);
8690
}
8791

92+
/// Register the platform UI crate's scroll_set function.
93+
#[no_mangle]
94+
pub extern "C" fn perry_geisterhand_register_scroll_set(f: extern "C" fn(i64, f64, f64)) {
95+
UI_SCROLL_SET_FN.store(f as *mut (), Ordering::Release);
96+
}
97+
8898
/// Register a widget callback in the global registry.
8999
/// Called from platform UI crates when widgets are created or callbacks attached.
90100
///
@@ -123,10 +133,89 @@ pub extern "C" fn perry_geisterhand_register(
123133
callback_kind,
124134
closure_f64,
125135
label,
136+
shortcut: String::new(),
137+
});
138+
}
139+
}
140+
141+
/// Register a widget callback with an associated keyboard shortcut string.
142+
/// Used by menu items that have shortcuts (e.g., "s" for Cmd+S).
143+
#[no_mangle]
144+
pub extern "C" fn perry_geisterhand_register_with_shortcut(
145+
handle: i64,
146+
widget_type: u8,
147+
callback_kind: u8,
148+
closure_f64: f64,
149+
label_ptr: *const u8,
150+
shortcut_ptr: *const u8,
151+
shortcut_len: usize,
152+
) {
153+
let label = if label_ptr.is_null() {
154+
String::new()
155+
} else {
156+
unsafe {
157+
let len = *(label_ptr as *const u32) as usize;
158+
let data = label_ptr.add(std::mem::size_of::<[u64; 1]>());
159+
if len > 0 && len < 10000 {
160+
String::from_utf8_lossy(std::slice::from_raw_parts(data, len)).into_owned()
161+
} else {
162+
String::new()
163+
}
164+
}
165+
};
166+
let shortcut = if shortcut_ptr.is_null() || shortcut_len == 0 {
167+
String::new()
168+
} else {
169+
unsafe {
170+
String::from_utf8_lossy(std::slice::from_raw_parts(shortcut_ptr, shortcut_len)).into_owned()
171+
}
172+
};
173+
if let Ok(mut reg) = REGISTRY.lock() {
174+
reg.push(RegisteredWidget {
175+
handle,
176+
widget_type,
177+
callback_kind,
178+
closure_f64,
179+
label,
180+
shortcut,
126181
});
127182
}
128183
}
129184

185+
/// Find a registered callback by shortcut string. Case-insensitive match.
186+
/// Returns the closure_f64 or 0.0 if not found.
187+
#[no_mangle]
188+
pub extern "C" fn perry_geisterhand_find_by_shortcut(
189+
shortcut_ptr: *const u8,
190+
shortcut_len: usize,
191+
) -> f64 {
192+
if shortcut_ptr.is_null() || shortcut_len == 0 {
193+
return 0.0;
194+
}
195+
let query = unsafe {
196+
String::from_utf8_lossy(std::slice::from_raw_parts(shortcut_ptr, shortcut_len))
197+
}.to_lowercase();
198+
match REGISTRY.lock() {
199+
Ok(reg) => {
200+
for w in reg.iter() {
201+
if !w.shortcut.is_empty() && w.shortcut.to_lowercase() == query {
202+
return w.closure_f64;
203+
}
204+
}
205+
0.0
206+
}
207+
Err(_) => 0.0,
208+
}
209+
}
210+
211+
/// Queue a scroll action for main-thread dispatch.
212+
#[no_mangle]
213+
pub extern "C" fn perry_geisterhand_queue_scroll(handle: i64, x: f64, y: f64) {
214+
if let Ok(mut q) = PENDING_ACTIONS.lock() {
215+
q.push(PendingAction::ScrollTo { handle, x, y });
216+
}
217+
}
218+
130219
/// Queue a callback invocation for main-thread dispatch.
131220
#[no_mangle]
132221
pub extern "C" fn perry_geisterhand_queue_action(closure_f64: f64) {
@@ -225,6 +314,15 @@ pub extern "C" fn perry_geisterhand_pump() {
225314
}
226315
}
227316
}
317+
PendingAction::ScrollTo { handle, x, y } => {
318+
let f = UI_SCROLL_SET_FN.load(Ordering::Acquire);
319+
if !f.is_null() {
320+
unsafe {
321+
let func: extern "C" fn(i64, f64, f64) = std::mem::transmute(f);
322+
func(handle, x, y);
323+
}
324+
}
325+
}
228326
PendingAction::CaptureScreenshot => {
229327
let f = UI_SCREENSHOT_CAPTURE_FN.load(Ordering::Acquire);
230328
let (ptr, len) = if !f.is_null() {
@@ -261,10 +359,12 @@ pub extern "C" fn perry_geisterhand_get_registry_json(out_len: *mut usize) -> *m
261359
let mut s = String::from("[");
262360
for (i, w) in reg.iter().enumerate() {
263361
if i > 0 { s.push(','); }
362+
let escaped_label = w.label.replace('\\', "\\\\").replace('"', "\\\"");
363+
let escaped_shortcut = w.shortcut.replace('\\', "\\\\").replace('"', "\\\"");
264364
s.push_str(&format!(
265-
r#"{{"handle":{},"widget_type":{},"callback_kind":{},"label":"{}"}}"#,
365+
r#"{{"handle":{},"widget_type":{},"callback_kind":{},"label":"{}","shortcut":"{}"}}"#,
266366
w.handle, w.widget_type, w.callback_kind,
267-
w.label.replace('\\', "\\\\").replace('"', "\\\"")
367+
escaped_label, escaped_shortcut
268368
));
269369
}
270370
s.push(']');

crates/perry-ui-geisterhand/src/server.rs

Lines changed: 112 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
//! HTTP server for geisterhand.
22
//! Routes: /widgets, /click/:handle, /type/:handle, /slide/:handle,
3-
//! /toggle/:handle, /state/:handle, /key, /chaos/start, /chaos/stop, /chaos/status
3+
//! /toggle/:handle, /state/:handle, /key, /scroll/:handle, /chaos/start, /chaos/stop, /chaos/status
44
55
use tiny_http::{Server, Response, Header, Method};
66

@@ -12,6 +12,8 @@ extern "C" {
1212
fn perry_geisterhand_queue_action1(closure_f64: f64, arg: f64);
1313
fn perry_geisterhand_queue_state_set(handle: i64, value: f64);
1414
fn perry_geisterhand_request_screenshot(out_len: *mut usize) -> *mut u8;
15+
fn perry_geisterhand_find_by_shortcut(shortcut_ptr: *const u8, shortcut_len: usize) -> f64;
16+
fn perry_geisterhand_queue_scroll(handle: i64, x: f64, y: f64);
1517
}
1618

1719
// Callback kind constants (must match perry-runtime/src/geisterhand_registry.rs)
@@ -49,6 +51,34 @@ fn parse_handle(path: &str, prefix: &str) -> Option<i64> {
4951
rest.parse::<i64>().ok()
5052
}
5153

54+
/// Parse a query parameter value from a URL (e.g., "/widgets?label=Save" → Some("Save"))
55+
fn query_param<'a>(url: &'a str, key: &str) -> Option<&'a str> {
56+
let query = url.split('?').nth(1)?;
57+
let needle = format!("{}=", key);
58+
for pair in query.split('&') {
59+
if let Some(val) = pair.strip_prefix(&needle) {
60+
return Some(val);
61+
}
62+
}
63+
None
64+
}
65+
66+
/// Map widget type name to code
67+
fn widget_type_from_name(name: &str) -> Option<u8> {
68+
match name {
69+
"button" => Some(0),
70+
"textfield" | "text_field" => Some(1),
71+
"slider" => Some(2),
72+
"toggle" => Some(3),
73+
"picker" => Some(4),
74+
"menu" => Some(5),
75+
"shortcut" => Some(6),
76+
"table" => Some(7),
77+
"scrollview" | "scroll_view" => Some(8),
78+
_ => name.parse::<u8>().ok(),
79+
}
80+
}
81+
5282
/// Read request body as string
5383
fn read_body(request: &mut tiny_http::Request) -> String {
5484
let mut body = String::new();
@@ -67,7 +97,8 @@ pub fn run_server(port: u16) {
6797
};
6898

6999
for mut request in server.incoming_requests() {
70-
let path = request.url().to_string();
100+
let full_url = request.url().to_string();
101+
let path = full_url.split('?').next().unwrap_or(&full_url);
71102
let method = request.method().clone();
72103

73104
// Handle CORS preflight
@@ -80,8 +111,8 @@ pub fn run_server(port: u16) {
80111
continue;
81112
}
82113

83-
let response = match (method, path.as_str()) {
84-
// GET /widgets — list all registered widgets
114+
let response = match (method, path) {
115+
// GET /widgets — list all registered widgets (supports ?label= and ?type= filters)
85116
(Method::Get, "/widgets") => {
86117
let mut len: usize = 0;
87118
let ptr = unsafe { perry_geisterhand_get_registry_json(&mut len) };
@@ -92,7 +123,42 @@ pub fn run_server(port: u16) {
92123
} else {
93124
"[]".to_string()
94125
};
95-
ok_json(&json)
126+
127+
// Apply query param filters
128+
let label_filter = query_param(&full_url, "label");
129+
let type_filter = query_param(&full_url, "type")
130+
.and_then(|t| widget_type_from_name(t));
131+
132+
if label_filter.is_some() || type_filter.is_some() {
133+
if let Ok(arr) = serde_json::from_str::<Vec<serde_json::Value>>(&json) {
134+
let filtered: Vec<&serde_json::Value> = arr.iter().filter(|w| {
135+
if let Some(label) = label_filter {
136+
if let Some(wl) = w.get("label").and_then(|l| l.as_str()) {
137+
if !wl.to_lowercase().contains(&label.to_lowercase()) {
138+
return false;
139+
}
140+
} else {
141+
return false;
142+
}
143+
}
144+
if let Some(wt) = type_filter {
145+
if let Some(wt_val) = w.get("widget_type").and_then(|t| t.as_u64()) {
146+
if wt_val != wt as u64 {
147+
return false;
148+
}
149+
} else {
150+
return false;
151+
}
152+
}
153+
true
154+
}).collect();
155+
ok_json(&serde_json::to_string(&filtered).unwrap_or_else(|_| "[]".to_string()))
156+
} else {
157+
ok_json(&json)
158+
}
159+
} else {
160+
ok_json(&json)
161+
}
96162
}
97163

98164
// POST /click/:handle — fire onClick
@@ -293,6 +359,47 @@ pub fn run_server(port: u16) {
293359
}
294360
}
295361

362+
// POST /key — fire a keyboard shortcut by matching registered menu shortcuts
363+
(Method::Post, "/key") => {
364+
let body = read_body(&mut request);
365+
let shortcut = match serde_json::from_str::<serde_json::Value>(&body) {
366+
Ok(v) => v.get("shortcut").and_then(|s| s.as_str()).unwrap_or("").to_string(),
367+
Err(_) => body.trim().to_string(),
368+
};
369+
if shortcut.is_empty() {
370+
error_json(400, "missing shortcut field")
371+
} else {
372+
let closure = unsafe {
373+
perry_geisterhand_find_by_shortcut(shortcut.as_ptr(), shortcut.len())
374+
};
375+
if closure != 0.0 {
376+
unsafe { perry_geisterhand_queue_action(closure); }
377+
ok_json(r#"{"ok":true}"#)
378+
} else {
379+
error_json(404, "no registered shortcut matches")
380+
}
381+
}
382+
}
383+
384+
// POST /scroll/:handle — scroll a scrollview
385+
(Method::Post, p) if p.starts_with("/scroll/") => {
386+
match parse_handle(p, "/scroll/") {
387+
Some(handle) => {
388+
let body = read_body(&mut request);
389+
let (x, y) = match serde_json::from_str::<serde_json::Value>(&body) {
390+
Ok(v) => (
391+
v.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0),
392+
v.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0),
393+
),
394+
Err(_) => (0.0, 0.0),
395+
};
396+
unsafe { perry_geisterhand_queue_scroll(handle, x, y); }
397+
ok_json(r#"{"ok":true}"#)
398+
}
399+
None => error_json(400, "invalid handle"),
400+
}
401+
}
402+
296403
_ => error_json(404, "not found"),
297404
};
298405

crates/perry-ui-ios/src/app.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,11 +336,13 @@ pub fn app_run(_app_handle: i64) {
336336
f: extern "C" fn(*mut usize) -> *mut u8,
337337
);
338338
fn perry_geisterhand_register_textfield_set_string(f: extern "C" fn(i64, i64));
339+
fn perry_geisterhand_register_scroll_set(f: extern "C" fn(i64, f64, f64));
339340
}
340341
unsafe {
341342
perry_geisterhand_register_state_set(crate::perry_ui_state_set);
342343
perry_geisterhand_register_screenshot_capture(crate::screenshot::perry_ui_screenshot_capture);
343344
perry_geisterhand_register_textfield_set_string(crate::perry_ui_textfield_set_string);
345+
perry_geisterhand_register_scroll_set(crate::widgets::scrollview::perry_ui_scroll_set_offset);
344346
}
345347
}
346348

crates/perry-ui-ios/src/menu.rs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,11 @@ pub fn add_item(menu_handle: i64, title_ptr: *const u8, callback: f64) {
8383
});
8484
}
8585
});
86+
#[cfg(feature = "geisterhand")]
87+
{
88+
extern "C" { fn perry_geisterhand_register(h: i64, wt: u8, ck: u8, cb: f64, lbl: *const u8); }
89+
unsafe { perry_geisterhand_register(menu_handle, 5, 0, callback, title_ptr); }
90+
}
8691
}
8792

8893
/// Add an item with a keyboard shortcut (e.g. "Cmd+N").
@@ -101,10 +106,26 @@ pub fn add_item_with_shortcut(
101106
menus[idx].push(MenuItemEntry::Item {
102107
title,
103108
callback,
104-
shortcut: Some(shortcut),
109+
shortcut: Some(shortcut.clone()),
105110
});
106111
}
107112
});
113+
#[cfg(feature = "geisterhand")]
114+
{
115+
extern "C" {
116+
fn perry_geisterhand_register_with_shortcut(
117+
h: i64, wt: u8, ck: u8, cb: f64, lbl: *const u8,
118+
shortcut_ptr: *const u8, shortcut_len: usize,
119+
);
120+
}
121+
let shortcut_bytes = shortcut.as_bytes();
122+
unsafe {
123+
perry_geisterhand_register_with_shortcut(
124+
menu_handle, 5, 0, callback, title_ptr,
125+
shortcut_bytes.as_ptr(), shortcut_bytes.len(),
126+
);
127+
}
128+
}
108129
}
109130

110131
/// Remove all items from a menu.

crates/perry-ui-ios/src/widgets/button.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ pub fn create(label_ptr: *const u8, on_press: f64) -> i64 {
9595

9696
let ns_string = NSString::from_str(label);
9797
let _: () = msg_send![&*button, setTitle: &*ns_string, forState: 0u64]; // UIControlStateNormal = 0
98+
let _: () = msg_send![&*button, setAccessibilityLabel: &*ns_string];
9899

99100
let _: () = msg_send![&*button, setTranslatesAutoresizingMaskIntoConstraints: false];
100101

0 commit comments

Comments
 (0)