Skip to main content

siglus_scene_vm/
desktop_config.rs

1//! Built-in configuration window, following Siglus cfg_wnd_config_base and cfg_wnd_func_*.
2//! Gameexe controls tab visibility, channel visibility, labels, and game-specific switches.
3
4use crate::formats::gameexe::GameexeConfig;
5use crate::render::Renderer;
6use crate::runtime::CommandContext;
7use crate::runtime::forms::syscom;
8use crate::runtime::globals::OriginalConfigRuntimeState;
9use anyhow::{Context, Result};
10use egui_wgpu::{Renderer as EguiRenderer, ScreenDescriptor};
11use std::sync::Arc;
12use std::time::Instant;
13use winit::dpi::{LogicalPosition, LogicalSize};
14use winit::event::{ElementState, Ime, KeyEvent, MouseButton, MouseScrollDelta, WindowEvent};
15use winit::event_loop::ActiveEventLoop;
16use winit::keyboard::{KeyCode, ModifiersState, PhysicalKey};
17use winit::window::{Window, WindowAttributes, WindowId};
18
19fn configure_egui_default_font(ctx: &egui::Context) {
20    let mut fonts = egui::FontDefinitions::default();
21    fonts.font_data.insert(
22        "siglus_default".to_string(),
23        egui::FontData::from_static(crate::text_render::DEFAULT_FONT_BYTES),
24    );
25    fonts
26        .families
27        .entry(egui::FontFamily::Proportional)
28        .or_default()
29        .insert(0, "siglus_default".to_string());
30    fonts
31        .families
32        .entry(egui::FontFamily::Monospace)
33        .or_default()
34        .insert(0, "siglus_default".to_string());
35    ctx.set_fonts(fonts);
36}
37
38pub enum DesktopConfigAction {
39    Changed,
40    Close,
41}
42
43pub struct DesktopConfigWindow {
44    window: Arc<dyn Window>,
45    window_id: WindowId,
46    renderer: Renderer,
47    egui_renderer: EguiRenderer,
48    egui_ctx: egui::Context,
49    start_time: Instant,
50    input_events: Vec<egui::Event>,
51    modifiers: egui::Modifiers,
52    pointer_pos: egui::Pos2,
53    pub dialog: ConfigDialog,
54}
55
56impl DesktopConfigWindow {
57    pub fn new(elwt: &dyn ActiveEventLoop, dialog: ConfigDialog) -> Result<Self> {
58        let window = elwt
59            .create_window(
60                WindowAttributes::default()
61                    .with_title("環境設定")
62                    .with_surface_size(LogicalSize::new(760.0, 570.0))
63                    .with_min_surface_size(LogicalSize::new(640.0, 440.0)),
64            )
65            .context("create configuration window")?;
66        let window: Arc<dyn Window> = Arc::from(window);
67        crate::ime::enable_ime(
68            window.as_ref(),
69            LogicalPosition::new(0, 0).into(),
70            LogicalSize::new(0, 0).into(),
71        );
72        let renderer =
73            pollster::block_on(Renderer::new(window.clone())).context("config renderer init")?;
74        let egui_renderer = EguiRenderer::new(&renderer.device, renderer.config.format, None, 1);
75        let egui_ctx = egui::Context::default();
76        configure_egui_default_font(&egui_ctx);
77        egui_ctx.set_visuals(egui::Visuals::light());
78        window.request_redraw();
79        Ok(Self {
80            window_id: window.id(),
81            window,
82            renderer,
83            egui_renderer,
84            egui_ctx,
85            start_time: Instant::now(),
86            input_events: Vec::new(),
87            modifiers: egui::Modifiers::default(),
88            pointer_pos: egui::Pos2::ZERO,
89            dialog,
90        })
91    }
92    pub fn window_id(&self) -> WindowId {
93        self.window_id
94    }
95    pub fn into_dialog(self) -> ConfigDialog {
96        // Dropping the renderer releases the surface's Arc, then the native
97        // window closes even on backends that cannot hide windows (Wayland).
98        self.dialog
99    }
100    pub fn handle_window_event(&mut self, event: WindowEvent) -> Option<DesktopConfigAction> {
101        match event {
102            WindowEvent::CloseRequested => return Some(DesktopConfigAction::Close),
103            WindowEvent::SurfaceResized(size) => {
104                self.renderer.resize(size.width.max(1), size.height.max(1));
105                self.window.request_redraw();
106            }
107            WindowEvent::ModifiersChanged(modifiers) => {
108                self.modifiers = map_modifiers(modifiers.state());
109            }
110            WindowEvent::PointerMoved {
111                position,
112                primary: true,
113                ..
114            }
115            | WindowEvent::PointerEntered {
116                position,
117                primary: true,
118                ..
119            } => {
120                let logical = position.to_logical::<f64>(self.window.scale_factor());
121                self.pointer_pos = egui::pos2(logical.x as f32, logical.y as f32);
122                self.input_events
123                    .push(egui::Event::PointerMoved(self.pointer_pos));
124                self.window.request_redraw();
125            }
126            WindowEvent::PointerLeft { primary: true, .. } => {
127                self.input_events.push(egui::Event::PointerGone);
128                self.window.request_redraw();
129            }
130            WindowEvent::PointerButton {
131                state,
132                button,
133                position,
134                primary: true,
135                ..
136            } => {
137                let button = button.mouse_button()?;
138                let logical = position.to_logical::<f64>(self.window.scale_factor());
139                self.pointer_pos = egui::pos2(logical.x as f32, logical.y as f32);
140                if let Some(button) = map_pointer_button(button) {
141                    self.input_events.push(egui::Event::PointerButton {
142                        pos: self.pointer_pos,
143                        button,
144                        pressed: state == ElementState::Pressed,
145                        modifiers: self.modifiers,
146                    });
147                    self.window.request_redraw();
148                }
149            }
150            WindowEvent::MouseWheel { delta, .. } => {
151                let delta = match delta {
152                    MouseScrollDelta::LineDelta(x, y) => egui::vec2(x * 24.0, y * 24.0),
153                    MouseScrollDelta::PixelDelta(pos) => egui::vec2(pos.x as f32, pos.y as f32),
154                    _ => return None,
155                };
156                self.input_events.push(egui::Event::MouseWheel {
157                    unit: egui::MouseWheelUnit::Point,
158                    delta,
159                    modifiers: self.modifiers,
160                });
161                self.window.request_redraw();
162            }
163            WindowEvent::Ime(ime) => {
164                let event = match ime {
165                    Ime::Enabled => egui::ImeEvent::Enabled,
166                    Ime::Preedit(text, _) => egui::ImeEvent::Preedit(text),
167                    Ime::Commit(text) => egui::ImeEvent::Commit(text),
168                    Ime::Disabled => egui::ImeEvent::Disabled,
169                    _ => return None,
170                };
171                self.input_events.push(egui::Event::Ime(event));
172                self.window.request_redraw();
173            }
174            #[cfg(target_os = "windows")]
175            WindowEvent::KeyboardInput {
176                event:
177                    KeyEvent {
178                        logical_key: winit::keyboard::Key::Named(winit::keyboard::NamedKey::Process),
179                        ..
180                    },
181                ..
182            } => return None,
183            WindowEvent::KeyboardInput { event, .. } => {
184                if let PhysicalKey::Code(code) = event.physical_key
185                    && let Some(key) = map_key(code)
186                {
187                    self.input_events.push(egui::Event::Key {
188                        key,
189                        physical_key: Some(key),
190                        pressed: event.state == ElementState::Pressed,
191                        repeat: event.repeat,
192                        modifiers: self.modifiers,
193                    });
194                }
195                if let KeyEvent {
196                    state: ElementState::Pressed,
197                    text: Some(text),
198                    ..
199                } = event
200                    && !self.modifiers.command
201                {
202                    let text = text.to_string();
203                    if !text.is_empty() && !text.chars().all(char::is_control) {
204                        self.input_events.push(egui::Event::Text(text));
205                    }
206                }
207                self.window.request_redraw();
208            }
209            WindowEvent::RedrawRequested => match self.render() {
210                Ok(action) => return action,
211                Err(err) => log::error!("desktop config render failed: {err:#}"),
212            },
213            _ => {}
214        }
215        None
216    }
217
218    fn render(&mut self) -> Result<Option<DesktopConfigAction>> {
219        let size = self.window.surface_size();
220        if size.width == 0 || size.height == 0 {
221            return Ok(None);
222        }
223        let scale = self.window.scale_factor() as f32;
224        self.egui_ctx.set_pixels_per_point(scale);
225        let logical_w = size.width as f32 / scale.max(1.0);
226        let logical_h = size.height as f32 / scale.max(1.0);
227        let events = std::mem::take(&mut self.input_events);
228        let modifiers = self.modifiers;
229        let raw_input = egui::RawInput {
230            screen_rect: Some(egui::Rect::from_min_size(
231                egui::Pos2::ZERO,
232                egui::vec2(logical_w, logical_h),
233            )),
234            time: Some(self.start_time.elapsed().as_secs_f64()),
235            modifiers,
236            events,
237            ..Default::default()
238        };
239
240        let before = self.dialog.state.clone();
241        let mut close = false;
242        let egui_ctx = self.egui_ctx.clone();
243        let output = egui_ctx.run(raw_input, |ctx| {
244            close = self.dialog.show(ctx);
245        });
246        let action = if close {
247            Some(DesktopConfigAction::Close)
248        } else if self.dialog.state != before {
249            Some(DesktopConfigAction::Changed)
250        } else {
251            None
252        };
253        let screen_desc = ScreenDescriptor {
254            size_in_pixels: [size.width, size.height],
255            pixels_per_point: scale,
256        };
257        let paint_jobs = self.egui_ctx.tessellate(output.shapes, scale);
258        for (id, delta) in &output.textures_delta.set {
259            self.egui_renderer.update_texture(
260                &self.renderer.device,
261                &self.renderer.queue,
262                *id,
263                delta,
264            );
265        }
266        let frame = match self.renderer.surface.get_current_texture() {
267            Ok(frame) => frame,
268            Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => {
269                self.renderer
270                    .resize(self.renderer.config.width, self.renderer.config.height);
271                return Ok(action);
272            }
273            Err(wgpu::SurfaceError::OutOfMemory) => anyhow::bail!("Config surface out of memory"),
274            Err(wgpu::SurfaceError::Timeout) => return Ok(action),
275        };
276        let view = frame
277            .texture
278            .create_view(&wgpu::TextureViewDescriptor::default());
279        let mut encoder =
280            self.renderer
281                .device
282                .create_command_encoder(&wgpu::CommandEncoderDescriptor {
283                    label: Some("siglus_config_egui_encoder"),
284                });
285        self.egui_renderer.update_buffers(
286            &self.renderer.device,
287            &self.renderer.queue,
288            &mut encoder,
289            &paint_jobs,
290            &screen_desc,
291        );
292        {
293            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
294                label: Some("siglus_config_egui_pass"),
295                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
296                    view: &view,
297                    resolve_target: None,
298                    ops: wgpu::Operations {
299                        load: wgpu::LoadOp::Clear(wgpu::Color {
300                            r: 0.965,
301                            g: 0.970,
302                            b: 0.980,
303                            a: 1.0,
304                        }),
305                        store: wgpu::StoreOp::Store,
306                    },
307                })],
308                depth_stencil_attachment: None,
309                timestamp_writes: None,
310                occlusion_query_set: None,
311            });
312            self.egui_renderer
313                .render(&mut pass, &paint_jobs, &screen_desc);
314        }
315        self.renderer.queue.submit(Some(encoder.finish()));
316        frame.present();
317        for id in output.textures_delta.free {
318            self.egui_renderer.free_texture(&id);
319        }
320        Ok(action)
321    }
322}
323fn map_pointer_button(button: MouseButton) -> Option<egui::PointerButton> {
324    match button {
325        MouseButton::Left => Some(egui::PointerButton::Primary),
326        MouseButton::Right => Some(egui::PointerButton::Secondary),
327        MouseButton::Middle => Some(egui::PointerButton::Middle),
328        _ => None,
329    }
330}
331
332fn map_modifiers(state: ModifiersState) -> egui::Modifiers {
333    egui::Modifiers {
334        alt: state.alt_key(),
335        ctrl: state.control_key(),
336        shift: state.shift_key(),
337        mac_cmd: cfg!(target_os = "macos") && state.meta_key(),
338        command: if cfg!(target_os = "macos") {
339            state.meta_key()
340        } else {
341            state.control_key()
342        },
343    }
344}
345
346fn map_key(code: KeyCode) -> Option<egui::Key> {
347    use KeyCode::*;
348    Some(match code {
349        ArrowDown => egui::Key::ArrowDown,
350        ArrowLeft => egui::Key::ArrowLeft,
351        ArrowRight => egui::Key::ArrowRight,
352        ArrowUp => egui::Key::ArrowUp,
353        Escape => egui::Key::Escape,
354        Tab => egui::Key::Tab,
355        Backspace => egui::Key::Backspace,
356        Enter | NumpadEnter => egui::Key::Enter,
357        Space => egui::Key::Space,
358        Insert => egui::Key::Insert,
359        Delete => egui::Key::Delete,
360        Home => egui::Key::Home,
361        End => egui::Key::End,
362        PageUp => egui::Key::PageUp,
363        PageDown => egui::Key::PageDown,
364        KeyA => egui::Key::A,
365        KeyC => egui::Key::C,
366        KeyV => egui::Key::V,
367        KeyX => egui::Key::X,
368        KeyY => egui::Key::Y,
369        KeyZ => egui::Key::Z,
370        _ => return None,
371    })
372}
373
374#[derive(Debug, Clone, Copy, PartialEq, Eq)]
375enum Tab {
376    Screen,
377    Volume,
378    Message,
379    Background,
380    Voice,
381    Auto,
382    Jitan,
383    Other,
384    System,
385}
386
387impl Tab {
388    const ALL: [Self; 9] = [
389        Self::Screen,
390        Self::Volume,
391        Self::Message,
392        Self::Background,
393        Self::Voice,
394        Self::Auto,
395        Self::Jitan,
396        Self::Other,
397        Self::System,
398    ];
399    fn definition(self) -> (&'static str, &'static str) {
400        match self {
401            Self::Screen => ("SCREEN", "画面"),
402            Self::Volume => ("VOLUME", "音量"),
403            Self::Message => ("MESSAGE", "文章"),
404            Self::Background => ("MWNDBK", "背景色"),
405            Self::Voice => ("KOE", "音声"),
406            Self::Auto => ("AUTOMODE", "オートモード"),
407            Self::Jitan => ("JITAN", "時短再生"),
408            Self::Other => ("ELSE", "その他"),
409            Self::System => ("SYSTEM", "システム"),
410        }
411    }
412}
413
414pub struct ConfigDialog {
415    pub state: OriginalConfigRuntimeState,
416    defaults: OriginalConfigRuntimeState,
417    gameexe: GameexeConfig,
418    tabs: Vec<Tab>,
419    tab: Tab,
420    voices: Vec<(usize, String)>,
421    remember_tab: bool,
422}
423
424impl ConfigDialog {
425    pub fn remember_tab_from(&mut self, previous: &Self) {
426        if self.remember_tab && self.tabs.contains(&previous.tab) {
427            self.tab = previous.tab;
428        }
429    }
430
431    pub fn new(ctx: &CommandContext) -> Self {
432        use crate::runtime::forms::codes::syscom_op::*;
433        let gameexe = ctx.tables.gameexe.clone().unwrap_or_default();
434        let tabs: Vec<_> = Tab::ALL
435            .into_iter()
436            .filter(|t| {
437                gameexe
438                    .get_i64(&format!("DIALOG_TAB_EXIST.{}", t.definition().0))
439                    .unwrap_or(1)
440                    != 0
441            })
442            .collect();
443        let requested = match ctx.globals.syscom.last_menu_call {
444            CALL_CONFIG_WINDOW_MODE_MENU => Tab::Screen,
445            CALL_CONFIG_VOLUME_MENU | CALL_CONFIG_BGMFADE_MENU => Tab::Volume,
446            CALL_CONFIG_FONT_MENU | CALL_CONFIG_MESSAGE_SPEED_MENU => Tab::Message,
447            CALL_CONFIG_FILTER_COLOR_MENU => Tab::Background,
448            CALL_CONFIG_KOEMODE_MENU | CALL_CONFIG_CHARAKOE_MENU => Tab::Voice,
449            CALL_CONFIG_AUTO_MODE_MENU => Tab::Auto,
450            CALL_CONFIG_JITAN_MENU => Tab::Jitan,
451            CALL_CONFIG_SYSTEM_MENU | CALL_CONFIG_MOVIE_MENU => Tab::System,
452            _ => Tab::Screen,
453        };
454        let tab = if tabs.contains(&requested) {
455            requested
456        } else {
457            tabs.first().copied().unwrap_or(Tab::Screen)
458        };
459        let state = syscom::config_state_for_save(ctx);
460        let voices = (0..state.chrkoe.len())
461            .filter_map(|i| {
462                let e = gameexe.get_indexed_entry("CHRKOE", i)?;
463                let name = e.item_unquoted(0)?.to_owned();
464                if name.is_empty() {
465                    return None;
466                }
467                let label = if syscom::config_chrkoe_name_visible(ctx, i) {
468                    name
469                } else {
470                    gameexe
471                        .get_unquoted("CHRKOE.NOT_LOOK_NAME_STR")
472                        .unwrap_or("???")
473                        .to_owned()
474                };
475                Some((i, label))
476            })
477            .collect();
478        Self {
479            state,
480            defaults: syscom::original_config_defaults(ctx),
481            gameexe,
482            tabs,
483            tab,
484            voices,
485            remember_tab: ctx.globals.syscom.last_menu_call == CALL_CONFIG_MENU,
486        }
487    }
488
489    fn show(&mut self, ctx: &egui::Context) -> bool {
490        let mut close = ctx.input(|i| i.key_pressed(egui::Key::Escape));
491        egui::TopBottomPanel::bottom("config_footer").show(ctx, |ui| {
492            ui.add_space(8.0);
493            ui.horizontal(|ui| {
494                ui.label(self.gameexe.get_unquoted("GAMEVERSION").unwrap_or(""));
495                ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
496                    if ui.button("閉じる").clicked() {
497                        close = true;
498                    }
499                    if ui.button("全て初期状態に戻す").clicked() {
500                        for tab in Tab::ALL {
501                            self.reset_tab(tab);
502                        }
503                    }
504                });
505            });
506            ui.add_space(8.0);
507        });
508        egui::TopBottomPanel::top("config_tabs").show(ctx, |ui| {
509            ui.add_space(6.0);
510            ui.horizontal_wrapped(|ui| {
511                for &tab in &self.tabs {
512                    ui.selectable_value(&mut self.tab, tab, tab.definition().1);
513                }
514            });
515            ui.add_space(6.0);
516        });
517        egui::CentralPanel::default().show(ctx, |ui| {
518            ui.spacing_mut().item_spacing = egui::vec2(10.0, 12.0);
519            ui.spacing_mut().slider_width = 270.0;
520            egui::ScrollArea::vertical().show(ui, |ui| {
521                if self.tabs.is_empty() {
522                    return;
523                }
524                match self.tab {
525                    Tab::Screen => self.screen(ui),
526                    Tab::Volume => self.volume(ui),
527                    Tab::Message => self.message(ui),
528                    Tab::Background => self.background(ui),
529                    Tab::Voice => self.voice(ui),
530                    Tab::Auto => self.auto(ui),
531                    Tab::Jitan => self.jitan(ui),
532                    Tab::Other => self.other(ui),
533                    Tab::System => self.system(ui),
534                }
535                ui.separator();
536                if ui.button("初期状態に戻す").clicked() {
537                    self.reset_tab(self.tab);
538                }
539            });
540        });
541        close
542    }
543
544    fn screen(&mut self, ui: &mut egui::Ui) {
545        ui.group(|ui| {
546            ui.label("画面モード");
547            ui.horizontal(|ui| {
548                ui.radio_value(&mut self.state.screen_size_mode, 1, "フルスクリーン");
549                ui.radio_value(&mut self.state.screen_size_mode, 0, "ウィンドウ");
550            });
551        });
552        ui.add_enabled_ui(self.state.screen_size_mode == 0, |ui| {
553            ui.group(|ui| {
554                ui.label("ウィンドウサイズ");
555                ui.add(
556                    egui::Slider::new(&mut self.state.screen_size_scale.0, 50..=200).suffix(" %"),
557                );
558                self.state.screen_size_scale.1 = self.state.screen_size_scale.0;
559                ui.horizontal(|ui| {
560                    for size in [50, 75, 100, 125, 150, 200] {
561                        if ui.button(format!("{size}%")).clicked() {
562                            self.state.screen_size_scale = (size, size);
563                        }
564                    }
565                });
566            });
567        });
568        ui.checkbox(
569            &mut self.state.mouse_cursor_hide_onoff,
570            "自動でマウスカーソルを隠す",
571        );
572        ui.add_enabled(
573            self.state.mouse_cursor_hide_onoff,
574            egui::Slider::new(&mut self.state.mouse_cursor_hide_time, 0..=10000).suffix(" ms"),
575        );
576    }
577
578    fn volume(&mut self, ui: &mut egui::Ui) {
579        ui.label("音量");
580        volume_row(
581            ui,
582            "全体",
583            &mut self.state.all_sound_user_volume,
584            &mut self.state.play_all_sound_check,
585        );
586        for (i, (key, label)) in [
587            ("BGM", "BGM"),
588            ("KOE", "音声"),
589            ("PCM", "効果音"),
590            ("SE", "システム音"),
591            ("MOVIE", "ムービー"),
592        ]
593        .into_iter()
594        .enumerate()
595        {
596            if self
597                .gameexe
598                .get_i64(&format!("DIALOG_EXIST.{key}"))
599                .unwrap_or(1)
600                != 0
601            {
602                volume_row(
603                    ui,
604                    label,
605                    &mut self.state.sound_user_volume[i],
606                    &mut self.state.play_sound_check[i],
607                );
608            }
609        }
610        if self.gameexe.get_i64("DIALOG_STYLE.VOLUME").unwrap_or(0) != 1 {
611            ui.separator();
612            ui.checkbox(
613                &mut self.state.bgmfade_use_check,
614                "音声再生時にBGMの音量を下げる",
615            );
616            ui.add_enabled(
617                self.state.bgmfade_use_check,
618                egui::Slider::new(&mut self.state.bgmfade_volume, 0..=255).text("BGM音量"),
619            );
620        }
621    }
622
623    fn message(&mut self, ui: &mut egui::Ui) {
624        ui.group(|ui| {
625            ui.label("フォント");
626            egui::ComboBox::from_id_source("font_name")
627                .selected_text(&self.state.font_name)
628                .show_ui(ui, |ui| {
629                    let mut names = vec!["MS ゴシック", "MS 明朝", "メイリオ"];
630                    if !names.contains(&self.defaults.font_name.as_str()) {
631                        names.push(&self.defaults.font_name);
632                    }
633                    for name in names {
634                        ui.selectable_value(&mut self.state.font_name, name.to_owned(), name);
635                    }
636                });
637        });
638        ui.group(|ui| {
639            ui.label("文字速度");
640            // The original slider increases left-to-right while stored delay decreases.
641            let mut speed = 100 - self.state.message_speed;
642            if ui
643                .add_enabled(
644                    !self.state.message_speed_nowait,
645                    egui::Slider::new(&mut speed, 0..=100).text("遅い ← → 速い"),
646                )
647                .changed()
648            {
649                self.state.message_speed = 100 - speed;
650            }
651            ui.checkbox(&mut self.state.message_speed_nowait, "一瞬で表示する");
652        });
653    }
654
655    fn background(&mut self, ui: &mut egui::Ui) {
656        ui.label("メッセージウィンドウ背景色");
657        let mut rgba = [
658            ((self.state.filter_color_argb >> 16) & 255) as i64,
659            ((self.state.filter_color_argb >> 8) & 255) as i64,
660            (self.state.filter_color_argb & 255) as i64,
661            ((self.state.filter_color_argb >> 24) & 255) as i64,
662        ];
663        for (v, label) in rgba.iter_mut().zip(["赤", "緑", "青", "不透明度"]) {
664            ui.add(egui::Slider::new(v, 0..=255).text(label));
665        }
666        self.state.filter_color_argb = ((rgba[3] as u32) << 24)
667            | ((rgba[0] as u32) << 16)
668            | ((rgba[1] as u32) << 8)
669            | rgba[2] as u32;
670        let (rect, _) = ui.allocate_exact_size(egui::vec2(320.0, 70.0), egui::Sense::hover());
671        ui.painter().rect_filled(
672            rect,
673            0.0,
674            egui::Color32::from_rgba_unmultiplied(
675                rgba[0] as u8,
676                rgba[1] as u8,
677                rgba[2] as u8,
678                rgba[3] as u8,
679            ),
680        );
681    }
682
683    fn voice(&mut self, ui: &mut egui::Ui) {
684        let style = self.gameexe.get_i64("DIALOG_STYLE.KOE").unwrap_or(0);
685        if style != 2 {
686            ui.label("音声モード");
687            for (i, label) in [
688                "音声あり(文章あり)",
689                "音声なし(文章のみ)",
690                "音声あり(文章なし)",
691            ]
692            .into_iter()
693            .enumerate()
694            {
695                ui.radio_value(&mut self.state.koe_mode, i as i64, label);
696            }
697        }
698        if style != 1 {
699            ui.separator();
700            ui.label("キャラクター音声");
701            for (i, name) in &self.voices {
702                if let Some(voice) = self.state.chrkoe.get_mut(*i) {
703                    ui.push_id(i, |ui| {
704                        volume_row(ui, name, &mut voice.volume, &mut voice.onoff);
705                    });
706                }
707            }
708        }
709    }
710
711    fn auto(&mut self, ui: &mut egui::Ui) {
712        ui.label("オートモード");
713        ui.add(
714            egui::Slider::new(&mut self.state.auto_mode_moji_wait, 0..=500)
715                .suffix(" ms")
716                .text("1文字あたりの待ち時間"),
717        );
718        ui.add(
719            egui::Slider::new(&mut self.state.auto_mode_min_wait, 0..=10000)
720                .suffix(" ms")
721                .text("最小待ち時間"),
722        );
723    }
724
725    fn jitan(&mut self, ui: &mut egui::Ui) {
726        ui.label("時短再生");
727        ui.add(
728            egui::Slider::new(&mut self.state.jitan_speed, 100..=300)
729                .step_by(25.0)
730                .suffix(" %"),
731        );
732        ui.checkbox(
733            &mut self.state.jitan_normal_onoff,
734            "文章を普通に読み進めている時に使用する",
735        );
736        ui.checkbox(
737            &mut self.state.jitan_auto_mode_onoff,
738            "オートモード中に使用する",
739        );
740        ui.checkbox(
741            &mut self.state.jitan_msgbk_onoff,
742            "声のリプレイ時の再生速度を変更する",
743        );
744    }
745
746    fn other(&mut self, ui: &mut egui::Ui) {
747        configured_checkbox(
748            ui,
749            &self.gameexe,
750            "MESSAGE_CHRCOLOR",
751            true,
752            "文章を色分けする。",
753            &mut self.state.message_chrcolor_flag,
754        );
755        for i in 0..4 {
756            configured_checkbox(
757                ui,
758                &self.gameexe,
759                &format!("OBJECT_DISP.{i:03}"),
760                i < 2,
761                &format!("オブジェクト表示{i}番を表示する。"),
762                &mut self.state.object_disp_flag[i],
763            );
764            configured_checkbox(
765                ui,
766                &self.gameexe,
767                &format!("GLOBAL_EXTRA_SWITCH.{i:03}"),
768                i < 2,
769                &format!("グローバル汎用スイッチ{i}番を使用する。"),
770                &mut self.state.global_extra_switch_flag[i],
771            );
772            let key = format!("DIALOG.GLOBAL_EXTRA_MODE.{i:03}");
773            if self.gameexe.get_i64(&format!("{key}.EXIST")).unwrap_or(1) == 0 {
774                continue;
775            }
776            ui.group(|ui| {
777                ui.label(
778                    self.gameexe
779                        .get_unquoted(&format!("{key}.STR"))
780                        .map(str::to_owned)
781                        .unwrap_or_else(|| format!("グローバル汎用モード{i}番")),
782                );
783                let count = self
784                    .gameexe
785                    .get_i64(&format!("{key}.ITEM_CNT"))
786                    .unwrap_or(if i < 2 { 3 } else { 1 })
787                    .clamp(0, 32);
788                for j in 0..count {
789                    let label = self
790                        .gameexe
791                        .get_unquoted(&format!("{key}.ITEM.{j:03}.STR"))
792                        .map(str::to_owned)
793                        .unwrap_or_else(|| format!("モード{j}"));
794                    ui.radio_value(&mut self.state.global_extra_mode_flag[i], j, label);
795                }
796            });
797        }
798    }
799
800    fn system(&mut self, ui: &mut egui::Ui) {
801        let c = &mut self.state;
802        for (key, label, value) in [
803            (
804                "SLEEP",
805                "本プログラムの動作を遅くして、他のプログラムがスムーズに動作するようにする。",
806                &mut c.sleep_flag,
807            ),
808            (
809                "NO_WIPE_ANIME",
810                "画面暗転効果のアニメを無効にする。",
811                &mut c.no_wipe_anime_flag,
812            ),
813            (
814                "SKIP_WIPE_ANIME",
815                "画面暗転効果をマウスクリックで飛ばす。",
816                &mut c.skip_wipe_anime_flag,
817            ),
818            (
819                "NO_MWND_ANIME",
820                "メッセージウィンドウの開閉時のアニメを無効にする。",
821                &mut c.no_mwnd_anime_flag,
822            ),
823            (
824                "WHEEL_NEXT_MESSAGE",
825                "マウスのホイールボタンの下回しで文章を読み進める。",
826                &mut c.wheel_next_message_flag,
827            ),
828            (
829                "KOE_DONT_STOP",
830                "声の再生中に次の文章に進んでも再生を続ける。",
831                &mut c.koe_dont_stop_flag,
832            ),
833            (
834                "SKIP_UNREAD_MESSAGE",
835                "未読の文章も早送りできるようにする。",
836                &mut c.skip_unread_message_flag,
837            ),
838        ] {
839            configured_checkbox(ui, &self.gameexe, key, true, label, value);
840        }
841        // SiglusEngine 1.1.141.x adds these two fixed system-config checkboxes.
842        // They are persisted as the two one-byte fields appended by config.sav v1.4.
843        ui.checkbox(
844            &mut c.joypad_swap_ab,
845            "コントローラーの A / B ボタンを入れ替える。",
846        );
847        ui.checkbox(
848            &mut c.joypad_swap_xy,
849            "コントローラーの X / Y ボタンを入れ替える。",
850        );
851    }
852
853    fn reset_tab(&mut self, tab: Tab) {
854        let c = &mut self.state;
855        let d = &self.defaults;
856        // Reset only controls owned by the page; preserve paths, display inventory and other tabs.
857        match tab {
858            Tab::Screen => {
859                c.screen_size_mode = d.screen_size_mode;
860                c.screen_size_scale = d.screen_size_scale;
861                c.mouse_cursor_hide_onoff = d.mouse_cursor_hide_onoff;
862                c.mouse_cursor_hide_time = d.mouse_cursor_hide_time;
863            }
864            Tab::Volume => {
865                c.all_sound_user_volume = d.all_sound_user_volume;
866                c.sound_user_volume = d.sound_user_volume;
867                c.play_all_sound_check = d.play_all_sound_check;
868                c.play_sound_check = d.play_sound_check;
869                c.bgmfade_use_check = d.bgmfade_use_check;
870                c.bgmfade_volume = d.bgmfade_volume;
871            }
872            Tab::Message => {
873                c.font_name = d.font_name.clone();
874                c.font_futoku = d.font_futoku;
875                c.font_shadow = d.font_shadow;
876                c.message_speed = d.message_speed;
877                c.message_speed_nowait = d.message_speed_nowait;
878            }
879            Tab::Background => c.filter_color_argb = d.filter_color_argb,
880            Tab::Voice => {
881                c.koe_mode = d.koe_mode;
882                c.chrkoe = d.chrkoe.clone();
883            }
884            Tab::Auto => {
885                c.auto_mode_moji_wait = d.auto_mode_moji_wait;
886                c.auto_mode_min_wait = d.auto_mode_min_wait;
887            }
888            Tab::Jitan => {
889                c.jitan_normal_onoff = d.jitan_normal_onoff;
890                c.jitan_auto_mode_onoff = d.jitan_auto_mode_onoff;
891                c.jitan_msgbk_onoff = d.jitan_msgbk_onoff;
892                c.jitan_speed = d.jitan_speed;
893            }
894            Tab::Other => {
895                c.message_chrcolor_flag = d.message_chrcolor_flag;
896                c.object_disp_flag = d.object_disp_flag.clone();
897                c.global_extra_switch_flag = d.global_extra_switch_flag.clone();
898                c.global_extra_mode_flag = d.global_extra_mode_flag.clone();
899            }
900            Tab::System => {
901                c.sleep_flag = d.sleep_flag;
902                c.no_wipe_anime_flag = d.no_wipe_anime_flag;
903                c.skip_wipe_anime_flag = d.skip_wipe_anime_flag;
904                c.no_mwnd_anime_flag = d.no_mwnd_anime_flag;
905                c.wheel_next_message_flag = d.wheel_next_message_flag;
906                c.koe_dont_stop_flag = d.koe_dont_stop_flag;
907                c.skip_unread_message_flag = d.skip_unread_message_flag;
908                c.joypad_swap_ab = d.joypad_swap_ab;
909                c.joypad_swap_xy = d.joypad_swap_xy;
910            }
911        }
912    }
913}
914
915fn volume_row(ui: &mut egui::Ui, label: &str, volume: &mut i64, enabled: &mut bool) {
916    ui.horizontal(|ui| {
917        ui.add_sized([90.0, 20.0], egui::Label::new(label));
918        ui.add_enabled(*enabled, egui::Slider::new(volume, 0..=255));
919        ui.checkbox(enabled, "再生");
920    });
921}
922
923fn configured_checkbox(
924    ui: &mut egui::Ui,
925    gameexe: &GameexeConfig,
926    key: &str,
927    default_exist: bool,
928    default_label: &str,
929    value: &mut bool,
930) {
931    if gameexe
932        .get_i64(&format!("DIALOG.{key}.EXIST"))
933        .unwrap_or(default_exist as i64)
934        != 0
935    {
936        ui.checkbox(
937            value,
938            gameexe
939                .get_unquoted(&format!("DIALOG.{key}.STR"))
940                .unwrap_or(default_label),
941        );
942    }
943}
944
945#[cfg(test)]
946mod tests {
947    use super::*;
948    use crate::runtime::forms::codes::syscom_op::*;
949
950    #[cfg(target_os = "linux")]
951    #[test]
952    #[ignore = "requires a desktop session and GPU"]
953    fn native_config_close_releases_window_and_surface() {
954        use winit::application::ApplicationHandler;
955        use winit::event_loop::EventLoop;
956        use winit::platform::wayland::EventLoopBuilderExtWayland;
957
958        struct Probe;
959        impl ApplicationHandler for Probe {
960            fn can_create_surfaces(&mut self, elwt: &dyn ActiveEventLoop) {
961                let ctx =
962                    CommandContext::new(std::env::temp_dir().join("siglus-config-close-test"));
963                let mut dialog = ConfigDialog::new(&ctx);
964                for _ in 0..2 {
965                    let mut window = DesktopConfigWindow::new(elwt, dialog).unwrap();
966                    let weak = Arc::downgrade(&window.window);
967                    window.render().unwrap();
968                    assert!(matches!(
969                        window.handle_window_event(WindowEvent::CloseRequested),
970                        Some(DesktopConfigAction::Close)
971                    ));
972                    dialog = window.into_dialog();
973                    assert!(weak.upgrade().is_none(), "native window or surface leaked");
974                }
975                elwt.exit();
976            }
977
978            fn window_event(&mut self, _: &dyn ActiveEventLoop, _: WindowId, _: WindowEvent) {}
979        }
980
981        let event_loop = EventLoop::builder().with_any_thread(true).build().unwrap();
982        event_loop.run_app(Probe).unwrap();
983    }
984
985    #[test]
986    fn recreated_dialog_remembers_tab_without_reusing_stale_settings() {
987        let mut ctx = CommandContext::new(std::env::temp_dir().join("siglus-config-reopen-test"));
988        ctx.globals.syscom.last_menu_call = CALL_CONFIG_MENU;
989        let mut previous = ConfigDialog::new(&ctx);
990        previous.tab = Tab::Volume;
991        ctx.globals
992            .syscom
993            .config_int
994            .insert(GET_WINDOW_MODE_SIZE, 75);
995        let mut reopened = ConfigDialog::new(&ctx);
996        reopened.remember_tab_from(&previous);
997        assert_eq!(reopened.tab, Tab::Volume);
998        assert_eq!(reopened.state.screen_size_scale, (75, 75));
999
1000        ctx.globals.syscom.last_menu_call = CALL_CONFIG_WINDOW_MODE_MENU;
1001        let mut requested = ConfigDialog::new(&ctx);
1002        requested.remember_tab_from(&previous);
1003        assert_eq!(requested.tab, Tab::Screen);
1004
1005        ctx.globals.syscom.last_menu_call = CALL_CONFIG_FONT_MENU;
1006        let mut font = ConfigDialog::new(&ctx);
1007        font.remember_tab_from(&previous);
1008        assert_eq!(font.tab, Tab::Message);
1009
1010        ctx.globals.syscom.last_menu_call = CALL_CONFIG_MENU;
1011        ctx.tables.gameexe = Some(GameexeConfig::from_text("#DIALOG_TAB_EXIST.VOLUME=0"));
1012        let mut hidden = ConfigDialog::new(&ctx);
1013        hidden.remember_tab_from(&previous);
1014        assert_eq!(hidden.tab, Tab::Screen);
1015    }
1016
1017    #[test]
1018    fn game_definitions_control_tabs_voice_names_and_reset_scope() {
1019        let mut ctx = CommandContext::new(std::env::temp_dir().join("siglus-config-ui-test"));
1020        ctx.tables.gameexe = Some(GameexeConfig::from_text(
1021            r#"
1022#DIALOG_TAB_EXIST.ELSE=0
1023#DIALOG_TAB_EXIST.ELSE=1
1024#DIALOG_TAB_EXIST.JITAN=0
1025#CONFIG.VOLUME.BGM=165
1026#CONFIG.GLOBAL_EXTRA_SWITCH.000.ONOFF=0
1027#CHRKOE.000="First",1,"First",1,235,(0)
1028#CHRKOE.001="Hidden",2,"Alias",1,210,(1)
1029#CHRKOE.NOT_LOOK_NAME_STR="???"
1030"#,
1031        ));
1032        syscom::apply_config_dialog_state(&mut ctx, OriginalConfigRuntimeState::default());
1033        // Script commands can have newer values than original_config.
1034        ctx.globals.syscom.config_int.insert(GET_BGM_VOLUME, 77);
1035        ctx.globals.syscom.last_menu_call = CALL_CONFIG_VOLUME_MENU;
1036        let mut dialog = ConfigDialog::new(&ctx);
1037        assert_eq!(dialog.tab, Tab::Volume);
1038        assert!(dialog.tabs.contains(&Tab::Other));
1039        assert!(!dialog.tabs.contains(&Tab::Jitan));
1040        assert_eq!(dialog.voices, vec![(0, "First".into()), (1, "???".into())]);
1041        assert_eq!(dialog.state.sound_user_volume[0], 77);
1042        dialog.state.editor_path = "keep this path".into();
1043        dialog.state.message_speed = 42;
1044        dialog.reset_tab(Tab::Volume);
1045        assert_eq!(dialog.state.sound_user_volume[0], 165);
1046        assert_eq!(dialog.state.message_speed, 42);
1047        assert_eq!(dialog.state.editor_path, "keep this path");
1048        dialog.reset_tab(Tab::Other);
1049        assert!(!dialog.state.global_extra_switch_flag[0]);
1050        syscom::reveal_config_voice_name(&mut ctx, "Alias");
1051        assert_eq!(ConfigDialog::new(&ctx).voices[1].1, "Hidden");
1052
1053        // Exercise layout for each page without a GPU or game assets.
1054        let egui = egui::Context::default();
1055        for tab in Tab::ALL {
1056            dialog.tab = tab;
1057            let _ = egui.run(egui::RawInput::default(), |ctx| {
1058                dialog.show(ctx);
1059            });
1060        }
1061    }
1062}