1use 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
33unsafe impl Send for RunResult {}
36
37struct 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 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 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 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(¤t_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}" } else {
364 "\u{2606}" };
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 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(¤t_url);
490 } else {
491 let title = url_to_title(¤t_url);
492 let _ = store.add(¤t_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 {
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 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 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 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 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 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 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
849pub 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 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 ®ular {
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
1399fn 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}