1#![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 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}