pinready 0.27.3

Cross-platform configurator and launcher supporting the adoption of Visual Pinball 10.8.1 — for testers and early adopters, not a replacement for a stable production setup
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
use super::*;

// Row sizing for the manually-laid-out action list. The action/keyboard/button
// columns are fixed; the joystick column is elastic and absorbs the remaining
// window width, so the table spans the full page. Long bindings — SDL3 emits
// things like `SDLJoy_03004c8009120000eaea000011010000_1 ; Button 7` (≈ 50
// chars) — are truncated with an ellipsis rather than stretching the row, which
// kept the layout tidy no matter what value lands in the cell.
const COL_ACTION_WIDTH: f32 = 170.0;
const COL_KEYBOARD_WIDTH: f32 = 140.0;
/// Minimum for the elastic joystick column; it grows to fill the window.
const COL_JOYSTICK_MIN: f32 = 130.0;
const COL_BUTTON_WIDTH: f32 = 104.0;
const COL_SPACING: f32 = 8.0;
const ROW_HEIGHT: f32 = 28.0;
/// Width reserved inside each binding column for its × unmap button, so the
/// text stays aligned whether or not the button is shown.
const UNMAP_BTN_WIDTH: f32 = 22.0;
/// Smallest total list width (narrow window); above this the joystick column
/// takes all the extra space so the table reaches the page edge.
const LIST_MIN_WIDTH: f32 =
    COL_ACTION_WIDTH + COL_KEYBOARD_WIDTH + COL_JOYSTICK_MIN + COL_BUTTON_WIDTH + 3.0 * COL_SPACING;

impl App {
    pub(super) fn render_inputs_page(&mut self, ui: &mut egui::Ui) {
        theme::page_header(ui, &t!("inputs_heading"), Some(&t!("inputs_intro")));

        // Detected controllers info
        if self.pinscape_id.is_some() || self.gamepad_id.is_some() {
            theme::section(ui, &t!("inputs_section_devices"), None, |ui| {
                if self.pinscape_id.is_some() {
                    ui.label(t!("inputs_pinscape").to_string());
                    ui.horizontal(|ui| {
                        theme::field_label(ui, &t!("inputs_pinscape_profile"));
                        help_marker(ui, &t!("inputs_pinscape_profile_hint"));
                        let prev_profile = self.pinscape_profile;
                        // Product names, plus a localized "None" sentinel entry that
                        // clears the defaults and opts out of detection re-applying
                        // them.
                        let profile_label = |i: usize| -> String {
                            inputs::PINSCAPE_PROFILES
                                .get(i)
                                .map(|s| (*s).to_string())
                                .unwrap_or_else(|| t!("inputs_profile_none").to_string())
                        };
                        egui::ComboBox::from_id_salt("pinscape_profile")
                            .selected_text(profile_label(self.pinscape_profile))
                            .show_ui(ui, |ui| {
                                for i in 0..=inputs::PINSCAPE_PROFILE_NONE {
                                    ui.selectable_value(
                                        &mut self.pinscape_profile,
                                        i,
                                        profile_label(i),
                                    );
                                }
                            });
                        if self.pinscape_profile != prev_profile
                            && let Some(vpx_id) = self.pinscape_id.clone()
                        {
                            for action in &mut self.actions {
                                action.joystick = None;
                            }
                            self.apply_pinscape_defaults(&vpx_id);
                        }
                    });
                }
                if self.gamepad_id.is_some() {
                    ui.horizontal(|ui| {
                        ui.checkbox(&mut self.use_gamepad, t!("inputs_gamepad").to_string());
                        help_marker(ui, &t!("inputs_gamepad_hint"));
                    });
                }
            });
        }

        theme::section(
            ui,
            &t!("inputs_section_actions"),
            Some(&t!("inputs_instructions")),
            |ui| {
                // Auto-map controls — walks every action in turn. "Skip" leaves the
                // current action with no binding at all and moves to the next; "Cancel"
                // bails out entirely. (Escape is a mappable key, not a skip.)
                ui.horizontal(|ui| {
                    if !self.auto_map_active {
                        let p = theme::pal(ui.ctx());
                        for (device, label, hint) in [
                            (
                                AutoMapDevice::Keyboard,
                                t!("inputs_auto_map_keyboard"),
                                t!("inputs_auto_map_keyboard_hint"),
                            ),
                            (
                                AutoMapDevice::Joystick,
                                t!("inputs_auto_map_joystick"),
                                t!("inputs_auto_map_joystick_hint"),
                            ),
                        ] {
                            if ui
                                .add(theme::primary_button(label, &p, true))
                                .on_hover_text(hint)
                                .clicked()
                            {
                                self.auto_map_active = true;
                                self.auto_map_device = device;
                                if !self.actions.is_empty() {
                                    self.capture_state = CaptureState::Capturing(0);
                                }
                            }
                        }
                    } else {
                        ui.colored_label(
                            theme::pal(ui.ctx()).warning,
                            t!("inputs_auto_map_running"),
                        );
                        if ui
                            .button(t!("inputs_auto_map_skip"))
                            .on_hover_text(t!("inputs_auto_map_skip_hint"))
                            .clicked()
                        {
                            // Skipped = this pass's column left empty, then on to
                            // the next action. A key pass also drops the VPX
                            // default key; the other column is never touched.
                            if let CaptureState::Capturing(idx) = self.capture_state
                                && let Some(action) = self.actions.get_mut(idx)
                            {
                                match self.auto_map_device {
                                    AutoMapDevice::Keyboard => {
                                        action.keyboard = None;
                                        action.unmapped = true;
                                    }
                                    AutoMapDevice::Joystick => action.joystick = None,
                                }
                            }
                            self.advance_capture_or_finish();
                        }
                        if ui
                            .button(t!("inputs_auto_map_cancel"))
                            .on_hover_text(t!("inputs_auto_map_cancel_hint"))
                            .clicked()
                        {
                            self.auto_map_active = false;
                            self.capture_state = CaptureState::Idle;
                        }
                    }
                });
                ui.add_space(8.0);

                // Process keyboard input via egui (has window focus)
                if let CaptureState::Capturing(idx) = self.capture_state {
                    let modifiers = ui.input(|i| i.modifiers);
                    let mut captured_or_skipped = false;

                    let key_events: Vec<(egui::Key, Option<egui::Key>, bool)> = ui.input(|i| {
                        i.events
                            .iter()
                            .filter_map(|e| {
                                if let egui::Event::Key {
                                    key,
                                    physical_key,
                                    pressed,
                                    ..
                                } = e
                                {
                                    Some((*key, *physical_key, *pressed))
                                } else {
                                    None
                                }
                            })
                            .collect()
                    });
                    // A joystick pass ignores the keyboard entirely.
                    let keys_listen =
                        !self.auto_map_active || self.auto_map_device == AutoMapDevice::Keyboard;
                    for &(key, physical_key, pressed) in &key_events {
                        if keys_listen && pressed {
                            // Escape is a mappable key (VPX's default ExitGame) — it is
                            // captured like any other. Skipping an action is done only
                            // via the "Skip" button, never a key.
                            let sc = physical_key
                                .and_then(inputs::egui_key_to_scancode)
                                .or_else(|| inputs::egui_key_to_scancode(key));
                            if let Some(sc) = sc {
                                if idx < self.actions.len() {
                                    // A key fills the keyboard slot only — any
                                    // joystick binding stays alongside (VPX runs
                                    // both as alternatives).
                                    self.actions[idx].unmapped = false;
                                    self.actions[idx].keyboard = Some(CapturedInput::Keyboard {
                                        scancode: sc,
                                        name: inputs::scancode_name(sc),
                                    });
                                }
                                captured_or_skipped = true;
                                break;
                            }
                        }
                    }

                    // Modifier-only press fallback (Shift/Ctrl/Alt by themselves).
                    if keys_listen
                        && !captured_or_skipped
                        && (modifiers.shift || modifiers.ctrl || modifiers.alt)
                        && key_events.is_empty()
                        && let Some(sc) = inputs::egui_modifiers_to_scancode(&modifiers)
                    {
                        if idx < self.actions.len() {
                            self.actions[idx].unmapped = false;
                            self.actions[idx].keyboard = Some(CapturedInput::Keyboard {
                                scancode: sc,
                                name: inputs::scancode_name(sc),
                            });
                        }
                        captured_or_skipped = true;
                    }

                    if captured_or_skipped {
                        self.advance_capture_or_finish();
                    }

                    // Joystick events are processed in the main ui() method.

                    // Request repaint while capturing to stay responsive.
                    ui.ctx().request_repaint();
                }

                // Conflicts
                let conflicts = inputs::find_conflicts(&self.actions);

                // Single unified action list — no more "advanced" toggle.
                ui.add_space(4.0);
                self.render_action_list_unified(ui, &conflicts);
            },
        );
    }

    /// Move to the next action in auto-map mode, or fall back to Idle.
    /// Called after a successful capture or an Escape skip.
    pub(super) fn advance_capture_or_finish(&mut self) {
        if self.auto_map_active
            && let CaptureState::Capturing(idx) = self.capture_state
        {
            let next = idx + 1;
            if next < self.actions.len() {
                self.capture_state = CaptureState::Capturing(next);
                return;
            }
            self.auto_map_active = false;
        }
        self.capture_state = CaptureState::Idle;
    }

    fn render_action_list_unified(&mut self, ui: &mut egui::Ui, conflicts: &[(usize, usize)]) {
        // Span the full page width (down to a sensible minimum on narrow
        // windows). Tighter gap between cells — egui's default `item_spacing`
        // is 8 and our column widths already include their own padding.
        let width = ui.available_width().max(LIST_MIN_WIDTH);
        ui.allocate_ui_with_layout(
            egui::vec2(width, 0.0),
            egui::Layout::top_down(egui::Align::LEFT),
            |ui| {
                ui.spacing_mut().item_spacing = egui::vec2(COL_SPACING, 0.0);
                self.render_action_list_inner(ui, conflicts);
            },
        );
    }

    fn render_action_list_inner(&mut self, ui: &mut egui::Ui, conflicts: &[(usize, usize)]) {
        // Elastic joystick column: whatever width is left after the fixed
        // columns and gaps. Long bindings truncate inside it (see cell code).
        // Stripes and states from the palette: a 4K cabinet playfield
        // washes out subtle deltas, so the stripes stay well apart.
        let p = theme::pal(ui.ctx());
        let bg_even = p.raised;
        let bg_odd = p.surface;
        let bg_hover = p.tint(p.accent, 46);
        let bg_capturing = p.tint(p.info, 110);
        let bg_conflict = p.tint(p.warning, 90);
        let row_border = p.border;
        // The two named columns share the width with the joystick one, so a
        // key name like "Maj. gauche (défaut)" is not cut short.
        let avail = ui.available_width();
        let action_w = (avail * 0.28).max(COL_ACTION_WIDTH);
        let keyboard_w = (avail * 0.26).max(COL_KEYBOARD_WIDTH);
        let joystick_w = (avail - action_w - keyboard_w - COL_BUTTON_WIDTH - 3.0 * COL_SPACING)
            .max(COL_JOYSTICK_MIN);

        // Header row.
        ui.horizontal(|ui| {
            ui.add_sized(
                [action_w, ROW_HEIGHT],
                egui::Label::new(egui::RichText::new(t!("inputs_col_action")).strong()),
            );
            ui.add_sized(
                [keyboard_w, ROW_HEIGHT],
                egui::Label::new(egui::RichText::new(t!("inputs_col_keyboard")).strong()),
            );
            ui.add_sized(
                [joystick_w, ROW_HEIGHT],
                egui::Label::new(egui::RichText::new(t!("inputs_col_joystick")).strong()),
            );
            ui.add_sized([COL_BUTTON_WIDTH, ROW_HEIGHT], egui::Label::new(""));
        });

        let n = self.actions.len();
        for idx in 0..n {
            // Snapshot what we need from this action so we can take
            // `&mut self` later for the click handler.
            let label = self.actions[idx].label;
            let keyboard = self.actions[idx].keyboard.clone();
            let joystick = self.actions[idx].joystick.clone();
            let default_scancode = self.actions[idx].default_scancode;
            let unmapped = self.actions[idx].unmapped;
            let is_capturing = self.capture_state == CaptureState::Capturing(idx);
            let has_conflict = conflicts.iter().any(|(a, b)| *a == idx || *b == idx);

            // Reserve a paint slot before the row so we can fill the
            // background based on hover / capturing state once we know
            // the row's rect.
            let bg_idx = ui.painter().add(egui::Shape::Noop);

            let row_resp = ui
                .horizontal(|ui| {
                    ui.set_min_height(ROW_HEIGHT);

                    let action_text = if is_capturing {
                        egui::RichText::new(t!(label)).strong().color(p.text)
                    } else {
                        egui::RichText::new(t!(label))
                    };
                    ui.add_sized(
                        [action_w, ROW_HEIGHT],
                        egui::Label::new(action_text).truncate(),
                    );

                    // Keyboard column: custom key, else the VPX default.
                    // While capturing, the hint sits here (a key press fills
                    // this column, a joystick button the other one).
                    let keyboard_text = if is_capturing {
                        t!("inputs_capturing").to_string()
                    } else if let Some(ref captured) = keyboard {
                        captured.display_name().to_string()
                    } else if unmapped {
                        // Left without a key on purpose: not the VPX default.
                        t!("inputs_unassigned").to_string()
                    } else if default_scancode != sdl3_sys::everything::SDL_SCANCODE_UNKNOWN {
                        format!(
                            "{}{}",
                            inputs::scancode_name(default_scancode),
                            t!("inputs_default_suffix")
                        )
                    } else {
                        t!("inputs_unassigned").to_string()
                    };
                    let keyboard_rich = if is_capturing {
                        egui::RichText::new(keyboard_text).strong().color(p.text)
                    } else if has_conflict {
                        egui::RichText::new(format!("/!\\ {keyboard_text}"))
                            .color(egui::Color32::from_rgb(255, 165, 0))
                    } else {
                        egui::RichText::new(keyboard_text)
                    };
                    ui.add_sized(
                        [keyboard_w - UNMAP_BTN_WIDTH - COL_SPACING, ROW_HEIGHT],
                        egui::Label::new(keyboard_rich).truncate(),
                    );
                    // ✕ = drop the custom key, back to the VPX default.
                    // `add_visible` keeps the slot allocated when absent so
                    // the columns stay aligned.
                    if ui
                        .add_visible(
                            keyboard.is_some(),
                            egui::Button::new("×").min_size(egui::vec2(UNMAP_BTN_WIDTH, 0.0)),
                        )
                        .on_hover_text(t!("inputs_unmap_keyboard"))
                        .clicked()
                    {
                        self.actions[idx].keyboard = None;
                    }

                    // Joystick column: assigned button or em-dash. Lives
                    // alongside the keyboard binding — VPX runs both.
                    let joystick_text = if let Some(ref captured) = joystick {
                        captured.display_name().to_string()
                    } else {
                        "—".to_string()
                    };
                    let joystick_rich = if has_conflict {
                        egui::RichText::new(format!("/!\\ {joystick_text}"))
                            .color(egui::Color32::from_rgb(255, 165, 0))
                    } else if joystick.is_none() {
                        egui::RichText::new(joystick_text).weak()
                    } else {
                        egui::RichText::new(joystick_text)
                    };
                    ui.add_sized(
                        [joystick_w - UNMAP_BTN_WIDTH - COL_SPACING, ROW_HEIGHT],
                        egui::Label::new(joystick_rich).truncate(),
                    );
                    // ✕ = unassign the joystick button entirely.
                    if ui
                        .add_visible(
                            joystick.is_some(),
                            egui::Button::new("×").min_size(egui::vec2(UNMAP_BTN_WIDTH, 0.0)),
                        )
                        .on_hover_text(t!("inputs_unmap_joystick"))
                        .clicked()
                    {
                        self.actions[idx].joystick = None;
                    }

                    // Capture button.
                    let btn_label = if is_capturing {
                        t!("inputs_cancel").to_string()
                    } else {
                        t!("inputs_map").to_string()
                    };
                    let btn = egui::Button::new(btn_label)
                        .min_size(egui::vec2(COL_BUTTON_WIDTH, ROW_HEIGHT - 4.0));
                    if ui.add(btn).on_hover_text(t!("inputs_map_hint")).clicked() {
                        if is_capturing {
                            // Manual cancel — also bails out of auto-map.
                            self.auto_map_active = false;
                            self.capture_state = CaptureState::Idle;
                        } else {
                            self.auto_map_active = false;
                            self.capture_state = CaptureState::Capturing(idx);
                        }
                    }
                })
                .response;

            // Row background: capturing > conflict > hover > stripe,
            // plus a 1 px bottom rule between rows so the boundaries
            // are visible even on a glossy 4K playfield.
            let hovered = ui.rect_contains_pointer(row_resp.rect);
            let bg = if is_capturing {
                bg_capturing
            } else if has_conflict {
                bg_conflict
            } else if hovered {
                bg_hover
            } else if idx % 2 == 0 {
                bg_even
            } else {
                bg_odd
            };
            // Build a two-shape group: filled rect + bottom rule.
            // Using `Shape::Vec` keeps the single bg_idx slot we reserved.
            let group = vec![
                egui::Shape::rect_filled(row_resp.rect, 0.0, bg),
                egui::Shape::line_segment(
                    [
                        egui::pos2(row_resp.rect.left() + 2.0, row_resp.rect.bottom()),
                        egui::pos2(row_resp.rect.right() - 2.0, row_resp.rect.bottom()),
                    ],
                    egui::Stroke::new(1.0, row_border),
                ),
            ];
            ui.painter().set(bg_idx, egui::Shape::Vec(group));
        }
    }
}