Skip to main content

oxide_browser/
ui.rs

1//! Desktop GUI for Oxide using [egui](https://github.com/emilk/egui) and [eframe](https://github.com/emilk/egui/tree/master/crates/eframe).
2//!
3//! This module implements a multi-tab browser shell: toolbar, canvas renderer, console panel,
4//! bookmarks sidebar, and an about dialog. The canvas draws guest WebAssembly output—rectangles,
5//! circles, text, lines, and images—via the host’s draw command stream. Interactive widgets
6//! (buttons, checkboxes, sliders, text inputs) are issued by the guest and rendered each frame.
7//! Hyperlinks drawn on the canvas are hit-tested so clicks trigger navigation.
8//!
9//! **Shortcuts:** Cmd+T new tab, Cmd+W close tab, Ctrl+Tab next tab, Cmd+D toggle bookmark for the
10//! current page, Cmd+B toggle the bookmarks panel.
11use std::collections::HashMap;
12use std::sync::{Arc, Mutex};
13use std::time::Instant;
14
15use eframe::egui;
16
17use crate::bookmarks::BookmarkStore;
18use crate::capabilities::{ConsoleLevel, DrawCommand, HostState, WidgetCommand, WidgetValue};
19use crate::engine::ModuleLoader;
20use crate::navigation::HistoryEntry;
21use crate::runtime::{LiveModule, PageStatus};
22
23enum RunRequest {
24    FetchAndRun { url: String },
25    LoadLocal(Vec<u8>),
26}
27
28struct RunResult {
29    error: Option<String>,
30    live_module: Option<LiveModule>,
31}
32
33// Send is required to pass LiveModule through the channel.
34// Store<HostState> is Send because HostState fields are Arc<Mutex<>>.
35unsafe impl Send for RunResult {}
36
37// ── Per-tab state ───────────────────────────────────────────────────────────
38
39struct TabState {
40    id: u64,
41    url_input: String,
42    host_state: HostState,
43    status: Arc<Mutex<PageStatus>>,
44    show_console: bool,
45    run_tx: std::sync::mpsc::Sender<RunRequest>,
46    run_rx: Arc<Mutex<std::sync::mpsc::Receiver<RunResult>>>,
47    image_textures: HashMap<usize, egui::TextureHandle>,
48    canvas_generation: u64,
49    pending_history_url: Option<String>,
50    hovered_link_url: Option<String>,
51    live_module: Option<LiveModule>,
52    last_frame: Instant,
53}
54
55impl TabState {
56    fn new(id: u64, host_state: HostState, status: Arc<Mutex<PageStatus>>) -> Self {
57        let (req_tx, req_rx) = std::sync::mpsc::channel::<RunRequest>();
58        let (res_tx, res_rx) = std::sync::mpsc::channel::<RunResult>();
59
60        let hs = host_state.clone();
61        let st = status.clone();
62
63        std::thread::spawn(move || {
64            let rt = tokio::runtime::Runtime::new().unwrap();
65            while let Ok(request) = req_rx.recv() {
66                let mut host = crate::runtime::BrowserHost::recreate(hs.clone(), st.clone());
67                let result = match request {
68                    RunRequest::FetchAndRun { url } => rt.block_on(host.fetch_and_run(&url)),
69                    RunRequest::LoadLocal(bytes) => host.run_bytes(&bytes),
70                };
71                let (error, live_module) = match result {
72                    Ok(live) => (None, live),
73                    Err(e) => (Some(e.to_string()), None),
74                };
75                let _ = res_tx.send(RunResult { error, live_module });
76            }
77        });
78
79        Self {
80            id,
81            url_input: String::from("https://"),
82            host_state,
83            status,
84            show_console: true,
85            run_tx: req_tx,
86            run_rx: Arc::new(Mutex::new(res_rx)),
87            image_textures: HashMap::new(),
88            canvas_generation: 0,
89            pending_history_url: None,
90            hovered_link_url: None,
91            live_module: None,
92            last_frame: Instant::now(),
93        }
94    }
95
96    fn display_title(&self) -> String {
97        let status = self.status.lock().unwrap().clone();
98        match status {
99            PageStatus::Idle => "New Tab".to_string(),
100            PageStatus::Loading(_) => "Loading\u{2026}".to_string(),
101            PageStatus::Running(ref url) => url_to_title(url),
102            PageStatus::Error(_) => "Error".to_string(),
103        }
104    }
105
106    // ── Navigation ──────────────────────────────────────────────────────
107
108    fn navigate(&mut self) {
109        let url = self.url_input.trim().to_string();
110        if url.is_empty() {
111            return;
112        }
113        self.pending_history_url = Some(url.clone());
114        let _ = self.run_tx.send(RunRequest::FetchAndRun { url });
115    }
116
117    fn navigate_to(&mut self, url: String, push_history: bool) {
118        self.url_input = url.clone();
119        if push_history {
120            self.pending_history_url = Some(url.clone());
121        }
122        let _ = self.run_tx.send(RunRequest::FetchAndRun { url });
123    }
124
125    fn go_back(&mut self) {
126        let entry = {
127            let mut nav = self.host_state.navigation.lock().unwrap();
128            nav.go_back().cloned()
129        };
130        if let Some(entry) = entry {
131            self.url_input = entry.url.clone();
132            *self.host_state.current_url.lock().unwrap() = entry.url.clone();
133            let _ = self.run_tx.send(RunRequest::FetchAndRun { url: entry.url });
134        }
135    }
136
137    fn go_forward(&mut self) {
138        let entry = {
139            let mut nav = self.host_state.navigation.lock().unwrap();
140            nav.go_forward().cloned()
141        };
142        if let Some(entry) = entry {
143            self.url_input = entry.url.clone();
144            *self.host_state.current_url.lock().unwrap() = entry.url.clone();
145            let _ = self.run_tx.send(RunRequest::FetchAndRun { url: entry.url });
146        }
147    }
148
149    fn load_local_file(&mut self) {
150        if let Some(path) = rfd::FileDialog::new()
151            .add_filter("WebAssembly", &["wasm"])
152            .set_title("Open .wasm Application")
153            .pick_file()
154        {
155            if let Ok(bytes) = std::fs::read(&path) {
156                let file_url = format!("file://{}", path.display());
157                self.url_input = file_url.clone();
158                self.pending_history_url = Some(file_url);
159                let _ = self.run_tx.send(RunRequest::LoadLocal(bytes));
160            }
161        }
162    }
163
164    // ── Frame lifecycle ─────────────────────────────────────────────────
165
166    fn capture_input(&self, ctx: &egui::Context) {
167        let mut input = self.host_state.input_state.lock().unwrap();
168
169        ctx.input(|i| {
170            if let Some(pos) = i.pointer.hover_pos() {
171                input.mouse_x = pos.x;
172                input.mouse_y = pos.y;
173            }
174
175            input.mouse_buttons_down[0] = i.pointer.primary_down();
176            input.mouse_buttons_down[1] = i.pointer.secondary_down();
177            input.mouse_buttons_down[2] = i.pointer.middle_down();
178
179            input.mouse_buttons_clicked[0] = i.pointer.primary_clicked();
180            input.mouse_buttons_clicked[1] = i.pointer.secondary_clicked();
181            input.mouse_buttons_clicked[2] = i.pointer.middle_down() && i.pointer.any_pressed();
182
183            input.modifiers_shift = i.modifiers.shift;
184            input.modifiers_ctrl = i.modifiers.ctrl;
185            input.modifiers_alt = i.modifiers.alt;
186
187            input.scroll_x = i.smooth_scroll_delta.x;
188            input.scroll_y = i.smooth_scroll_delta.y;
189
190            input.keys_down.clear();
191            input.keys_pressed.clear();
192            for event in &i.events {
193                if let egui::Event::Key { key, pressed, .. } = event {
194                    if let Some(code) = egui_key_to_oxide(key) {
195                        if *pressed {
196                            input.keys_pressed.push(code);
197                        }
198                        if *pressed {
199                            input.keys_down.push(code);
200                        }
201                    }
202                }
203            }
204        });
205    }
206
207    fn tick_frame(&mut self) {
208        if self.live_module.is_none() {
209            return;
210        }
211
212        let now = Instant::now();
213        let dt = now - self.last_frame;
214        self.last_frame = now;
215        let dt_ms = dt.as_millis().min(100) as u32;
216
217        self.host_state.widget_commands.lock().unwrap().clear();
218
219        if let Some(ref mut live) = self.live_module {
220            match live.tick(dt_ms) {
221                Ok(()) => {}
222                Err(e) => {
223                    let msg = if e.to_string().contains("fuel") {
224                        "on_frame halted: fuel limit exceeded".to_string()
225                    } else {
226                        format!("on_frame error: {e}")
227                    };
228                    crate::capabilities::console_log(
229                        &self.host_state.console,
230                        ConsoleLevel::Error,
231                        msg.clone(),
232                    );
233                    *self.status.lock().unwrap() = PageStatus::Error(msg);
234                    self.live_module = None;
235                    return;
236                }
237            }
238        }
239
240        self.host_state.widget_clicked.lock().unwrap().clear();
241    }
242
243    fn drain_results(&mut self) {
244        if let Ok(rx) = self.run_rx.lock() {
245            while let Ok(result) = rx.try_recv() {
246                if let Some(err) = result.error {
247                    *self.status.lock().unwrap() = PageStatus::Error(err);
248                    self.pending_history_url = None;
249                    self.live_module = None;
250                } else {
251                    if let Some(url) = self.pending_history_url.take() {
252                        let mut nav = self.host_state.navigation.lock().unwrap();
253                        nav.push(HistoryEntry::new(&url));
254                    }
255                    self.host_state.widget_states.lock().unwrap().clear();
256                    self.host_state.widget_clicked.lock().unwrap().clear();
257                    self.host_state.widget_commands.lock().unwrap().clear();
258                    self.live_module = result.live_module;
259                    self.last_frame = Instant::now();
260                }
261            }
262        }
263    }
264
265    fn handle_pending_navigation(&mut self) {
266        let pending = self.host_state.pending_navigation.lock().unwrap().take();
267        if let Some(url) = pending {
268            self.navigate_to(url, true);
269        }
270    }
271
272    fn sync_url_bar(&mut self) {
273        let cur = self.host_state.current_url.lock().unwrap().clone();
274        if !cur.is_empty() && cur != self.url_input {
275            let status = self.status.lock().unwrap().clone();
276            if matches!(status, PageStatus::Running(_)) {
277                self.url_input = cur;
278            }
279        }
280    }
281
282    // ── Rendering ───────────────────────────────────────────────────────
283
284    fn render_toolbar(
285        &mut self,
286        ctx: &egui::Context,
287        bookmark_store: &Option<BookmarkStore>,
288        show_bookmarks: &mut bool,
289        show_about: &mut bool,
290    ) {
291        let can_back = self.host_state.navigation.lock().unwrap().can_go_back();
292        let can_fwd = self.host_state.navigation.lock().unwrap().can_go_forward();
293
294        let status_icon = match &*self.status.lock().unwrap() {
295            PageStatus::Idle => "\u{26AA}",
296            PageStatus::Loading(_) => "\u{1F504}",
297            PageStatus::Running(_) => "\u{1F7E2}",
298            PageStatus::Error(_) => "\u{1F534}",
299        }
300        .to_string();
301
302        let status = self.status.lock().unwrap().clone();
303
304        let current_url = self.url_input.clone();
305        let is_bookmarked = bookmark_store
306            .as_ref()
307            .map(|s| s.contains(&current_url))
308            .unwrap_or(false);
309
310        let mut toggle_bookmark = false;
311        let mut toggle_panel = false;
312
313        egui::TopBottomPanel::top("toolbar").show(ctx, |ui| {
314            ui.horizontal(|ui| {
315                ui.spacing_mut().item_spacing.x = 6.0;
316
317                let back_btn = ui.add_enabled(
318                    can_back,
319                    egui::Button::new(egui::RichText::new("\u{25C0}").size(14.0))
320                        .corner_radius(12.0)
321                        .min_size(egui::vec2(28.0, 28.0))
322                        .frame(false),
323                );
324                if back_btn.clicked() {
325                    self.go_back();
326                }
327                if back_btn.hovered() && can_back {
328                    back_btn.on_hover_text("Back");
329                }
330
331                let fwd_btn = ui.add_enabled(
332                    can_fwd,
333                    egui::Button::new(egui::RichText::new("\u{25B6}").size(14.0))
334                        .corner_radius(12.0)
335                        .min_size(egui::vec2(28.0, 28.0))
336                        .frame(false),
337                );
338                if fwd_btn.clicked() {
339                    self.go_forward();
340                }
341                if fwd_btn.hovered() && can_fwd {
342                    fwd_btn.on_hover_text("Forward");
343                }
344
345                ui.label(egui::RichText::new(&status_icon).size(16.0));
346
347                let response = ui.add(
348                    egui::TextEdit::singleline(&mut self.url_input)
349                        .desired_width(ui.available_width() - 190.0)
350                        .hint_text("Enter .wasm URL...")
351                        .font(egui::TextStyle::Monospace),
352                );
353                if response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
354                    self.navigate();
355                }
356
357                let has_url =
358                    !self.url_input.trim().is_empty() && self.url_input.trim() != "https://";
359
360                if has_url {
361                    let star = if is_bookmarked {
362                        "\u{2605}" // filled star
363                    } else {
364                        "\u{2606}" // outline star
365                    };
366                    let star_color = if is_bookmarked {
367                        egui::Color32::from_rgb(255, 200, 50)
368                    } else {
369                        egui::Color32::from_rgb(160, 160, 170)
370                    };
371                    let star_btn = ui.add(
372                        egui::Button::new(egui::RichText::new(star).size(18.0).color(star_color))
373                            .frame(false)
374                            .min_size(egui::vec2(28.0, 28.0)),
375                    );
376                    if star_btn.clicked() {
377                        toggle_bookmark = true;
378                    }
379                    star_btn.on_hover_text(if is_bookmarked {
380                        "Remove bookmark"
381                    } else {
382                        "Add bookmark"
383                    });
384                }
385
386                if ui.button("Go").clicked() {
387                    self.navigate();
388                }
389                if ui.button("Open File").clicked() {
390                    self.load_local_file();
391                }
392
393                let book_size = egui::vec2(28.0, 28.0);
394                let (book_rect, book_resp) =
395                    ui.allocate_exact_size(book_size, egui::Sense::click());
396                if ui.is_rect_visible(book_rect) {
397                    let c = book_rect.center();
398                    let book_color = if *show_bookmarks {
399                        egui::Color32::from_rgb(255, 200, 50)
400                    } else if book_resp.hovered() {
401                        egui::Color32::from_rgb(220, 220, 230)
402                    } else {
403                        egui::Color32::from_rgb(160, 160, 170)
404                    };
405                    let stroke = egui::Stroke::new(1.5, book_color);
406                    let hw = 6.0;
407                    let hh = 7.0;
408                    let spine_x = c.x - hw;
409                    ui.painter().rect_stroke(
410                        egui::Rect::from_min_size(
411                            egui::pos2(spine_x, c.y - hh),
412                            egui::vec2(hw * 2.0, hh * 2.0),
413                        ),
414                        egui::CornerRadius::same(2),
415                        stroke,
416                        egui::StrokeKind::Outside,
417                    );
418                    ui.painter().line_segment(
419                        [egui::pos2(c.x, c.y - hh), egui::pos2(c.x, c.y + hh)],
420                        stroke,
421                    );
422                }
423                if book_resp.clicked() {
424                    toggle_panel = true;
425                }
426                book_resp.on_hover_text(if *show_bookmarks {
427                    "Hide bookmarks"
428                } else {
429                    "Show bookmarks"
430                });
431
432                // ── Three-dots overflow menu ─────────────────────
433                let dot_size = egui::vec2(28.0, 28.0);
434                let (menu_rect, menu_resp) = ui.allocate_exact_size(dot_size, egui::Sense::click());
435                if ui.is_rect_visible(menu_rect) {
436                    let c = menu_rect.center();
437                    let dot_color = if menu_resp.hovered() {
438                        egui::Color32::from_rgb(220, 220, 230)
439                    } else {
440                        egui::Color32::from_rgb(160, 160, 170)
441                    };
442                    let r = 2.0;
443                    let gap = 5.0;
444                    ui.painter()
445                        .circle_filled(c + egui::vec2(0.0, -gap), r, dot_color);
446                    ui.painter().circle_filled(c, r, dot_color);
447                    ui.painter()
448                        .circle_filled(c + egui::vec2(0.0, gap), r, dot_color);
449                }
450                let menu_id = ui.make_persistent_id("toolbar_overflow_menu");
451                if menu_resp.clicked() {
452                    ui.memory_mut(|mem| mem.toggle_popup(menu_id));
453                }
454                let menu_resp = menu_resp.on_hover_text("Menu");
455                egui::popup_below_widget(
456                    ui,
457                    menu_id,
458                    &menu_resp,
459                    egui::PopupCloseBehavior::CloseOnClick,
460                    |ui| {
461                        ui.set_min_width(160.0);
462
463                        let console_label = if self.show_console {
464                            "Hide Console"
465                        } else {
466                            "Show Console"
467                        };
468                        if ui.button(console_label).clicked() {
469                            self.show_console = !self.show_console;
470                        }
471
472                        ui.separator();
473
474                        if ui.button("About Oxide").clicked() {
475                            *show_about = true;
476                        }
477                    },
478                );
479            });
480
481            if let PageStatus::Error(ref msg) = status {
482                ui.colored_label(egui::Color32::from_rgb(220, 50, 50), msg);
483            }
484        });
485
486        if toggle_bookmark {
487            if let Some(store) = bookmark_store.as_ref() {
488                if is_bookmarked {
489                    let _ = store.remove(&current_url);
490                } else {
491                    let title = url_to_title(&current_url);
492                    let _ = store.add(&current_url, &title);
493                }
494            }
495        }
496
497        if toggle_panel {
498            *show_bookmarks = !*show_bookmarks;
499        }
500    }
501
502    fn render_canvas(&mut self, ctx: &egui::Context) {
503        // Phase 1: Update texture cache from decoded images.
504        {
505            let canvas = self.host_state.canvas.lock().unwrap();
506            if canvas.generation != self.canvas_generation {
507                self.image_textures.clear();
508                self.canvas_generation = canvas.generation;
509            }
510            let tab_id = self.id;
511            for (i, decoded) in canvas.images.iter().enumerate() {
512                self.image_textures.entry(i).or_insert_with(|| {
513                    let color_image = egui::ColorImage::from_rgba_unmultiplied(
514                        [decoded.width as usize, decoded.height as usize],
515                        &decoded.pixels,
516                    );
517                    ctx.load_texture(
518                        format!("oxide_img_{i}_tab{tab_id}"),
519                        color_image,
520                        egui::TextureOptions::LINEAR,
521                    )
522                });
523            }
524        }
525
526        // Phase 2: Clone commands and hyperlinks.
527        let commands = self.host_state.canvas.lock().unwrap().commands.clone();
528        let hyperlinks = self.host_state.hyperlinks.lock().unwrap().clone();
529        let widget_commands = self.host_state.widget_commands.lock().unwrap().clone();
530        let tex_ids: HashMap<usize, egui::TextureId> = self
531            .image_textures
532            .iter()
533            .map(|(k, v)| (*k, v.id()))
534            .collect();
535
536        let host_state = self.host_state.clone();
537        let canvas_offset = self.host_state.canvas_offset.clone();
538
539        // Phase 3: Render.
540        self.hovered_link_url = None;
541        let mut new_hovered: Option<String> = None;
542        let mut clicked_link: Option<String> = None;
543
544        egui::CentralPanel::default().show(ctx, |ui| {
545            if commands.is_empty() && widget_commands.is_empty() {
546                ui.vertical_centered(|ui| {
547                    ui.add_space(ui.available_height() / 3.0);
548                    ui.heading(
549                        egui::RichText::new("Oxide Browser")
550                            .size(32.0)
551                            .color(egui::Color32::from_rgb(180, 120, 255)),
552                    );
553                    ui.label(
554                        egui::RichText::new("A binary-first browser for WebAssembly applications")
555                            .size(14.0)
556                            .color(egui::Color32::GRAY),
557                    );
558                    ui.add_space(8.0);
559                    ui.label(
560                        egui::RichText::new(
561                            "Enter a .wasm URL above or open a local file to get started.",
562                        )
563                        .color(egui::Color32::from_rgb(140, 140, 140)),
564                    );
565                });
566                return;
567            }
568
569            let available = ui.available_size();
570            let (response, painter) = ui.allocate_painter(available, egui::Sense::click());
571            let rect = response.rect;
572
573            *canvas_offset.lock().unwrap() = (rect.min.x, rect.min.y);
574
575            // ── Draw commands ───────────────────────────────────────
576            for cmd in &commands {
577                match cmd {
578                    DrawCommand::Clear { r, g, b, a } => {
579                        painter.rect_filled(
580                            rect,
581                            0.0,
582                            egui::Color32::from_rgba_unmultiplied(*r, *g, *b, *a),
583                        );
584                    }
585                    DrawCommand::Rect {
586                        x,
587                        y,
588                        w,
589                        h,
590                        r,
591                        g,
592                        b,
593                        a,
594                    } => {
595                        let min = rect.min + egui::vec2(*x, *y);
596                        let r2 = egui::Rect::from_min_size(min, egui::vec2(*w, *h));
597                        painter.rect_filled(
598                            r2,
599                            0.0,
600                            egui::Color32::from_rgba_unmultiplied(*r, *g, *b, *a),
601                        );
602                    }
603                    DrawCommand::Circle {
604                        cx,
605                        cy,
606                        radius,
607                        r,
608                        g,
609                        b,
610                        a,
611                    } => {
612                        let center = rect.min + egui::vec2(*cx, *cy);
613                        painter.circle_filled(
614                            center,
615                            *radius,
616                            egui::Color32::from_rgba_unmultiplied(*r, *g, *b, *a),
617                        );
618                    }
619                    DrawCommand::Text {
620                        x,
621                        y,
622                        size,
623                        r,
624                        g,
625                        b,
626                        text,
627                    } => {
628                        let pos = rect.min + egui::vec2(*x, *y);
629                        painter.text(
630                            pos,
631                            egui::Align2::LEFT_TOP,
632                            text,
633                            egui::FontId::proportional(*size),
634                            egui::Color32::from_rgb(*r, *g, *b),
635                        );
636                    }
637                    DrawCommand::Line {
638                        x1,
639                        y1,
640                        x2,
641                        y2,
642                        r,
643                        g,
644                        b,
645                        thickness,
646                    } => {
647                        let p1 = rect.min + egui::vec2(*x1, *y1);
648                        let p2 = rect.min + egui::vec2(*x2, *y2);
649                        painter.line_segment(
650                            [p1, p2],
651                            egui::Stroke::new(*thickness, egui::Color32::from_rgb(*r, *g, *b)),
652                        );
653                    }
654                    DrawCommand::Image {
655                        x,
656                        y,
657                        w,
658                        h,
659                        image_id,
660                    } => {
661                        if let Some(tex_id) = tex_ids.get(image_id) {
662                            let img_rect = egui::Rect::from_min_size(
663                                rect.min + egui::vec2(*x, *y),
664                                egui::vec2(*w, *h),
665                            );
666                            let uv = egui::Rect::from_min_max(
667                                egui::pos2(0.0, 0.0),
668                                egui::pos2(1.0, 1.0),
669                            );
670                            painter.image(*tex_id, img_rect, uv, egui::Color32::WHITE);
671                        }
672                    }
673                }
674            }
675
676            // ── Interactive widgets ─────────────────────────────────
677            if !widget_commands.is_empty() {
678                let mut widget_states = host_state.widget_states.lock().unwrap();
679                let mut widget_clicked = host_state.widget_clicked.lock().unwrap();
680
681                for cmd in &widget_commands {
682                    match cmd {
683                        WidgetCommand::Button {
684                            id,
685                            x,
686                            y,
687                            w,
688                            h,
689                            label,
690                        } => {
691                            let wr = egui::Rect::from_min_size(
692                                rect.min + egui::vec2(*x, *y),
693                                egui::vec2(*w, *h),
694                            );
695                            if ui.put(wr, egui::Button::new(label.as_str())).clicked() {
696                                widget_clicked.insert(*id);
697                            }
698                        }
699                        WidgetCommand::Checkbox { id, x, y, label } => {
700                            let mut checked = match widget_states.get(id) {
701                                Some(WidgetValue::Bool(b)) => *b,
702                                _ => false,
703                            };
704                            let wr = egui::Rect::from_min_size(
705                                rect.min + egui::vec2(*x, *y),
706                                egui::vec2(250.0, 24.0),
707                            );
708                            if ui
709                                .put(wr, egui::Checkbox::new(&mut checked, label.as_str()))
710                                .changed()
711                            {
712                                widget_states.insert(*id, WidgetValue::Bool(checked));
713                            }
714                        }
715                        WidgetCommand::Slider {
716                            id,
717                            x,
718                            y,
719                            w,
720                            min,
721                            max,
722                        } => {
723                            let mut value = match widget_states.get(id) {
724                                Some(WidgetValue::Float(v)) => *v,
725                                _ => *min,
726                            };
727                            let wr = egui::Rect::from_min_size(
728                                rect.min + egui::vec2(*x, *y),
729                                egui::vec2(*w, 24.0),
730                            );
731                            if ui
732                                .put(wr, egui::Slider::new(&mut value, *min..=*max))
733                                .changed()
734                            {
735                                widget_states.insert(*id, WidgetValue::Float(value));
736                            }
737                        }
738                        WidgetCommand::TextInput { id, x, y, w } => {
739                            let mut text = match widget_states.get(id) {
740                                Some(WidgetValue::Text(t)) => t.clone(),
741                                _ => String::new(),
742                            };
743                            let wr = egui::Rect::from_min_size(
744                                rect.min + egui::vec2(*x, *y),
745                                egui::vec2(*w, 24.0),
746                            );
747                            let te = egui::TextEdit::singleline(&mut text)
748                                .desired_width(*w)
749                                .id(egui::Id::new(("oxide_text_input", *id)));
750                            if ui.put(wr, te).changed() {
751                                widget_states.insert(*id, WidgetValue::Text(text));
752                            }
753                        }
754                    }
755                }
756            }
757
758            // ── Hyperlink underlines ────────────────────────────────
759            for link in &hyperlinks {
760                let link_rect = egui::Rect::from_min_size(
761                    rect.min + egui::vec2(link.x, link.y),
762                    egui::vec2(link.w, link.h),
763                );
764                painter.line_segment(
765                    [link_rect.left_bottom(), link_rect.right_bottom()],
766                    egui::Stroke::new(
767                        1.0,
768                        egui::Color32::from_rgba_unmultiplied(120, 140, 255, 80),
769                    ),
770                );
771            }
772
773            // ── Hyperlink hover / click detection ───────────────────
774            if let Some(pointer_pos) = response.hover_pos() {
775                for link in &hyperlinks {
776                    let link_rect = egui::Rect::from_min_size(
777                        rect.min + egui::vec2(link.x, link.y),
778                        egui::vec2(link.w, link.h),
779                    );
780                    if link_rect.contains(pointer_pos) {
781                        ctx.set_cursor_icon(egui::CursorIcon::PointingHand);
782                        new_hovered = Some(link.url.clone());
783
784                        painter.rect_filled(
785                            link_rect,
786                            0.0,
787                            egui::Color32::from_rgba_unmultiplied(120, 140, 255, 30),
788                        );
789
790                        if response.clicked() {
791                            clicked_link = Some(link.url.clone());
792                        }
793                        break;
794                    }
795                }
796            }
797        });
798
799        self.hovered_link_url = new_hovered;
800        if let Some(url) = clicked_link {
801            self.navigate_to(url, true);
802        }
803    }
804
805    fn render_console(&mut self, ctx: &egui::Context) {
806        egui::TopBottomPanel::bottom("console")
807            .resizable(true)
808            .default_height(160.0)
809            .show(ctx, |ui| {
810                ui.horizontal(|ui| {
811                    ui.label(egui::RichText::new("Console").strong());
812                    if ui.small_button("Clear").clicked() {
813                        self.host_state.console.lock().unwrap().clear();
814                    }
815                });
816                ui.separator();
817
818                egui::ScrollArea::vertical()
819                    .stick_to_bottom(true)
820                    .auto_shrink([false; 2])
821                    .show(ui, |ui| {
822                        let entries = self.host_state.console.lock().unwrap().clone();
823                        for entry in &entries {
824                            let color = match entry.level {
825                                ConsoleLevel::Log => egui::Color32::from_rgb(200, 200, 200),
826                                ConsoleLevel::Warn => egui::Color32::from_rgb(240, 200, 60),
827                                ConsoleLevel::Error => egui::Color32::from_rgb(240, 70, 70),
828                            };
829                            ui.horizontal(|ui| {
830                                ui.label(
831                                    egui::RichText::new(&entry.timestamp)
832                                        .monospace()
833                                        .color(egui::Color32::from_rgb(100, 100, 100))
834                                        .size(11.0),
835                                );
836                                ui.label(
837                                    egui::RichText::new(&entry.message)
838                                        .monospace()
839                                        .color(color)
840                                        .size(12.0),
841                                );
842                            });
843                        }
844                    });
845            });
846    }
847}
848
849// ── Application ─────────────────────────────────────────────────────────────
850
851/// Main [`eframe::App`] implementation: owns tab state, bookmarks, and browser chrome (navigation,
852/// panels, and dialogs) for the Oxide desktop shell.
853pub struct OxideApp {
854    tabs: Vec<TabState>,
855    active_tab: usize,
856    next_tab_id: u64,
857    shared_kv_db: Option<Arc<sled::Db>>,
858    shared_module_loader: Option<Arc<ModuleLoader>>,
859    bookmark_store: Option<BookmarkStore>,
860    show_bookmarks: bool,
861    show_about: bool,
862}
863
864impl OxideApp {
865    pub fn new(host_state: HostState, status: Arc<Mutex<PageStatus>>) -> Self {
866        let shared_kv_db = host_state.kv_db.clone();
867        let shared_module_loader = host_state.module_loader.clone();
868        let bookmark_store = host_state.bookmark_store.lock().unwrap().clone();
869
870        let first_tab = TabState::new(0, host_state, status);
871
872        Self {
873            tabs: vec![first_tab],
874            active_tab: 0,
875            next_tab_id: 1,
876            shared_kv_db,
877            shared_module_loader,
878            bookmark_store,
879            show_bookmarks: false,
880            show_about: false,
881        }
882    }
883
884    fn create_tab(&mut self) -> usize {
885        let bm_shared: crate::bookmarks::SharedBookmarkStore =
886            Arc::new(Mutex::new(self.bookmark_store.clone()));
887        let host_state = HostState {
888            kv_db: self.shared_kv_db.clone(),
889            module_loader: self.shared_module_loader.clone(),
890            bookmark_store: bm_shared,
891            ..Default::default()
892        };
893        let status = Arc::new(Mutex::new(PageStatus::Idle));
894        let tab = TabState::new(self.next_tab_id, host_state, status);
895        self.next_tab_id += 1;
896        self.tabs.push(tab);
897        self.tabs.len() - 1
898    }
899
900    fn close_tab(&mut self, idx: usize) {
901        if self.tabs.len() <= 1 {
902            return;
903        }
904        self.tabs.remove(idx);
905        if self.active_tab == idx {
906            if self.active_tab >= self.tabs.len() {
907                self.active_tab = self.tabs.len() - 1;
908            }
909        } else if self.active_tab > idx {
910            self.active_tab -= 1;
911        }
912    }
913
914    fn handle_keyboard_shortcuts(&mut self, ctx: &egui::Context) {
915        let (new_tab, close_tab, next_tab, prev_tab, toggle_bookmark, toggle_panel) =
916            ctx.input(|i| {
917                let cmd = i.modifiers.command;
918                (
919                    cmd && i.key_pressed(egui::Key::T),
920                    cmd && i.key_pressed(egui::Key::W),
921                    i.modifiers.ctrl && !i.modifiers.shift && i.key_pressed(egui::Key::Tab),
922                    i.modifiers.ctrl && i.modifiers.shift && i.key_pressed(egui::Key::Tab),
923                    cmd && i.key_pressed(egui::Key::D),
924                    cmd && i.key_pressed(egui::Key::B),
925                )
926            });
927
928        if new_tab {
929            let idx = self.create_tab();
930            self.active_tab = idx;
931        }
932        if close_tab && self.tabs.len() > 1 {
933            let active = self.active_tab;
934            self.close_tab(active);
935        }
936        if next_tab && !self.tabs.is_empty() {
937            self.active_tab = (self.active_tab + 1) % self.tabs.len();
938        }
939        if prev_tab && !self.tabs.is_empty() {
940            if self.active_tab == 0 {
941                self.active_tab = self.tabs.len() - 1;
942            } else {
943                self.active_tab -= 1;
944            }
945        }
946        if toggle_bookmark {
947            self.toggle_active_bookmark();
948        }
949        if toggle_panel {
950            self.show_bookmarks = !self.show_bookmarks;
951        }
952    }
953
954    fn toggle_active_bookmark(&self) {
955        let url = self.tabs[self.active_tab].url_input.trim().to_string();
956        if url.is_empty() || url == "https://" {
957            return;
958        }
959        if let Some(store) = &self.bookmark_store {
960            if store.contains(&url) {
961                let _ = store.remove(&url);
962            } else {
963                let title = url_to_title(&url);
964                let _ = store.add(&url, &title);
965            }
966        }
967    }
968}
969
970impl eframe::App for OxideApp {
971    fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
972        self.handle_keyboard_shortcuts(ctx);
973
974        // Drain results and handle navigations for all tabs so background loads complete.
975        for tab in &mut self.tabs {
976            tab.drain_results();
977            tab.handle_pending_navigation();
978            tab.sync_url_bar();
979        }
980
981        ctx.request_repaint();
982
983        let active = self.active_tab;
984        self.tabs[active].capture_input(ctx);
985        self.tabs[active].tick_frame();
986
987        self.render_tab_bar(ctx);
988
989        let bm_store = self.bookmark_store.clone();
990        let mut show_bm = self.show_bookmarks;
991        let mut show_about = self.show_about;
992        self.tabs[self.active_tab].render_toolbar(ctx, &bm_store, &mut show_bm, &mut show_about);
993        self.show_bookmarks = show_bm;
994        self.show_about = show_about;
995
996        let mut nav_to_url: Option<String> = None;
997        if self.show_bookmarks {
998            nav_to_url = Self::render_bookmarks_panel(ctx, &self.bookmark_store);
999        }
1000
1001        self.tabs[self.active_tab].render_canvas(ctx);
1002
1003        if self.tabs[self.active_tab].show_console {
1004            self.tabs[self.active_tab].render_console(ctx);
1005        }
1006
1007        if let Some(url) = nav_to_url {
1008            self.tabs[self.active_tab].navigate_to(url, true);
1009        }
1010
1011        if let Some(link_url) = self.tabs[self.active_tab].hovered_link_url.clone() {
1012            egui::TopBottomPanel::bottom("link_status")
1013                .default_height(18.0)
1014                .show(ctx, |ui| {
1015                    ui.label(
1016                        egui::RichText::new(&link_url)
1017                            .monospace()
1018                            .size(11.0)
1019                            .color(egui::Color32::from_rgb(140, 140, 180)),
1020                    );
1021                });
1022        }
1023
1024        if self.show_about {
1025            self.render_about_modal(ctx);
1026        }
1027    }
1028}
1029
1030impl OxideApp {
1031    fn render_about_modal(&mut self, ctx: &egui::Context) {
1032        egui::Window::new("About Oxide")
1033            .collapsible(false)
1034            .resizable(false)
1035            .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
1036            .fixed_size([360.0, 0.0])
1037            .show(ctx, |ui| {
1038                ui.vertical_centered(|ui| {
1039                    ui.add_space(8.0);
1040                    ui.heading(
1041                        egui::RichText::new("Oxide Browser")
1042                            .size(24.0)
1043                            .strong()
1044                            .color(egui::Color32::from_rgb(180, 120, 255)),
1045                    );
1046                    ui.add_space(4.0);
1047                    ui.label(
1048                        egui::RichText::new(format!("Version {}", env!("CARGO_PKG_VERSION")))
1049                            .size(13.0)
1050                            .color(egui::Color32::from_rgb(160, 160, 170)),
1051                    );
1052                    ui.add_space(12.0);
1053                });
1054
1055                ui.label("A binary-first, decentralized browser that loads and runs WebAssembly modules instead of HTML/JavaScript.");
1056                ui.add_space(8.0);
1057
1058                egui::Grid::new("about_details")
1059                    .num_columns(2)
1060                    .spacing([12.0, 4.0])
1061                    .show(ui, |ui| {
1062                        ui.label(
1063                            egui::RichText::new("Runtime")
1064                                .strong()
1065                                .color(egui::Color32::from_rgb(180, 180, 190)),
1066                        );
1067                        ui.label("Wasmtime");
1068                        ui.end_row();
1069
1070                        ui.label(
1071                            egui::RichText::new("UI")
1072                                .strong()
1073                                .color(egui::Color32::from_rgb(180, 180, 190)),
1074                        );
1075                        ui.label("egui / eframe");
1076                        ui.end_row();
1077
1078                        ui.label(
1079                            egui::RichText::new("Sandbox")
1080                                .strong()
1081                                .color(egui::Color32::from_rgb(180, 180, 190)),
1082                        );
1083                        ui.label("Capability-based, 16 MB memory limit");
1084                        ui.end_row();
1085
1086                        ui.label(
1087                            egui::RichText::new("Storage")
1088                                .strong()
1089                                .color(egui::Color32::from_rgb(180, 180, 190)),
1090                        );
1091                        ui.label("sled embedded KV store");
1092                        ui.end_row();
1093
1094                        ui.label(
1095                            egui::RichText::new("License")
1096                                .strong()
1097                                .color(egui::Color32::from_rgb(180, 180, 190)),
1098                        );
1099                        ui.label("MIT");
1100                        ui.end_row();
1101                    });
1102
1103                ui.add_space(12.0);
1104                ui.vertical_centered(|ui| {
1105                    if ui.button("Close").clicked() {
1106                        self.show_about = false;
1107                    }
1108                });
1109                ui.add_space(4.0);
1110            });
1111    }
1112}
1113
1114impl OxideApp {
1115    fn render_tab_bar(&mut self, ctx: &egui::Context) {
1116        let mut switch_to = None;
1117        let mut close_idx = None;
1118        let mut open_new = false;
1119        let num_tabs = self.tabs.len();
1120
1121        egui::TopBottomPanel::top("tab_bar")
1122            .exact_height(30.0)
1123            .show(ctx, |ui| {
1124                ui.horizontal_centered(|ui| {
1125                    ui.spacing_mut().item_spacing.x = 2.0;
1126
1127                    for i in 0..num_tabs {
1128                        let is_active = i == self.active_tab;
1129                        let title = self.tabs[i].display_title();
1130
1131                        let bg = if is_active {
1132                            egui::Color32::from_rgb(55, 55, 65)
1133                        } else {
1134                            egui::Color32::TRANSPARENT
1135                        };
1136
1137                        egui::Frame::NONE
1138                            .fill(bg)
1139                            .inner_margin(4.0)
1140                            .corner_radius(4.0)
1141                            .show(ui, |ui| {
1142                                ui.horizontal(|ui| {
1143                                    ui.spacing_mut().item_spacing.x = 4.0;
1144
1145                                    let text_color = if is_active {
1146                                        egui::Color32::from_rgb(220, 220, 230)
1147                                    } else {
1148                                        egui::Color32::from_rgb(150, 150, 160)
1149                                    };
1150
1151                                    let max_len = 22;
1152                                    let display = if title.chars().count() > max_len {
1153                                        let truncated: String =
1154                                            title.chars().take(max_len).collect();
1155                                        format!("{truncated}\u{2026}")
1156                                    } else {
1157                                        title
1158                                    };
1159
1160                                    let tab_label = ui.add(
1161                                        egui::Label::new(
1162                                            egui::RichText::new(&display)
1163                                                .color(text_color)
1164                                                .size(12.0),
1165                                        )
1166                                        .sense(egui::Sense::click()),
1167                                    );
1168                                    if tab_label.clicked() {
1169                                        switch_to = Some(i);
1170                                    }
1171
1172                                    if num_tabs > 1 {
1173                                        let close_color = if is_active {
1174                                            egui::Color32::from_rgb(160, 160, 170)
1175                                        } else {
1176                                            egui::Color32::from_rgb(100, 100, 110)
1177                                        };
1178                                        let close_btn = ui.add(
1179                                            egui::Label::new(
1180                                                egui::RichText::new("\u{00D7}")
1181                                                    .color(close_color)
1182                                                    .size(16.0),
1183                                            )
1184                                            .sense(egui::Sense::click()),
1185                                        );
1186                                        if close_btn.clicked() {
1187                                            close_idx = Some(i);
1188                                        }
1189                                    }
1190                                });
1191                            });
1192                    }
1193
1194                    ui.add_space(4.0);
1195
1196                    let shortcut_hint = if cfg!(target_os = "macos") {
1197                        "New tab (\u{2318}T)"
1198                    } else {
1199                        "New tab (Ctrl+T)"
1200                    };
1201                    if ui
1202                        .add(
1203                            egui::Button::new(egui::RichText::new("+").size(16.0))
1204                                .frame(false)
1205                                .min_size(egui::vec2(24.0, 22.0)),
1206                        )
1207                        .on_hover_text(shortcut_hint)
1208                        .clicked()
1209                    {
1210                        open_new = true;
1211                    }
1212                });
1213            });
1214
1215        if let Some(i) = close_idx {
1216            self.close_tab(i);
1217        }
1218        if open_new {
1219            let idx = self.create_tab();
1220            self.active_tab = idx;
1221        }
1222        if let Some(i) = switch_to {
1223            if i < self.tabs.len() {
1224                self.active_tab = i;
1225            }
1226        }
1227    }
1228}
1229
1230impl OxideApp {
1231    fn render_bookmarks_panel(
1232        ctx: &egui::Context,
1233        bookmark_store: &Option<BookmarkStore>,
1234    ) -> Option<String> {
1235        let store = bookmark_store.as_ref()?;
1236
1237        let all = store.list_all();
1238        let favorites: Vec<_> = all.iter().filter(|b| b.is_favorite).collect();
1239        let regular: Vec<_> = all.iter().filter(|b| !b.is_favorite).collect();
1240
1241        let mut navigate_url: Option<String> = None;
1242        let mut toggle_fav_url: Option<String> = None;
1243        let mut remove_url: Option<String> = None;
1244
1245        egui::SidePanel::left("bookmarks_panel")
1246            .default_width(260.0)
1247            .resizable(true)
1248            .show(ctx, |ui| {
1249                ui.heading(
1250                    egui::RichText::new("\u{2605} Bookmarks")
1251                        .size(16.0)
1252                        .color(egui::Color32::from_rgb(255, 200, 50)),
1253                );
1254                ui.separator();
1255
1256                if all.is_empty() {
1257                    ui.add_space(20.0);
1258                    ui.label(
1259                        egui::RichText::new("No bookmarks yet.\nClick the \u{2606} star in the toolbar to bookmark a page.")
1260                            .color(egui::Color32::from_rgb(130, 130, 140))
1261                            .size(12.0),
1262                    );
1263                    return;
1264                }
1265
1266                egui::ScrollArea::vertical()
1267                    .auto_shrink([false; 2])
1268                    .show(ui, |ui| {
1269                        if !favorites.is_empty() {
1270                            ui.label(
1271                                egui::RichText::new("Favorites")
1272                                    .strong()
1273                                    .size(13.0)
1274                                    .color(egui::Color32::from_rgb(255, 200, 50)),
1275                            );
1276                            ui.add_space(4.0);
1277                            for bm in &favorites {
1278                                render_bookmark_row(
1279                                    ui,
1280                                    bm,
1281                                    &mut navigate_url,
1282                                    &mut toggle_fav_url,
1283                                    &mut remove_url,
1284                                );
1285                            }
1286                            if !regular.is_empty() {
1287                                ui.add_space(8.0);
1288                                ui.separator();
1289                                ui.add_space(4.0);
1290                            }
1291                        }
1292
1293                        if !regular.is_empty() {
1294                            ui.label(
1295                                egui::RichText::new("All Bookmarks")
1296                                    .strong()
1297                                    .size(13.0)
1298                                    .color(egui::Color32::from_rgb(180, 180, 190)),
1299                            );
1300                            ui.add_space(4.0);
1301                            for bm in &regular {
1302                                render_bookmark_row(
1303                                    ui,
1304                                    bm,
1305                                    &mut navigate_url,
1306                                    &mut toggle_fav_url,
1307                                    &mut remove_url,
1308                                );
1309                            }
1310                        }
1311                    });
1312            });
1313
1314        if let Some(url) = toggle_fav_url {
1315            let _ = store.toggle_favorite(&url);
1316        }
1317        if let Some(url) = remove_url {
1318            let _ = store.remove(&url);
1319        }
1320
1321        navigate_url
1322    }
1323}
1324
1325fn render_bookmark_row(
1326    ui: &mut egui::Ui,
1327    bm: &crate::bookmarks::Bookmark,
1328    navigate_url: &mut Option<String>,
1329    toggle_fav_url: &mut Option<String>,
1330    remove_url: &mut Option<String>,
1331) {
1332    let max_title_len = 28;
1333    let display_title = if bm.title.is_empty() {
1334        url_to_title(&bm.url)
1335    } else {
1336        bm.title.clone()
1337    };
1338    let truncated = if display_title.chars().count() > max_title_len {
1339        let t: String = display_title.chars().take(max_title_len).collect();
1340        format!("{t}\u{2026}")
1341    } else {
1342        display_title
1343    };
1344
1345    ui.horizontal(|ui| {
1346        ui.spacing_mut().item_spacing.x = 4.0;
1347
1348        let fav_icon = if bm.is_favorite {
1349            "\u{2605}"
1350        } else {
1351            "\u{2606}"
1352        };
1353        let fav_color = if bm.is_favorite {
1354            egui::Color32::from_rgb(255, 200, 50)
1355        } else {
1356            egui::Color32::from_rgb(120, 120, 130)
1357        };
1358        let fav_btn = ui.add(
1359            egui::Label::new(egui::RichText::new(fav_icon).color(fav_color).size(14.0))
1360                .sense(egui::Sense::click()),
1361        );
1362        if fav_btn.clicked() {
1363            *toggle_fav_url = Some(bm.url.clone());
1364        }
1365        fav_btn.on_hover_text(if bm.is_favorite {
1366            "Unfavorite"
1367        } else {
1368            "Mark as favorite"
1369        });
1370
1371        let link = ui.add(
1372            egui::Label::new(
1373                egui::RichText::new(&truncated)
1374                    .color(egui::Color32::from_rgb(170, 190, 255))
1375                    .size(12.5),
1376            )
1377            .sense(egui::Sense::click()),
1378        );
1379        if link.clicked() {
1380            *navigate_url = Some(bm.url.clone());
1381        }
1382        link.on_hover_text(&bm.url);
1383
1384        let del_btn = ui.add(
1385            egui::Label::new(
1386                egui::RichText::new("\u{00D7}")
1387                    .color(egui::Color32::from_rgb(140, 100, 100))
1388                    .size(14.0),
1389            )
1390            .sense(egui::Sense::click()),
1391        );
1392        if del_btn.clicked() {
1393            *remove_url = Some(bm.url.clone());
1394        }
1395        del_btn.on_hover_text("Remove bookmark");
1396    });
1397}
1398
1399// ── Helpers ─────────────────────────────────────────────────────────────────
1400
1401fn url_to_title(url: &str) -> String {
1402    if url == "(local)" {
1403        return "Local Module".to_string();
1404    }
1405    if let Some(stripped) = url
1406        .strip_prefix("https://")
1407        .or_else(|| url.strip_prefix("http://"))
1408    {
1409        stripped.split('/').next().unwrap_or(stripped).to_string()
1410    } else if let Some(stripped) = url.strip_prefix("file://") {
1411        stripped
1412            .rsplit('/')
1413            .next()
1414            .unwrap_or("Local File")
1415            .to_string()
1416    } else {
1417        let max = 20;
1418        if url.chars().count() > max {
1419            let truncated: String = url.chars().take(max).collect();
1420            format!("{truncated}\u{2026}")
1421        } else {
1422            url.to_string()
1423        }
1424    }
1425}
1426
1427fn egui_key_to_oxide(key: &egui::Key) -> Option<u32> {
1428    use egui::Key::*;
1429    match key {
1430        A => Some(0),
1431        B => Some(1),
1432        C => Some(2),
1433        D => Some(3),
1434        E => Some(4),
1435        F => Some(5),
1436        G => Some(6),
1437        H => Some(7),
1438        I => Some(8),
1439        J => Some(9),
1440        K => Some(10),
1441        L => Some(11),
1442        M => Some(12),
1443        N => Some(13),
1444        O => Some(14),
1445        P => Some(15),
1446        Q => Some(16),
1447        R => Some(17),
1448        S => Some(18),
1449        T => Some(19),
1450        U => Some(20),
1451        V => Some(21),
1452        W => Some(22),
1453        X => Some(23),
1454        Y => Some(24),
1455        Z => Some(25),
1456        Num0 => Some(26),
1457        Num1 => Some(27),
1458        Num2 => Some(28),
1459        Num3 => Some(29),
1460        Num4 => Some(30),
1461        Num5 => Some(31),
1462        Num6 => Some(32),
1463        Num7 => Some(33),
1464        Num8 => Some(34),
1465        Num9 => Some(35),
1466        Enter => Some(36),
1467        Escape => Some(37),
1468        Tab => Some(38),
1469        Backspace => Some(39),
1470        Delete => Some(40),
1471        Space => Some(41),
1472        ArrowUp => Some(42),
1473        ArrowDown => Some(43),
1474        ArrowLeft => Some(44),
1475        ArrowRight => Some(45),
1476        Home => Some(46),
1477        End => Some(47),
1478        PageUp => Some(48),
1479        PageDown => Some(49),
1480        _ => None,
1481    }
1482}