Skip to content

Commit d70ccab

Browse files
proggeramlugRalph Kuepper
andauthored
feat(ui): add native WheelPicker primitive (#8307)
* feat(ui): add native WheelPicker primitive (#5873) * chore(ui): pin WheelPicker's callback tables in the GC frontier ratchet The two new thread-local tables in the iOS and visionOS wheel_picker park a NaN-boxed on_change closure that no registered scanner reaches — the same shape as the 112 UI callback tables already pinned. Pin them deliberately so lint reflects the debt instead of hiding it, and add the changelog fragment. --------- Co-authored-by: Ralph Kuepper <ralph@skelpo.com>
1 parent 280aafe commit d70ccab

35 files changed

Lines changed: 968 additions & 2 deletions

File tree

android-build/app/src/main/java/com/perry/app/PerryBridge.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,15 @@ object PerryBridge {
350350
seekBar.progress = progress
351351
}
352352

353+
// --- NumberPicker (WheelPicker) callback ---
354+
355+
@JvmStatic
356+
fun setNumberPickerCallback(picker: NumberPicker, callbackKey: Long) {
357+
picker.setOnValueChangedListener { _, _, newValue ->
358+
nativeInvokeCallback1(callbackKey, newValue.toDouble())
359+
}
360+
}
361+
353362
// --- Context menu ---
354363

355364
@JvmStatic
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
### Added
2+
3+
- **`WheelPicker` — a native scrolling wheel/drum selector (#5873).** Renders a
4+
real platform wheel where one exists (`UIPickerView` on iOS/tvOS/visionOS,
5+
`NumberPicker` on Android) and a scroll-capable native control elsewhere
6+
(macOS, Windows, GTK4, watchOS), so the widget is available on every target
7+
rather than only the ones with a first-class drum control.
8+
9+
Dispatch is wired across all four backends — native, JS (`web_runtime.js`),
10+
WASM (`ui_method_map.rs`), and ArkTS — plus the shared UI dispatch table, HIR
11+
lowering, the API manifest, `perry.d.ts`, and the widget docs, so the same
12+
TypeScript source compiles on each.
13+
14+
Each platform's implementation parks its per-handle `on_change` closure in a
15+
thread-local `CALLBACKS` table, matching the existing `COMBOBOX_CALLBACKS`
16+
pattern that every other callback-bearing widget uses. Those tables are
17+
NaN-boxed-callback holders that no registered GC scanner reaches, so the two
18+
new ones (iOS, visionOS) are pinned in the `frontier` ratchet of
19+
`scripts/gc_runtime_root_holders.json` alongside the other 112 UI callback
20+
tables — deliberately, not silently: registering per-crate scanners over the
21+
whole `perry-ui*` tier remains the real fix and the ratchet may only shrink.
22+
The sibling `SELECTED` tables are pinned for the same shape reason but hold
23+
only row indices, never a heap value.

crates/perry-api-manifest/src/entries/part_4.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,10 @@ pub(crate) const API_MANIFEST_PART_4: &[ApiEntry] = &[
348348
method("perry/ui", "Slider", false, None),
349349
method("perry/ui", "ProgressView", false, None),
350350
method("perry/ui", "Picker", false, None),
351+
method("perry/ui", "WheelPicker", false, None),
352+
method("perry/ui", "wheelPickerAddItem", false, None),
353+
method("perry/ui", "wheelPickerGetSelected", false, None),
354+
method("perry/ui", "wheelPickerSetSelected", false, None),
351355
method("perry/ui", "ImageFile", false, None),
352356
method("perry/ui", "ImageSymbol", false, None),
353357
method("perry/ui", "loadImage", false, None),

crates/perry-codegen-arkts/src/emit_widget.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ pub(crate) fn emit_widget(
142142
mutations,
143143
),
144144
"Picker" => emit_picker(args, callbacks),
145+
"WheelPicker" => emit_wheel_picker(args, callbacks),
145146
// Issue #475 — Combobox(initial, onChange) maps to ArkUI
146147
// Select. Runtime-added items (`comboboxAddItem`) are
147148
// currently not folded into the static options array;

crates/perry-codegen-arkts/src/tests/containers.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -579,6 +579,21 @@ fn picker_with_options_and_closure() {
579579
assert_eq!(r.callbacks.len(), 1);
580580
}
581581

582+
#[test]
583+
fn wheel_picker_uses_native_looping_text_picker() {
584+
let mut m = empty_module();
585+
m.init
586+
.push(app_with_body(nmc("WheelPicker", vec![closure_stub()])));
587+
let r = emit_index_ets(&mut m).unwrap().unwrap();
588+
assert!(r
589+
.ets_source
590+
.contains("TextPicker({ range: [], value: '' }).canLoop(true)"));
591+
assert!(r
592+
.ets_source
593+
.contains("perryEntry.invokeCallback1(0, index)"));
594+
assert_eq!(r.callbacks.len(), 1);
595+
}
596+
582597
#[test]
583598
fn combobox_emits_arkui_select() {
584599
// Issue #475 — Combobox(initial, onChange) → Select with onSelect.

crates/perry-codegen-arkts/src/util.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ pub(crate) fn is_widget_factory(name: &str) -> bool {
209209
| "Toggle"
210210
| "Slider"
211211
| "Picker"
212+
| "WheelPicker"
212213
| "Combobox"
213214
| "RichTextEditor"
214215
| "Calendar"

crates/perry-codegen-arkts/src/widgets/inputs.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,27 @@ pub(crate) fn emit_picker(args: &[Expr], callbacks: &mut Vec<Expr>) -> String {
195195
)
196196
}
197197

198+
/// `WheelPicker(onChange)` -> ArkUI's native looping `TextPicker`.
199+
pub(crate) fn emit_wheel_picker(args: &[Expr], callbacks: &mut Vec<Expr>) -> String {
200+
let onchange = match args.first() {
201+
Some(closure @ Expr::Closure { .. }) => {
202+
let idx = callbacks.len();
203+
callbacks.push(closure.clone());
204+
format!(
205+
".onChange((_value: string, index: number) => {{\n \
206+
perryEntry.invokeCallback1({}, index);\n \
207+
{drain}\
208+
}})",
209+
idx,
210+
drain = drain_loop_body()
211+
)
212+
}
213+
_ => String::new(),
214+
};
215+
216+
format!("TextPicker({{ range: [], value: '' }}).canLoop(true){onchange}")
217+
}
218+
198219
/// Issue #475 — `Combobox(initial, onChange)` → ArkUI `Select([...])` with
199220
/// `.value()` / `.selected()` / `.onSelect()`. ArkUI's Select takes a
200221
/// `SelectOption[]` (each option is `{value: string}`); `.value()` sets

crates/perry-codegen-js/src/emit/calls.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,10 @@ impl JsEmitter {
152152
"ProgressView" | "progressview_create" => "perry_ui_progressview_create",
153153
"Image" | "image_create" => "perry_ui_image_create",
154154
"Picker" | "picker_create" => "perry_ui_picker_create",
155+
"WheelPicker" | "wheel_picker_create" => "perry_ui_wheel_picker_create",
156+
"wheelPickerAddItem" => "perry_ui_wheel_picker_add_item",
157+
"wheelPickerSetSelected" => "perry_ui_wheel_picker_set_selected",
158+
"wheelPickerGetSelected" => "perry_ui_wheel_picker_get_selected",
155159
// Table (issue #192)
156160
"Table" | "table_create" => "perry_ui_table_create",
157161
"tableSetColumnHeader" => "perry_ui_table_set_column_header",

crates/perry-codegen-js/src/web_runtime.js

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,20 @@ function perry_ui_picker_create(items_json, selected, callback) {
343343
return wrapWidget(allocHandle(el));
344344
}
345345

346+
// Issue #5873: persistent wheel/list selector. This follows the native
347+
// one-callback ABI and receives options through wheelPickerAddItem.
348+
function perry_ui_wheel_picker_create(callback) {
349+
const el = document.createElement("select");
350+
el.size = 5;
351+
el.className = "perry-wheel-picker";
352+
el.style.overflowY = "auto";
353+
el.style.scrollSnapType = "y mandatory";
354+
if (typeof callback === "function") {
355+
el.addEventListener("change", () => callback(el.selectedIndex));
356+
}
357+
return wrapWidget(allocHandle(el));
358+
}
359+
346360
function perry_ui_form_create() {
347361
const el = document.createElement("form");
348362
el.addEventListener("submit", e => e.preventDefault());
@@ -1503,6 +1517,26 @@ function perry_ui_picker_get_selected(h) {
15031517
return el ? el.selectedIndex : -1;
15041518
}
15051519

1520+
function perry_ui_wheel_picker_add_item(h, title) {
1521+
const el = getHandle(h);
1522+
if (!el) return;
1523+
const opt = document.createElement("option");
1524+
opt.value = el.children.length;
1525+
opt.textContent = title;
1526+
opt.style.scrollSnapAlign = "center";
1527+
el.appendChild(opt);
1528+
}
1529+
1530+
function perry_ui_wheel_picker_set_selected(h, index) {
1531+
const el = getHandle(h);
1532+
if (el) el.selectedIndex = index;
1533+
}
1534+
1535+
function perry_ui_wheel_picker_get_selected(h) {
1536+
const el = getHandle(h);
1537+
return el ? el.selectedIndex : -1;
1538+
}
1539+
15061540
// --- Image Operations ---
15071541
function perry_ui_image_create_symbol(name) {
15081542
const el = document.createElement("span");
@@ -3601,6 +3635,7 @@ window.__perry = {
36013635
perry_ui_progressview_create,
36023636
perry_ui_image_create,
36033637
perry_ui_picker_create,
3638+
perry_ui_wheel_picker_create,
36043639
perry_ui_form_create,
36053640
perry_ui_section_create,
36063641
perry_ui_navigationstack_create,
@@ -3686,6 +3721,9 @@ window.__perry = {
36863721
perry_ui_picker_add_item,
36873722
perry_ui_picker_set_selected,
36883723
perry_ui_picker_get_selected,
3724+
perry_ui_wheel_picker_add_item,
3725+
perry_ui_wheel_picker_set_selected,
3726+
perry_ui_wheel_picker_get_selected,
36893727
// Image
36903728
perry_ui_image_create_symbol,
36913729
perry_ui_image_create_url,

crates/perry-codegen-wasm/src/emit/ui_method_map.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ pub(super) fn map_ui_method(method: &str, class_name: Option<&str>) -> &'static
2222
"ProgressView" | "progressview_create" => "perry_ui_progressview_create",
2323
"Image" | "image_create" => "perry_ui_image_create",
2424
"Picker" | "picker_create" => "perry_ui_picker_create",
25+
"WheelPicker" | "wheel_picker_create" => "perry_ui_wheel_picker_create",
2526
"Form" | "form_create" => "perry_ui_form_create",
2627
"Section" | "section_create" => "perry_ui_section_create",
2728
"NavigationStack" | "navigationstack_create" => "perry_ui_navigationstack_create",
@@ -178,6 +179,9 @@ pub(super) fn map_ui_method(method: &str, class_name: Option<&str>) -> &'static
178179
"pickerAddItem" => "perry_ui_picker_add_item",
179180
"pickerSetSelected" => "perry_ui_picker_set_selected",
180181
"pickerGetSelected" => "perry_ui_picker_get_selected",
182+
"wheelPickerAddItem" => "perry_ui_wheel_picker_add_item",
183+
"wheelPickerSetSelected" => "perry_ui_wheel_picker_set_selected",
184+
"wheelPickerGetSelected" => "perry_ui_wheel_picker_get_selected",
181185
// Camera (issue #191) — Web has no live-camera FFI yet, so the
182186
// wasm_runtime.js stubs return 0 / -1. The dispatch entries here
183187
// exist so user code calling `CameraView()` from a browser build

0 commit comments

Comments
 (0)