indicatrix-cut 0.7.1

Desktop faceting-design editor: library browsing, spectral 3D rendering, material retargeting, and a solid inspection view.
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
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
//! Material-selection, quality (lighting preset/target samples/resolution/inclusion
//! scattering/pause/bounce count/exposure), and material-effect-override (crystal-axis
//! orientation, frosted girdle, edge rounding, physical stone size) callback wiring.
//!
//! Split out of `gui::mod` purely to keep that module (already sizeable) from growing
//! further -- same reasoning as `gui::detail`/`gui::search`/`gui::remote`.

use crate::{
    LibraryModel, MainWindow, SettingsModel, ViewportModel,
    bridge::{
        preview_render::material_ri_at_sodium_d,
        render_thread::{RenderContext, resolve_material},
    },
    gui::{
        is_c_axis_override_available, local_compute_target_from_index,
        local_preview_scale_from_index,
        optics::c_axis::{angles_to_c_axis, c_axis_to_angles},
        render::sample_scale::exponent_to_count,
        show_toast,
        tutorial_events::raise,
    },
    settings::{
        SettingsPersister,
        model::{head_shadow_deg_from_slider, surface_glare_from_percent},
    },
};
use indicatrix::optics::materials::GemMaterial;
use indicatrix_editor::guide::viewing_events as events;
use slint::{ComponentHandle, SharedString};
use std::sync::{Arc, Mutex};

/// Wires up the material-selection callback. Split out of
/// `setup_material_and_quality_callbacks` purely to keep that function under clippy's
/// function-length lint.
pub(in crate::gui) fn setup_material_changed_callback(
    ui: &MainWindow,
    render_ctx: &Arc<Mutex<RenderContext>>,
    settings_store: &Arc<SettingsPersister>,
) {
    let render_ctx_mat = render_ctx.clone();
    let settings_store_mat = settings_store.clone();
    let ui_weak_mat = ui.as_weak();
    ui.global::<ViewportModel>()
        .on_material_changed(move |material: SharedString| {
            let mut ctx = RenderContext::lock(&render_ctx_mat);
            ctx.material_name = material.to_string();
            // Cleared, not left alone: `resolve_material_with_override` PREFERS
            // `material_override` over the name just written, and
            // `editor::view::refresh_design_settings` sets one on every refresh while
            // "Linked to design" is on. Without this, picking a material here moved the
            // label, the toast and the persisted setting, and left the trace untouched
            // -- the dropdown looked like it worked and did nothing at all.
            ctx.material_override = None;
            // Same reasoning one step further: a design that names no material and
            // matches no built-in within tolerance suspends tracing outright
            // (`SuspensionFlags`, via `editor::view::traced_material_for`'s refusal).
            // Naming one by hand is exactly the way out of that, and the refusal cannot
            // be re-imposed now that the link below is off.
            ctx.material_unresolved = None;
            ctx.dirty = true;
            // Resolved once and reused for both derived UI values below -- the
            // crystal-axis-availability check and the RI-tolerance filter's "defaults to
            // the currently loaded material" default (`MainWindow::ri_material_default`).
            // Deliberately folded into this same handler rather than a second
            // `on_material_changed` registration: Slint only keeps the last handler
            // registered for a given callback, so a second one would silently replace it.
            // A dropdown selection always names something in `ViewportModel.
            // material_options` (built-ins + the current custom list), so this should
            // always resolve in practice -- but `resolve_material` is `Option` now
            // (no `materials[0]` fallback, see its own doc comment), so a `None` here
            // is handled the same defensive way `resolve_material_and_quality` does:
            // Diamond stands in for these two UI-default computations only (crystal-
            // axis availability, the RI filter's starting point), never for anything
            // that reaches the tracer.
            let resolved = resolve_material(
                &GemMaterial::all_materials(),
                &ctx.custom_materials,
                &material,
            );
            let available = resolved.as_ref().is_some_and(is_c_axis_override_available);
            let ri_default = resolved.as_ref().map_or_else(
                || material_ri_at_sodium_d(&GemMaterial::diamond()),
                material_ri_at_sodium_d,
            );
            drop(ctx);
            settings_store_mat.update(|s| s.settings.selected_material = material.to_string());
            if let Some(ui) = ui_weak_mat.upgrade() {
                // Picking a material by hand IS the independent choice "Linked to
                // design" exists to suppress (see `ViewportModel::
                // viewport_material_linked`'s own doc comment). Left on, the next
                // editor refresh would put the design's own material straight back --
                // and write a fresh `material_override` with it, making this dropdown
                // inert again. Turning it off is also what makes the pick durable:
                // `refresh_design_settings` touches none of the three fields above
                // while unlinked. The link's own handler already re-syncs on the way
                // back ON, so nothing is lost by switching it off here.
                let was_linked = ui.global::<ViewportModel>().get_viewport_material_linked();
                if was_linked {
                    ui.global::<ViewportModel>()
                        .set_viewport_material_linked(false);
                }
                ui.global::<SettingsModel>()
                    .set_c_axis_override_available(available);
                // `ri_default` is f64; Slint's `float` property type is f32 -- a lossy
                // but harmless cast for a display/filter-default value in the 1.0-3.0 RI
                // range.
                ui.global::<LibraryModel>()
                    .set_ri_material_default(ri_default as f32);
                let message = if was_linked {
                    format!("Material switched to {material} \u{2014} unlinked from the design.")
                } else {
                    format!("Material switched to {material}")
                };
                show_toast(&ui, &message, "info");
            }
        });
}

/// Wires up lighting-preset/target-samples/render-resolution/inclusion-scattering
/// changes, pause/tab-visibility, bounce count, and exposure callbacks. Each of the
/// persisted settings also feeds the debounced `settings_store`. Split out of
/// `run_gui` purely to keep that function under clippy's function-length lint.
pub(in crate::gui) fn setup_material_and_quality_callbacks(
    ui: &MainWindow,
    render_ctx: &Arc<Mutex<RenderContext>>,
    settings_store: &Arc<SettingsPersister>,
) {
    // The UI carries the lighting preset's enum discriminant as a plain `int`;
    // `LightingPreset::from_index` maps it back to the enum here, the one boundary
    // crossing from "UI index" to "physics enum", and `.label()` is what's actually
    // persisted/toasted as text.
    let render_ctx_lit = render_ctx.clone();
    let settings_store_lit = settings_store.clone();
    let ui_weak_lit = ui.as_weak();
    ui.global::<ViewportModel>()
        .on_lighting_changed(move |idx: i32| {
            let preset = crate::gui::optics::offered_lighting::offered_from_index(idx);
            let mut ctx = RenderContext::lock(&render_ctx_lit);
            ctx.lighting_preset = preset;
            ctx.dirty = true;
            drop(ctx);
            settings_store_lit.update(|s| s.settings.lighting_rig = preset.label().to_string());
            if let Some(ui) = ui_weak_lit.upgrade() {
                show_toast(&ui, &format!("Lighting preset: {}", preset.label()), "info");
                // A tutorial step may wait for a lighting preset to be chosen.
                raise(&ui, events::LIGHTING_PRESET_CHOSEN);
            }
        });

    // Target Samples slider. Carries the slider's exponent (3..=10);
    // `exponent_to_count` is the one boundary crossing from "slider position" to "the
    // actual sample count", which is what's stored/persisted/rendered.
    let render_ctx_samples = render_ctx.clone();
    let settings_store_samples = settings_store.clone();
    let ui_weak_samples = ui.as_weak();
    ui.global::<SettingsModel>()
        .on_target_samples_changed(move |exponent: i32| {
            let target_samples = exponent_to_count(u32::try_from(exponent).unwrap_or(0));
            let mut ctx = RenderContext::lock(&render_ctx_samples);
            ctx.target_samples = target_samples;
            ctx.dirty = true;
            drop(ctx);
            settings_store_samples.update(|s| s.settings.target_samples = target_samples);
            if let Some(ui) = ui_weak_samples.upgrade() {
                show_toast(&ui, &format!("Target samples: {target_samples}"), "info");
            }
        });

    // Render Resolution pill selector: carries the resolved (width, height) pair
    // directly, unlike the samples slider above. Setting `ctx.width`/`.height` is all
    // that's needed to reset progressive accumulation cleanly: the render loop's
    // `update_accumulation_state` already reallocates the accumulation buffer, the
    // denoiser guide buffers, and `FramebufferTransfer` whenever it sees `width`/
    // `height` differ on the next frame -- `ctx.dirty = true` here is the same
    // belt-and-suspenders every other setting in this function sets, not load-bearing.
    let render_ctx_res = render_ctx.clone();
    let settings_store_res = settings_store.clone();
    let ui_weak_res = ui.as_weak();
    ui.global::<SettingsModel>()
        .on_resolution_changed(move |width: i32, height: i32| {
            let (width, height) = (width as u32, height as u32);
            let mut ctx = RenderContext::lock(&render_ctx_res);
            ctx.width = width;
            ctx.height = height;
            ctx.dirty = true;
            drop(ctx);
            settings_store_res.update(|s| {
                s.settings.render_width = width;
                s.settings.render_height = height;
            });
            if let Some(ui) = ui_weak_res.upgrade() {
                show_toast(&ui, &format!("Render resolution: {width}x{height}"), "info");
            }
        });

    setup_local_render_path_callbacks(ui, render_ctx, settings_store);

    // Inclusion/subsurface scattering amount. Linear, unlike the samples slider
    // above -- `scattering_sigma_s`'s doc comment gives the 0.0-3.0 useful range
    // directly, so there's no perceptual remapping to invert here.
    let render_ctx_inc = render_ctx.clone();
    let settings_store_inc = settings_store.clone();
    ui.global::<SettingsModel>()
        .on_inclusion_changed(move |sigma_s: f32| {
            let clamped = sigma_s.clamp(0.0, 3.0);
            let mut ctx = RenderContext::lock(&render_ctx_inc);
            ctx.inclusion_sigma_s = clamped;
            ctx.dirty = true;
            drop(ctx);
            settings_store_inc.update(|s| s.settings.inclusion_sigma_s = clamped);
        });

    // Render Pause / Resume -- explicit user intent.
    // Independent of the tab-visibility auto-suspend: this is the one the button reflects.
    let render_ctx_pause = render_ctx.clone();
    ui.global::<ViewportModel>()
        .on_pause_toggled(move |paused: bool| {
            let mut ctx = RenderContext::lock(&render_ctx_pause);
            ctx.paused = paused;
        });

    // Automatic render suspend when the rendered image isn't visible anywhere is driven
    // by `RenderContext::tab_visible`, recomputed by `gui::render::render_visibility::
    // recompute_tab_visible` from all the signals `render_is_visible` combines (outer
    // tab, inner sub-tab, each sub-tab's own Solid/Path-traced view-mode toggle, and
    // the Retarget dialog) -- see that function's own doc comment for the full list of
    // call sites. That path must never touch `ctx.paused`: a manual pause must survive
    // switching tabs (or flipping a view mode) away and back.

    let render_ctx_bnc = render_ctx.clone();
    let settings_store_bnc = settings_store.clone();
    ui.global::<SettingsModel>()
        .on_bounces_changed(move |bounces: i32| {
            let clamped = (bounces as u32).max(1);
            let mut ctx = RenderContext::lock(&render_ctx_bnc);
            ctx.max_bounces = clamped;
            ctx.dirty = true;
            drop(ctx);
            settings_store_bnc.update(|s| s.settings.max_bounces = clamped);
        });

    let render_ctx_exp = render_ctx.clone();
    let settings_store_exp = settings_store.clone();
    ui.global::<SettingsModel>()
        .on_exposure_changed(move |exposure: f32| {
            let clamped = exposure.clamp(0.2, 5.0);
            let mut ctx = RenderContext::lock(&render_ctx_exp);
            ctx.exposure = clamped;
            ctx.dirty = true;
            drop(ctx);
            settings_store_exp.update(|s| s.settings.exposure = clamped);
        });

    // The settings dialog's "Some advanced settings are in use" note (Simple mode).
    super::advanced_in_use::setup_advanced_in_use_callback(ui);
}

/// Wires up the two local render-path controls: the preview-then-settle resolution
/// reduction and the CPU/GPU compute target. Split out of
/// `setup_material_and_quality_callbacks` purely to keep that function under clippy's
/// function-length lint.
///
/// What these two share, and what makes them the natural seam to cut on: neither sets
/// `ctx.dirty` and neither raises a toast, because neither changes what is already on
/// screen -- they only change how the next frame (or the next drag) is traced.
fn setup_local_render_path_callbacks(
    ui: &MainWindow,
    render_ctx: &Arc<Mutex<RenderContext>>,
    settings_store: &Arc<SettingsPersister>,
) {
    // Local preview-then-settle rendering: optional resolution reduction while the
    // camera is moving -- see `RenderContext::local_preview_scale`'s doc comment for
    // the mechanism. `Off` (index 0, the default) reproduces this crate's
    // pre-existing behaviour exactly. No `ctx.dirty`/toast: this alone never changes
    // what's on screen right now, only whether the next drag renders reduced.
    let render_ctx_preview = render_ctx.clone();
    let settings_store_preview = settings_store.clone();
    ui.global::<SettingsModel>()
        .on_local_preview_scale_changed(move |index: i32| {
            let scale = local_preview_scale_from_index(index);
            RenderContext::lock(&render_ctx_preview).local_preview_scale = scale;
            settings_store_preview.update(|s| s.settings.local_preview_scale = scale);
        });

    // Local Compute: which engine(s) the local (non-remote) render loop uses -- see
    // `RenderContext::local_compute_target`'s doc comment. Live-updates `RenderContext`
    // (read fresh by the render loop every frame) in addition to persisting the
    // choice. No `ctx.dirty`/toast: this only changes how the next frame is traced,
    // never what's already accumulated -- switching engines mid-render continues the
    // same running average rather than restarting it.
    let render_ctx_local_compute = render_ctx.clone();
    let settings_store_local_compute = settings_store.clone();
    ui.global::<SettingsModel>()
        .on_local_compute_target_changed(move |index: i32| {
            let target = local_compute_target_from_index(index);
            RenderContext::lock(&render_ctx_local_compute).local_compute_target = target;
            settings_store_local_compute.update(|s| s.settings.local_compute_target = target);
        });
}

/// Wires up the crystal-axis orientation override, the frosted-girdle toggle, the
/// edge-rounding slider, and the physical stone-size control. Split out of
/// `setup_material_and_quality_callbacks` purely to keep that function under clippy's
/// function-length lint.
pub(in crate::gui) fn setup_material_effect_override_callbacks(
    ui: &MainWindow,
    render_ctx: &Arc<Mutex<RenderContext>>,
    settings_store: &Arc<SettingsPersister>,
) {
    // Crystal-axis orientation override. One callback carries both angles -- the
    // moved slider's new value plus the other slider's current value, read directly
    // off `root.*` in `settings_dialog.slint` -- so a single-axis drag never has to
    // guess where the other axis currently sits. `angles_to_c_axis` is the one
    // boundary crossing from these degree sliders to the physical `Vec3`
    // `RenderContext::c_axis_override` stores.
    let render_ctx_axis_angles = render_ctx.clone();
    let settings_store_axis_angles = settings_store.clone();
    ui.global::<SettingsModel>().on_c_axis_angles_changed(
        move |tilt_deg: f32, azimuth_deg: f32| {
            let tilt_deg = tilt_deg.clamp(0.0, 90.0);
            let azimuth_deg = azimuth_deg.clamp(0.0, 360.0);
            let mut ctx = RenderContext::lock(&render_ctx_axis_angles);
            ctx.c_axis_override = Some(angles_to_c_axis(tilt_deg, azimuth_deg));
            ctx.dirty = true;
            drop(ctx);
            settings_store_axis_angles.update(|s| {
                s.settings.c_axis_tilt_deg = tilt_deg;
                s.settings.c_axis_azimuth_deg = azimuth_deg;
            });
        },
    );

    // Crystal-axis override on/off switch. Off ("as cut", the default) leaves the
    // resolved material's own `c_axis` untouched. Turning it on seeds the two angle
    // sliders from the currently selected material's own `c_axis` via
    // `c_axis_to_angles` (the inverse of `angles_to_c_axis` above), so enabling the
    // override never makes the stone visibly jump.
    let render_ctx_axis_toggle = render_ctx.clone();
    let settings_store_axis_toggle = settings_store.clone();
    let ui_weak_axis_toggle = ui.as_weak();
    ui.global::<SettingsModel>()
        .on_c_axis_override_changed(move |enabled: bool| {
            let mut ctx = RenderContext::lock(&render_ctx_axis_toggle);
            if enabled {
                // The checkbox this wires is only enabled in the UI while
                // `is_c_axis_override_available` already confirmed the CURRENT
                // material resolves and supports one, so this should always resolve
                // here too -- but `resolve_material` is `Option` (no `materials[0]`
                // fallback, see its own doc comment), so a `None` still falls back to
                // Diamond's own (identity) axis rather than panicking, matching the
                // "Diamond stands in for a UI-default computation, never the tracer"
                // convention this module already uses elsewhere.
                let base = resolve_material(
                    &GemMaterial::all_materials(),
                    &ctx.custom_materials,
                    &ctx.material_name,
                )
                .unwrap_or_else(GemMaterial::diamond);
                let (tilt_deg, azimuth_deg) = c_axis_to_angles(base.c_axis);
                ctx.c_axis_override = Some(angles_to_c_axis(tilt_deg, azimuth_deg));
                ctx.dirty = true;
                drop(ctx);
                settings_store_axis_toggle.update(|s| {
                    s.settings.c_axis_override_enabled = true;
                    s.settings.c_axis_tilt_deg = tilt_deg;
                    s.settings.c_axis_azimuth_deg = azimuth_deg;
                });
                if let Some(ui) = ui_weak_axis_toggle.upgrade() {
                    ui.global::<SettingsModel>().set_c_axis_tilt_deg(tilt_deg);
                    ui.global::<SettingsModel>()
                        .set_c_axis_azimuth_deg(azimuth_deg);
                }
            } else {
                ctx.c_axis_override = None;
                ctx.dirty = true;
                drop(ctx);
                settings_store_axis_toggle.update(|s| s.settings.c_axis_override_enabled = false);
            }
        });

    // Bruted (frosted) girdle finish toggle -- a plain on/off switch, not a slider.
    // See `RenderContext::girdle_frosted`'s doc comment.
    let render_ctx_girdle = render_ctx.clone();
    let settings_store_girdle = settings_store.clone();
    ui.global::<SettingsModel>()
        .on_girdle_frosted_changed(move |frosted: bool| {
            let mut ctx = RenderContext::lock(&render_ctx_girdle);
            ctx.girdle_frosted = frosted;
            ctx.dirty = true;
            drop(ctx);
            settings_store_girdle.update(|s| s.settings.girdle_frosted = frosted);
        });

    // Facet edge rounding radius, same opt-in-linear treatment as the inclusion
    // slider -- see `RenderContext::edge_rounding_radius`'s doc comment for the
    // `0.0`-`0.03` range's sourcing.
    let render_ctx_edge = render_ctx.clone();
    let settings_store_edge = settings_store.clone();
    ui.global::<SettingsModel>()
        .on_edge_rounding_changed(move |radius: f32| {
            let clamped = radius.clamp(0.0, 0.03);
            let mut ctx = RenderContext::lock(&render_ctx_edge);
            ctx.edge_rounding_radius = clamped;
            ctx.dirty = true;
            drop(ctx);
            settings_store_edge.update(|s| s.settings.edge_rounding_radius = clamped);
        });

    // Physical stone size: girdle width in millimetres, off ("today's look",
    // unscaled) at 0.0. No upper clamp beyond staying non-negative -- this is a
    // free-typed measurement (a settings-dialog spin box) rather than a bounded
    // slider; `apply_material_overrides` guards the resulting scale against
    // non-finite/non-positive results regardless.
    let render_ctx_stone = render_ctx.clone();
    let settings_store_stone = settings_store.clone();
    ui.global::<SettingsModel>()
        .on_stone_width_changed(move |width_mm: f32| {
            let clamped = width_mm.max(0.0);
            let mut ctx = RenderContext::lock(&render_ctx_stone);
            ctx.stone_width_mm = clamped;
            ctx.dirty = true;
            drop(ctx);
            settings_store_stone.update(|s| s.settings.stone_width_mm = clamped);
        });
}

/// Wires the surface-glare slider: the scale of the white mirror image of the light in
/// the live view and every export (`RenderContext::surface_glare`, persisted as
/// `AppSettings::surface_glare`). The slider reports a percent; a drag that stays on the
/// same 5 % step changes nothing and so never restarts accumulation.
pub(in crate::gui) fn setup_surface_glare_callback(
    ui: &MainWindow,
    render_ctx: &Arc<Mutex<RenderContext>>,
    settings_store: &Arc<SettingsPersister>,
) {
    let render_ctx = render_ctx.clone();
    let settings_store = settings_store.clone();
    ui.global::<SettingsModel>()
        .on_surface_glare_changed(move |percent: f32| {
            let glare = surface_glare_from_percent(percent);
            let mut ctx = RenderContext::lock(&render_ctx);
            if ctx.surface_glare.to_bits() == glare.to_bits() {
                return;
            }
            ctx.surface_glare = glare;
            ctx.dirty = true;
            drop(ctx);
            settings_store.update(|s| s.settings.surface_glare = glare);
        });
}

/// Wires the head-shadow slider: the angular radius of the viewer's head shadow on the
/// lit lighting presets, in the live view and every export
/// (`RenderContext::head_shadow_deg`, persisted as `AppSettings::head_shadow_deg`). The
/// slider reports whole degrees; a drag that stays on the same degree changes nothing and
/// so never restarts accumulation.
pub(in crate::gui) fn setup_head_shadow_callback(
    ui: &MainWindow,
    render_ctx: &Arc<Mutex<RenderContext>>,
    settings_store: &Arc<SettingsPersister>,
) {
    let render_ctx = render_ctx.clone();
    let settings_store = settings_store.clone();
    ui.global::<SettingsModel>()
        .on_head_shadow_changed(move |degrees: f32| {
            let deg = head_shadow_deg_from_slider(degrees);
            let mut ctx = RenderContext::lock(&render_ctx);
            if ctx.head_shadow_deg.to_bits() == deg.to_bits() {
                return;
            }
            ctx.head_shadow_deg = deg;
            ctx.dirty = true;
            drop(ctx);
            settings_store.update(|s| s.settings.head_shadow_deg = deg);
        });
}

/// Wires the backdrop pill row: what the camera sees behind the stone, in the live
/// view and every export (`RenderContext::backdrop`, persisted as
/// `AppSettings::backdrop`).
pub(in crate::gui) fn setup_backdrop_callback(
    ui: &MainWindow,
    render_ctx: &Arc<Mutex<RenderContext>>,
    settings_store: &Arc<SettingsPersister>,
) {
    let render_ctx = render_ctx.clone();
    let settings_store = settings_store.clone();
    ui.global::<SettingsModel>()
        .on_backdrop_changed(move |index: i32| {
            let backdrop = crate::settings::model::Backdrop::from_index(index);
            let mut ctx = RenderContext::lock(&render_ctx);
            ctx.backdrop = backdrop;
            ctx.dirty = true;
            drop(ctx);
            settings_store.update(|s| s.settings.backdrop = backdrop);
        });
}