Skip to main content

siglus_scene_vm/
desktop_twitter.rs

1//! Desktop Twitter dialog used by the Siglus Twitter subsystem.
2
3#![cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
4
5use std::io::Write;
6use std::process::{Command, Stdio};
7use std::sync::Arc;
8use std::time::Instant;
9
10use anyhow::{Context, Result};
11use egui_wgpu::{Renderer as EguiRenderer, ScreenDescriptor};
12use winit::dpi::{LogicalPosition, LogicalSize};
13use winit::event::{ElementState, Ime, KeyEvent, MouseButton, MouseScrollDelta, WindowEvent};
14use winit::event_loop::ActiveEventLoop;
15use winit::keyboard::{KeyCode, ModifiersState, PhysicalKey};
16use winit::window::{Window, WindowAttributes, WindowId};
17
18use crate::render::Renderer;
19use crate::runtime::twitter::TwitterDialogRequest;
20
21fn configure_egui_default_font(ctx: &egui::Context) {
22    let mut fonts = egui::FontDefinitions::default();
23    fonts.font_data.insert(
24        "siglus_default".to_string(),
25        egui::FontData::from_static(crate::text_render::DEFAULT_FONT_BYTES),
26    );
27    fonts
28        .families
29        .entry(egui::FontFamily::Proportional)
30        .or_default()
31        .insert(0, "siglus_default".to_string());
32    fonts
33        .families
34        .entry(egui::FontFamily::Monospace)
35        .or_default()
36        .insert(0, "siglus_default".to_string());
37    ctx.set_fonts(fonts);
38}
39
40#[derive(Debug, Clone)]
41pub enum DesktopTwitterAction {
42    Authorize,
43    CompleteAuthorize(String),
44    Tweet(String),
45    Close,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49enum DialogNotice {
50    None,
51    EmptyTweetConfirm,
52    AuthSuccess,
53    TweetSuccess,
54    Error,
55}
56
57pub struct DesktopTwitterWindow {
58    request: TwitterDialogRequest,
59    // The GPU surface owns another Arc; dropping both destroys the native window.
60    window: Arc<dyn Window>,
61    window_id: WindowId,
62    renderer: Renderer,
63    egui_renderer: EguiRenderer,
64    egui_ctx: egui::Context,
65    preview: egui::TextureHandle,
66    start_time: Instant,
67    input_events: Vec<egui::Event>,
68    modifiers: egui::Modifiers,
69    pointer_pos: egui::Pos2,
70    tweet_text: String,
71    callback_text: String,
72    authorized: bool,
73    account_label: String,
74    authorization_entry: bool,
75    notice: DialogNotice,
76    notice_text: String,
77}
78
79impl DesktopTwitterWindow {
80    pub fn new(elwt: &dyn ActiveEventLoop, request: TwitterDialogRequest) -> Result<Self> {
81        let window = elwt
82            .create_window(
83                WindowAttributes::default()
84                    .with_title("Twitter")
85                    .with_surface_size(LogicalSize::new(620.0, 520.0))
86                    .with_min_surface_size(LogicalSize::new(520.0, 440.0)),
87            )
88            .context("create desktop Twitter window")?;
89        let window: Arc<dyn Window> = Arc::from(window);
90        crate::ime::enable_ime(
91            window.as_ref(),
92            LogicalPosition::new(0, 0).into(),
93            LogicalSize::new(0, 0).into(),
94        );
95        let renderer =
96            pollster::block_on(Renderer::new(window.clone())).context("Twitter renderer init")?;
97        let egui_renderer = EguiRenderer::new(&renderer.device, renderer.config.format, None, 1);
98        let egui_ctx = egui::Context::default();
99        configure_egui_default_font(&egui_ctx);
100        let preview_image = egui::ColorImage::from_rgba_unmultiplied(
101            [
102                request.image_width.max(1) as usize,
103                request.image_height.max(1) as usize,
104            ],
105            &request.image_rgba,
106        );
107        let preview = egui_ctx.load_texture(
108            "siglus_tweet_preview",
109            preview_image,
110            egui::TextureOptions::LINEAR,
111        );
112        let tweet_text = request.initial_text.clone();
113        window.request_redraw();
114        Ok(Self {
115            request,
116            window_id: window.id(),
117            window,
118            renderer,
119            egui_renderer,
120            egui_ctx,
121            preview,
122            start_time: Instant::now(),
123            input_events: Vec::new(),
124            modifiers: egui::Modifiers::default(),
125            pointer_pos: egui::Pos2::ZERO,
126            tweet_text,
127            callback_text: String::new(),
128            authorized: false,
129            account_label: String::new(),
130            authorization_entry: false,
131            notice: DialogNotice::None,
132            notice_text: String::new(),
133        })
134    }
135
136    pub fn window_id(&self) -> WindowId {
137        self.window_id
138    }
139
140    pub fn request_redraw(&self) {
141        self.window.request_redraw();
142    }
143
144    pub fn image_path(&self) -> &std::path::Path {
145        &self.request.image_path
146    }
147
148    pub fn set_account_state(&mut self, authorized: bool, user_name: &str, screen_name: &str) {
149        self.authorized = authorized;
150        self.account_label = if authorized {
151            format!("{} (@{})", user_name, screen_name)
152        } else {
153            "投稿するには認証が必要です。".to_string()
154        };
155        self.window.request_redraw();
156    }
157
158    pub fn show_authorization_entry(&mut self) {
159        self.authorization_entry = true;
160        self.callback_text.clear();
161        self.notice = DialogNotice::None;
162        self.notice_text.clear();
163        self.window.request_redraw();
164    }
165
166    pub fn authentication_succeeded(&mut self) {
167        self.authorization_entry = false;
168        self.callback_text.clear();
169        self.notice = DialogNotice::AuthSuccess;
170        self.notice_text = "認証に成功しました。".to_string();
171        self.window.request_redraw();
172    }
173
174    pub fn tweet_succeeded(&mut self) {
175        self.tweet_text.clear();
176        self.notice = DialogNotice::TweetSuccess;
177        self.notice_text = "投稿しました。".to_string();
178        self.window.request_redraw();
179    }
180
181    pub fn show_error(&mut self, text: impl Into<String>) {
182        self.notice = DialogNotice::Error;
183        self.notice_text = text.into();
184        self.window.request_redraw();
185    }
186
187    pub fn handle_window_event(&mut self, event: WindowEvent) -> Option<DesktopTwitterAction> {
188        match event {
189            WindowEvent::CloseRequested => return Some(DesktopTwitterAction::Close),
190            WindowEvent::SurfaceResized(size) => {
191                self.renderer.resize(size.width.max(1), size.height.max(1));
192                self.window.request_redraw();
193            }
194            WindowEvent::ModifiersChanged(modifiers) => {
195                self.modifiers = map_modifiers(modifiers.state());
196            }
197            WindowEvent::PointerMoved {
198                position,
199                primary: true,
200                ..
201            }
202            | WindowEvent::PointerEntered {
203                position,
204                primary: true,
205                ..
206            } => {
207                let logical = position.to_logical::<f64>(self.window.scale_factor());
208                self.pointer_pos = egui::pos2(logical.x as f32, logical.y as f32);
209                self.input_events
210                    .push(egui::Event::PointerMoved(self.pointer_pos));
211                self.window.request_redraw();
212            }
213            WindowEvent::PointerLeft { primary: true, .. } => {
214                self.input_events.push(egui::Event::PointerGone);
215                self.window.request_redraw();
216            }
217            WindowEvent::PointerButton {
218                state,
219                button,
220                position,
221                primary: true,
222                ..
223            } => {
224                let button = button.mouse_button()?;
225                let logical = position.to_logical::<f64>(self.window.scale_factor());
226                self.pointer_pos = egui::pos2(logical.x as f32, logical.y as f32);
227                if let Some(button) = map_pointer_button(button) {
228                    self.input_events.push(egui::Event::PointerButton {
229                        pos: self.pointer_pos,
230                        button,
231                        pressed: state == ElementState::Pressed,
232                        modifiers: self.modifiers,
233                    });
234                    self.window.request_redraw();
235                }
236            }
237            WindowEvent::MouseWheel { delta, .. } => {
238                let delta = match delta {
239                    MouseScrollDelta::LineDelta(x, y) => egui::vec2(x * 24.0, y * 24.0),
240                    MouseScrollDelta::PixelDelta(pos) => egui::vec2(pos.x as f32, pos.y as f32),
241                    _ => return None,
242                };
243                self.input_events.push(egui::Event::MouseWheel {
244                    unit: egui::MouseWheelUnit::Point,
245                    delta,
246                    modifiers: self.modifiers,
247                });
248                self.window.request_redraw();
249            }
250            WindowEvent::Ime(ime) => {
251                let event = match ime {
252                    Ime::Enabled => egui::ImeEvent::Enabled,
253                    Ime::Preedit(text, _) => egui::ImeEvent::Preedit(text),
254                    Ime::Commit(text) => egui::ImeEvent::Commit(text),
255                    Ime::Disabled => egui::ImeEvent::Disabled,
256                    _ => return None,
257                };
258                self.input_events.push(egui::Event::Ime(event));
259                self.window.request_redraw();
260            }
261            #[cfg(target_os = "windows")]
262            WindowEvent::KeyboardInput {
263                event:
264                    KeyEvent {
265                        logical_key: winit::keyboard::Key::Named(winit::keyboard::NamedKey::Process),
266                        ..
267                    },
268                ..
269            } => return None,
270            WindowEvent::KeyboardInput { event, .. } => {
271                if self.modifiers.command {
272                    match (event.state, event.physical_key) {
273                        (ElementState::Pressed, PhysicalKey::Code(KeyCode::KeyC)) => {
274                            self.input_events.push(egui::Event::Copy);
275                        }
276                        (ElementState::Pressed, PhysicalKey::Code(KeyCode::KeyX)) => {
277                            self.input_events.push(egui::Event::Cut);
278                        }
279                        (ElementState::Pressed, PhysicalKey::Code(KeyCode::KeyV)) => {
280                            match read_system_clipboard() {
281                                Ok(text) if !text.is_empty() => {
282                                    self.input_events.push(egui::Event::Paste(text));
283                                }
284                                Ok(_) => {}
285                                Err(err) => {
286                                    log::debug!("desktop Twitter clipboard read failed: {err:#}");
287                                }
288                            }
289                        }
290                        _ => {}
291                    }
292                }
293                if let PhysicalKey::Code(code) = event.physical_key
294                    && let Some(key) = map_key(code)
295                {
296                    self.input_events.push(egui::Event::Key {
297                        key,
298                        physical_key: Some(key),
299                        pressed: event.state == ElementState::Pressed,
300                        repeat: event.repeat,
301                        modifiers: self.modifiers,
302                    });
303                }
304                if let KeyEvent {
305                    state: ElementState::Pressed,
306                    text: Some(text),
307                    ..
308                } = event
309                    && !self.modifiers.command
310                {
311                    let text = text.to_string();
312                    if !text.is_empty() && !text.chars().all(char::is_control) {
313                        self.input_events.push(egui::Event::Text(text));
314                    }
315                }
316                self.window.request_redraw();
317            }
318            WindowEvent::RedrawRequested => match self.render() {
319                Ok(action) => return action,
320                Err(err) => log::error!("desktop Twitter render failed: {err:#}"),
321            },
322            _ => {}
323        }
324        None
325    }
326
327    fn render(&mut self) -> Result<Option<DesktopTwitterAction>> {
328        let size = self.window.surface_size();
329        if size.width == 0 || size.height == 0 {
330            return Ok(None);
331        }
332        let scale = self.window.scale_factor() as f32;
333        self.egui_ctx.set_pixels_per_point(scale);
334        let logical_w = size.width as f32 / scale.max(1.0);
335        let logical_h = size.height as f32 / scale.max(1.0);
336        let events = std::mem::take(&mut self.input_events);
337        let modifiers = self.modifiers;
338        let raw_input = egui::RawInput {
339            screen_rect: Some(egui::Rect::from_min_size(
340                egui::Pos2::ZERO,
341                egui::vec2(logical_w, logical_h),
342            )),
343            time: Some(self.start_time.elapsed().as_secs_f64()),
344            modifiers,
345            events,
346            ..Default::default()
347        };
348
349        let mut action = None;
350        let preview_id = self.preview.id();
351        let preview_w = self.request.image_width.max(1) as f32;
352        let preview_h = self.request.image_height.max(1) as f32;
353        let authorized = self.authorized;
354        let account_label = self.account_label.clone();
355        let egui_ctx = self.egui_ctx.clone();
356        let output = egui_ctx.run(raw_input, |ctx| {
357            egui::CentralPanel::default().show(ctx, |ui| {
358                ui.heading("Twitter");
359                ui.add_space(4.0);
360                ui.horizontal(|ui| {
361                    ui.label(account_label);
362                    if ui.button("認証").clicked() {
363                        action = Some(DesktopTwitterAction::Authorize);
364                    }
365                });
366                ui.add_space(8.0);
367
368                let available_w = ui.available_width();
369                let canvas_h = (ui.available_height() * 0.58).clamp(180.0, 300.0);
370                let (canvas_rect, _) = ui.allocate_exact_size(
371                    egui::vec2(available_w, canvas_h),
372                    egui::Sense::hover(),
373                );
374                ui.painter()
375                    .rect_filled(canvas_rect, 0.0, egui::Color32::GRAY);
376                let scale = (canvas_rect.width() / preview_w)
377                    .min(canvas_rect.height() / preview_h)
378                    .max(0.0001);
379                let draw_size = egui::vec2(preview_w * scale, preview_h * scale);
380                let image_rect = egui::Rect::from_center_size(canvas_rect.center(), draw_size);
381                ui.put(
382                    image_rect,
383                    egui::Image::new((preview_id, draw_size)),
384                );
385
386                ui.add_space(8.0);
387                ui.add_enabled(
388                    authorized,
389                    egui::TextEdit::multiline(&mut self.tweet_text)
390                        .desired_rows(3)
391                        .desired_width(f32::INFINITY),
392                );
393                ui.add_space(4.0);
394                ui.horizontal(|ui| {
395                    if ui.add_enabled(authorized, egui::Button::new("Twitter へ投稿")).clicked() {
396                        if self.tweet_text.is_empty() {
397                            self.notice = DialogNotice::EmptyTweetConfirm;
398                            self.notice_text = "メッセージが空です。投稿してよろしいですか?".to_string();
399                        } else {
400                            action = Some(DesktopTwitterAction::Tweet(self.tweet_text.clone()));
401                        }
402                    }
403                    if ui.button("閉じる").clicked() {
404                        action = Some(DesktopTwitterAction::Close);
405                    }
406                });
407            });
408
409            if self.authorization_entry {
410                egui::Window::new("Twitter 認証")
411                    .collapsible(false)
412                    .resizable(true)
413                    .default_width(500.0)
414                    .show(ctx, |ui| {
415                        ui.label("ブラウザで認証後、callback URL または oauth_verifier を貼り付けてください。");
416                        ui.add(
417                            egui::TextEdit::multiline(&mut self.callback_text)
418                                .desired_rows(3)
419                                .desired_width(f32::INFINITY),
420                        );
421                        ui.horizontal(|ui| {
422                            if ui.button("クリップボードから貼り付け").clicked() {
423                                match read_system_clipboard() {
424                                    Ok(text) => self.callback_text = text.trim().to_string(),
425                                    Err(err) => {
426                                        self.notice = DialogNotice::Error;
427                                        self.notice_text = format!("クリップボードを読み取れませんでした: {err:#}");
428                                    }
429                                }
430                            }
431                            if ui.button("認証を完了").clicked() {
432                                action = Some(DesktopTwitterAction::CompleteAuthorize(
433                                    self.callback_text.clone(),
434                                ));
435                            }
436                            if ui.button("閉じる").clicked() {
437                                self.authorization_entry = false;
438                            }
439                        });
440                    });
441            }
442
443            match self.notice {
444                DialogNotice::EmptyTweetConfirm => {
445                    egui::Window::new("Twitter")
446                        .collapsible(false)
447                        .resizable(false)
448                        .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO)
449                        .show(ctx, |ui| {
450                            ui.label(self.notice_text.clone());
451                            ui.horizontal(|ui| {
452                                if ui.button("OK").clicked() {
453                                    self.notice = DialogNotice::None;
454                                    action = Some(DesktopTwitterAction::Tweet(String::new()));
455                                }
456                                if ui.button("キャンセル").clicked() {
457                                    self.notice = DialogNotice::None;
458                                }
459                            });
460                        });
461                }
462                DialogNotice::AuthSuccess | DialogNotice::Error => {
463                    egui::Window::new("Twitter")
464                        .collapsible(false)
465                        .resizable(false)
466                        .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO)
467                        .show(ctx, |ui| {
468                            ui.label(self.notice_text.clone());
469                            if ui.button("OK").clicked() {
470                                self.notice = DialogNotice::None;
471                            }
472                        });
473                }
474                DialogNotice::TweetSuccess => {
475                    egui::Window::new("Twitter")
476                        .collapsible(false)
477                        .resizable(false)
478                        .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO)
479                        .show(ctx, |ui| {
480                            ui.label(self.notice_text.clone());
481                            if ui.button("OK").clicked() {
482                                action = Some(DesktopTwitterAction::Close);
483                            }
484                        });
485                }
486                DialogNotice::None => {}
487            }
488        });
489
490        if !output.platform_output.copied_text.is_empty()
491            && let Err(err) = write_system_clipboard(&output.platform_output.copied_text)
492        {
493            log::debug!("desktop Twitter clipboard write failed: {err:#}");
494        }
495
496        let screen_desc = ScreenDescriptor {
497            size_in_pixels: [size.width, size.height],
498            pixels_per_point: scale,
499        };
500        let paint_jobs = self.egui_ctx.tessellate(output.shapes, scale);
501        for (id, delta) in &output.textures_delta.set {
502            self.egui_renderer.update_texture(
503                &self.renderer.device,
504                &self.renderer.queue,
505                *id,
506                delta,
507            );
508        }
509        let frame = match self.renderer.surface.get_current_texture() {
510            Ok(frame) => frame,
511            Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => {
512                self.renderer
513                    .resize(self.renderer.config.width, self.renderer.config.height);
514                return Ok(action);
515            }
516            Err(wgpu::SurfaceError::OutOfMemory) => anyhow::bail!("Twitter surface out of memory"),
517            Err(wgpu::SurfaceError::Timeout) => return Ok(action),
518        };
519        let view = frame
520            .texture
521            .create_view(&wgpu::TextureViewDescriptor::default());
522        let mut encoder =
523            self.renderer
524                .device
525                .create_command_encoder(&wgpu::CommandEncoderDescriptor {
526                    label: Some("siglus_twitter_egui_encoder"),
527                });
528        self.egui_renderer.update_buffers(
529            &self.renderer.device,
530            &self.renderer.queue,
531            &mut encoder,
532            &paint_jobs,
533            &screen_desc,
534        );
535        {
536            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
537                label: Some("siglus_twitter_egui_pass"),
538                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
539                    view: &view,
540                    resolve_target: None,
541                    ops: wgpu::Operations {
542                        load: wgpu::LoadOp::Clear(wgpu::Color {
543                            r: 0.965,
544                            g: 0.970,
545                            b: 0.980,
546                            a: 1.0,
547                        }),
548                        store: wgpu::StoreOp::Store,
549                    },
550                })],
551                depth_stencil_attachment: None,
552                timestamp_writes: None,
553                occlusion_query_set: None,
554            });
555            self.egui_renderer
556                .render(&mut pass, &paint_jobs, &screen_desc);
557        }
558        self.renderer.queue.submit(Some(encoder.finish()));
559        frame.present();
560        for id in output.textures_delta.free {
561            self.egui_renderer.free_texture(&id);
562        }
563        Ok(action)
564    }
565}
566
567fn read_command_stdout(program: &str, args: &[&str]) -> Result<String> {
568    let output = Command::new(program)
569        .args(args)
570        .output()
571        .with_context(|| format!("run {program} for clipboard access"))?;
572    if !output.status.success() {
573        anyhow::bail!("{program} exited with {}", output.status);
574    }
575    String::from_utf8(output.stdout).context("clipboard text is not valid UTF-8")
576}
577
578#[cfg(target_os = "macos")]
579fn read_system_clipboard() -> Result<String> {
580    read_command_stdout("pbpaste", &[])
581}
582
583#[cfg(target_os = "windows")]
584fn read_system_clipboard() -> Result<String> {
585    read_command_stdout(
586        "powershell.exe",
587        &[
588            "-NoProfile",
589            "-NonInteractive",
590            "-Command",
591            "[Console]::OutputEncoding=[Text.UTF8Encoding]::new(); Get-Clipboard -Raw",
592        ],
593    )
594}
595
596#[cfg(target_os = "linux")]
597fn read_system_clipboard() -> Result<String> {
598    let candidates: &[(&str, &[&str])] = &[
599        ("wl-paste", &["--no-newline"]),
600        ("xclip", &["-selection", "clipboard", "-o"]),
601        ("xsel", &["--clipboard", "--output"]),
602    ];
603    let mut errors = Vec::new();
604    for (program, args) in candidates {
605        match read_command_stdout(program, args) {
606            Ok(text) => return Ok(text),
607            Err(err) => errors.push(format!("{program}: {err:#}")),
608        }
609    }
610    anyhow::bail!(
611        "no usable Wayland/X11 clipboard reader was found ({})",
612        errors.join("; ")
613    )
614}
615
616fn write_command_stdin(program: &str, args: &[&str], text: &str) -> Result<()> {
617    let mut child = Command::new(program)
618        .args(args)
619        .stdin(Stdio::piped())
620        .stdout(Stdio::null())
621        .stderr(Stdio::piped())
622        .spawn()
623        .with_context(|| format!("run {program} for clipboard access"))?;
624    child
625        .stdin
626        .as_mut()
627        .context("clipboard writer stdin is unavailable")?
628        .write_all(text.as_bytes())
629        .context("write clipboard text")?;
630    let output = child
631        .wait_with_output()
632        .context("wait for clipboard writer")?;
633    if !output.status.success() {
634        anyhow::bail!(
635            "{program} exited with {}: {}",
636            output.status,
637            String::from_utf8_lossy(&output.stderr).trim()
638        );
639    }
640    Ok(())
641}
642
643#[cfg(target_os = "macos")]
644pub(crate) fn write_system_clipboard(text: &str) -> Result<()> {
645    write_command_stdin("pbcopy", &[], text)
646}
647
648#[cfg(target_os = "windows")]
649pub(crate) fn write_system_clipboard(text: &str) -> Result<()> {
650    let status = Command::new("powershell.exe")
651        .args([
652            "-NoProfile",
653            "-NonInteractive",
654            "-Command",
655            "Set-Clipboard -Value $env:SIGLUS_CLIPBOARD_TEXT",
656        ])
657        .env("SIGLUS_CLIPBOARD_TEXT", text)
658        .status()
659        .context("run powershell.exe for clipboard access")?;
660    if !status.success() {
661        anyhow::bail!("powershell.exe exited with {status}");
662    }
663    Ok(())
664}
665
666#[cfg(target_os = "linux")]
667pub(crate) fn write_system_clipboard(text: &str) -> Result<()> {
668    let candidates: &[(&str, &[&str])] = &[
669        ("wl-copy", &[]),
670        ("xclip", &["-selection", "clipboard", "-i"]),
671        ("xsel", &["--clipboard", "--input"]),
672    ];
673    let mut errors = Vec::new();
674    for (program, args) in candidates {
675        match write_command_stdin(program, args, text) {
676            Ok(()) => return Ok(()),
677            Err(err) => errors.push(format!("{program}: {err:#}")),
678        }
679    }
680    anyhow::bail!(
681        "no usable Wayland/X11 clipboard writer was found ({})",
682        errors.join("; ")
683    )
684}
685
686fn map_pointer_button(button: MouseButton) -> Option<egui::PointerButton> {
687    match button {
688        MouseButton::Left => Some(egui::PointerButton::Primary),
689        MouseButton::Right => Some(egui::PointerButton::Secondary),
690        MouseButton::Middle => Some(egui::PointerButton::Middle),
691        _ => None,
692    }
693}
694
695fn map_modifiers(state: ModifiersState) -> egui::Modifiers {
696    egui::Modifiers {
697        alt: state.alt_key(),
698        ctrl: state.control_key(),
699        shift: state.shift_key(),
700        mac_cmd: cfg!(target_os = "macos") && state.meta_key(),
701        command: if cfg!(target_os = "macos") {
702            state.meta_key()
703        } else {
704            state.control_key()
705        },
706    }
707}
708
709fn map_key(code: KeyCode) -> Option<egui::Key> {
710    use KeyCode::*;
711    Some(match code {
712        ArrowDown => egui::Key::ArrowDown,
713        ArrowLeft => egui::Key::ArrowLeft,
714        ArrowRight => egui::Key::ArrowRight,
715        ArrowUp => egui::Key::ArrowUp,
716        Escape => egui::Key::Escape,
717        Tab => egui::Key::Tab,
718        Backspace => egui::Key::Backspace,
719        Enter | NumpadEnter => egui::Key::Enter,
720        Space => egui::Key::Space,
721        Insert => egui::Key::Insert,
722        Delete => egui::Key::Delete,
723        Home => egui::Key::Home,
724        End => egui::Key::End,
725        PageUp => egui::Key::PageUp,
726        PageDown => egui::Key::PageDown,
727        KeyA => egui::Key::A,
728        KeyC => egui::Key::C,
729        KeyV => egui::Key::V,
730        KeyX => egui::Key::X,
731        KeyY => egui::Key::Y,
732        KeyZ => egui::Key::Z,
733        _ => return None,
734    })
735}