oxide_browser/ui.rs
1//! Desktop shell for Oxide using [GPUI](https://www.gpui.rs/) (Zed’s GPU-accelerated UI framework).
2//!
3//! Guest canvas commands are painted with [`Window::paint_quad`], [`Window::paint_path`],
4//! [`Window::paint_image`], and GPU text shaping — bitmaps (including video frames) are uploaded as
5//! [`RenderImage`] textures and composited on the GPU.
6//!
7//! ## Public API
8//!
9//! - [`run_browser`] — Start the GPUI [`Application`] and open the main browser window; pass
10//! [`HostState`] and page status from [`crate::runtime::BrowserHost`].
11//! - [`OxideBrowserView`] — Root view: tabs, toolbar, canvas [`canvas`] element, console, and bookmarks.
12
13use std::collections::{HashMap, HashSet};
14use std::path::PathBuf;
15use std::sync::atomic::Ordering;
16use std::sync::mpsc::{self, TryRecvError};
17use std::sync::{Arc, Mutex};
18use std::time::{Instant, SystemTime, UNIX_EPOCH};
19
20use gpui::prelude::*;
21use gpui::{
22 canvas, div, font, img, point, px, size, Application, Bounds, ClickEvent, FocusHandle,
23 ImageSource, InteractiveElement, KeyDownEvent, KeyUpEvent, Keystroke, MouseButton,
24 MouseDownEvent, MouseUpEvent, PathBuilder, Pixels, Point, Render, RenderImage, Rgba,
25 ScrollDelta, ScrollWheelEvent, SharedString, TextRun, TitlebarOptions, Window, WindowBounds,
26 WindowKind, WindowOptions,
27};
28use image::Frame;
29use smallvec::smallvec;
30
31use crate::bookmarks::BookmarkStore;
32use crate::capabilities::{
33 ConsoleLevel, DrawCommand, GradientStop, HostState, WidgetCommand, WidgetValue,
34};
35use crate::download::{format_bytes, DownloadManager, DownloadState};
36use crate::engine::ModuleLoader;
37use crate::forge::{
38 ForgeChatMessage, ForgeCreationSummary, ForgeMessageRole, ForgePhase, ForgeSnapshot, ForgeState,
39};
40use crate::forge_config::{mask_api_key, ForgeProvider, ForgeUserConfig};
41use crate::history::HistoryStore;
42use crate::navigation::HistoryEntry;
43use crate::runtime::{LiveModule, PageStatus};
44
45enum RunRequest {
46 FetchAndRun { url: String },
47 LoadLocal(Vec<u8>),
48}
49
50struct RunResult {
51 error: Option<String>,
52 live_module: Option<LiveModule>,
53}
54
55// SAFETY: `LiveModule` contains a wasmtime `Store<HostState>` whose fields are
56// behind `Arc<Mutex<…>>`, making them safe to send across threads. The `error`
57// field is a plain `Option<String>`.
58unsafe impl Send for RunResult {}
59
60#[derive(Clone, PartialEq)]
61enum InternalPage {
62 Home,
63 History,
64 Bookmarks,
65 About,
66 Forge,
67}
68
69fn try_internal_page(url: &str) -> Option<InternalPage> {
70 match url {
71 "oxide://home" => Some(InternalPage::Home),
72 "oxide://history" => Some(InternalPage::History),
73 "oxide://bookmarks" => Some(InternalPage::Bookmarks),
74 "oxide://about" => Some(InternalPage::About),
75 "oxide://forge" => Some(InternalPage::Forge),
76 _ => None,
77 }
78}
79
80fn format_friendly_timestamp(timestamp_ms: u64) -> String {
81 let now_ms = SystemTime::now()
82 .duration_since(UNIX_EPOCH)
83 .unwrap_or_default()
84 .as_millis() as u64;
85 let diff_secs = now_ms.saturating_sub(timestamp_ms) / 1000;
86 if diff_secs < 60 {
87 "Just now".to_string()
88 } else if diff_secs < 3600 {
89 let m = diff_secs / 60;
90 if m == 1 {
91 "1 minute ago".to_string()
92 } else {
93 format!("{m} minutes ago")
94 }
95 } else if diff_secs < 86400 {
96 let h = diff_secs / 3600;
97 if h == 1 {
98 "1 hour ago".to_string()
99 } else {
100 format!("{h} hours ago")
101 }
102 } else {
103 let d = diff_secs / 86400;
104 if d == 1 {
105 "Yesterday".to_string()
106 } else {
107 format!("{d} days ago")
108 }
109 }
110}
111
112struct TabState {
113 id: u64,
114 url_input: String,
115 host_state: HostState,
116 status: Arc<Mutex<PageStatus>>,
117 show_console: bool,
118 run_tx: std::sync::mpsc::Sender<RunRequest>,
119 run_rx: Arc<Mutex<std::sync::mpsc::Receiver<RunResult>>>,
120 /// GPU texture cache for decoded canvas images (video frames use the same path).
121 image_textures: HashMap<usize, Arc<RenderImage>>,
122 pip_texture: Option<Arc<RenderImage>>,
123 pip_last_serial: u64,
124 canvas_generation: u64,
125 pending_history_url: Option<String>,
126 hovered_link_url: Option<String>,
127 live_module: Option<LiveModule>,
128 last_frame: Instant,
129 keys_held: HashSet<u32>,
130 /// Guest `TextInput` widget id with keyboard focus, if any.
131 text_input_focus: Option<u32>,
132 /// Cursor byte offset in `url_input`.
133 url_cursor: usize,
134 /// Selection anchor byte offset; when != `url_cursor`, the range between them is selected.
135 url_sel_start: usize,
136 /// True while the mouse button is held to drag-select in the URL bar.
137 url_selecting: bool,
138 /// Bounds of the URL text canvas element, for mouse hit-testing.
139 url_text_bounds: Arc<Mutex<Bounds<Pixels>>>,
140 internal_page: Option<InternalPage>,
141 /// Draft prompt for `oxide://forge`. Cleared when a session is started.
142 forge_prompt: String,
143 /// Forge session id being viewed / generated in this tab, if any.
144 forge_session_id: Option<u64>,
145}
146
147impl TabState {
148 fn new(id: u64, host_state: HostState, status: Arc<Mutex<PageStatus>>) -> Self {
149 let (req_tx, req_rx) = std::sync::mpsc::channel::<RunRequest>();
150 let (res_tx, res_rx) = std::sync::mpsc::channel::<RunResult>();
151
152 let hs = host_state.clone();
153 let st = status.clone();
154
155 std::thread::spawn(move || {
156 let rt = tokio::runtime::Runtime::new().unwrap();
157 while let Ok(request) = req_rx.recv() {
158 let mut host = crate::runtime::BrowserHost::recreate(hs.clone(), st.clone());
159 let result = match request {
160 RunRequest::FetchAndRun { url } => rt.block_on(host.fetch_and_run(&url)),
161 RunRequest::LoadLocal(bytes) => host.run_bytes(&bytes),
162 };
163 let (error, live_module) = match result {
164 Ok(live) => (None, live),
165 Err(e) => (Some(e.to_string()), None),
166 };
167 let _ = res_tx.send(RunResult { error, live_module });
168 }
169 });
170
171 Self {
172 id,
173 url_input: String::from("oxide://home"),
174 host_state,
175 status,
176 show_console: false,
177 run_tx: req_tx,
178 run_rx: Arc::new(Mutex::new(res_rx)),
179 image_textures: HashMap::new(),
180 pip_texture: None,
181 pip_last_serial: 0,
182 canvas_generation: 0,
183 pending_history_url: None,
184 hovered_link_url: None,
185 live_module: None,
186 last_frame: Instant::now(),
187 keys_held: HashSet::new(),
188 text_input_focus: None,
189 url_cursor: 12,
190 url_sel_start: 12,
191 url_selecting: false,
192 url_text_bounds: Arc::new(Mutex::new(Bounds::default())),
193 internal_page: Some(InternalPage::Home),
194 forge_prompt: String::new(),
195 forge_session_id: None,
196 }
197 .with_home_status()
198 }
199
200 fn with_home_status(self) -> Self {
201 *self.status.lock().unwrap() = PageStatus::Running("oxide://home".to_string());
202 self
203 }
204
205 fn display_title(&self) -> String {
206 let status = self.status.lock().unwrap().clone();
207 match status {
208 PageStatus::Idle => "New Tab".to_string(),
209 PageStatus::Loading(_) => "Loading\u{2026}".to_string(),
210 PageStatus::Running(ref url) => url_to_title(url),
211 PageStatus::Error(_) => "Error".to_string(),
212 }
213 }
214
215 fn navigate(&mut self, dm: &DownloadManager) {
216 let mut url = self.url_input.trim().to_string();
217 if url.is_empty() {
218 return;
219 }
220 let is_search = (!url.contains('.')
221 && !url.starts_with("oxide://")
222 && !url.starts_with("http://")
223 && !url.starts_with("https://"))
224 || url.contains(' ');
225 if is_search {
226 self.forge_prompt = url;
227 url = "oxide://forge".to_string();
228 self.url_input = url.clone();
229 self.url_clamp_cursor();
230 }
231 if let Some(page) = try_internal_page(&url) {
232 self.internal_page = Some(page);
233 self.live_module = None;
234 *self.status.lock().unwrap() = PageStatus::Running(url.clone());
235 let mut nav = self.host_state.navigation.lock().unwrap();
236 nav.push(HistoryEntry::new(&url));
237 return;
238 }
239 if is_downloadable_url(&url) {
240 dm.start_download(url);
241 return;
242 }
243 self.internal_page = None;
244 self.pending_history_url = Some(url.clone());
245 let _ = self.run_tx.send(RunRequest::FetchAndRun { url });
246 }
247
248 fn navigate_to(&mut self, mut url: String, push_history: bool, dm: &DownloadManager) {
249 let is_search = (!url.contains('.')
250 && !url.starts_with("oxide://")
251 && !url.starts_with("http://")
252 && !url.starts_with("https://"))
253 || url.contains(' ');
254 if is_search {
255 self.forge_prompt = url;
256 url = "oxide://forge".to_string();
257 }
258 self.url_input = url.clone();
259 let len = self.url_input.len();
260 self.url_cursor = len;
261 self.url_sel_start = len;
262 if let Some(page) = try_internal_page(&url) {
263 self.internal_page = Some(page);
264 self.live_module = None;
265 *self.status.lock().unwrap() = PageStatus::Running(url.clone());
266 if push_history {
267 let mut nav = self.host_state.navigation.lock().unwrap();
268 nav.push(HistoryEntry::new(&url));
269 }
270 return;
271 }
272 if is_downloadable_url(&url) {
273 dm.start_download(url);
274 return;
275 }
276 self.internal_page = None;
277 if push_history {
278 self.pending_history_url = Some(url.clone());
279 }
280 let _ = self.run_tx.send(RunRequest::FetchAndRun { url });
281 }
282
283 fn reload(&mut self) {
284 *self.host_state.scroll_x.lock().unwrap() = 0.0;
285 *self.host_state.scroll_y.lock().unwrap() = 0.0;
286 let url = self.url_input.clone();
287 if !url.is_empty() {
288 if let Some(page) = try_internal_page(&url) {
289 self.internal_page = Some(page);
290 self.live_module = None;
291 *self.status.lock().unwrap() = PageStatus::Running(url.clone());
292 } else {
293 self.internal_page = None;
294 let _ = self.run_tx.send(RunRequest::FetchAndRun { url });
295 }
296 }
297 }
298
299 fn go_back(&mut self) {
300 let entry = {
301 let mut nav = self.host_state.navigation.lock().unwrap();
302 nav.go_back().cloned()
303 };
304 if let Some(entry) = entry {
305 self.url_input = entry.url.clone();
306 self.url_clamp_cursor();
307 *self.host_state.current_url.lock().unwrap() = entry.url.clone();
308 if let Some(page) = try_internal_page(&entry.url) {
309 self.internal_page = Some(page);
310 self.live_module = None;
311 *self.status.lock().unwrap() = PageStatus::Running(entry.url);
312 } else {
313 self.internal_page = None;
314 let _ = self.run_tx.send(RunRequest::FetchAndRun { url: entry.url });
315 }
316 }
317 }
318
319 fn go_forward(&mut self) {
320 let entry = {
321 let mut nav = self.host_state.navigation.lock().unwrap();
322 nav.go_forward().cloned()
323 };
324 if let Some(entry) = entry {
325 self.url_input = entry.url.clone();
326 self.url_clamp_cursor();
327 *self.host_state.current_url.lock().unwrap() = entry.url.clone();
328 if let Some(page) = try_internal_page(&entry.url) {
329 self.internal_page = Some(page);
330 self.live_module = None;
331 *self.status.lock().unwrap() = PageStatus::Running(entry.url);
332 } else {
333 self.internal_page = None;
334 let _ = self.run_tx.send(RunRequest::FetchAndRun { url: entry.url });
335 }
336 }
337 }
338
339 fn drain_results(&mut self) {
340 if let Ok(rx) = self.run_rx.lock() {
341 while let Ok(result) = rx.try_recv() {
342 if let Some(err) = result.error {
343 *self.status.lock().unwrap() = PageStatus::Error(err);
344 self.pending_history_url = None;
345 self.live_module = None;
346 } else {
347 self.internal_page = None;
348 if let Some(url) = self.pending_history_url.take() {
349 let mut nav = self.host_state.navigation.lock().unwrap();
350 nav.push(HistoryEntry::new(&url));
351 drop(nav);
352 if let Some(store) = self.host_state.history_store.lock().unwrap().as_ref()
353 {
354 let title = url_to_title(&url);
355 let _ = store.record(&url, &title);
356 }
357 }
358 self.host_state.widget_states.lock().unwrap().clear();
359 self.host_state.widget_clicked.lock().unwrap().clear();
360 self.host_state.widget_commands.lock().unwrap().clear();
361 self.live_module = result.live_module;
362 self.last_frame = Instant::now();
363 }
364 }
365 }
366 }
367
368 fn handle_pending_navigation(&mut self, dm: &DownloadManager) {
369 let pending = self.host_state.pending_navigation.lock().unwrap().take();
370 if let Some(url) = pending {
371 self.navigate_to(url, true, dm);
372 }
373 }
374
375 fn sync_url_bar(&mut self) {
376 let cur = self.host_state.current_url.lock().unwrap().clone();
377 if !cur.is_empty() && cur != self.url_input {
378 let status = self.status.lock().unwrap().clone();
379 if matches!(status, PageStatus::Running(_)) {
380 self.url_input = cur;
381 self.url_clamp_cursor();
382 }
383 }
384 }
385
386 fn url_clamp_cursor(&mut self) {
387 let len = self.url_input.len();
388 self.url_cursor = self.url_cursor.min(len);
389 self.url_sel_start = self.url_sel_start.min(len);
390 }
391
392 fn url_has_selection(&self) -> bool {
393 self.url_cursor != self.url_sel_start
394 }
395
396 fn url_sel_range(&self) -> std::ops::Range<usize> {
397 let lo = self.url_cursor.min(self.url_sel_start);
398 let hi = self.url_cursor.max(self.url_sel_start);
399 lo..hi
400 }
401
402 fn url_prev_boundary(&self) -> usize {
403 let text = &self.url_input;
404 if self.url_cursor == 0 {
405 return 0;
406 }
407 let mut i = self.url_cursor - 1;
408 while i > 0 && !text.is_char_boundary(i) {
409 i -= 1;
410 }
411 i
412 }
413
414 fn url_next_boundary(&self) -> usize {
415 let text = &self.url_input;
416 if self.url_cursor >= text.len() {
417 return text.len();
418 }
419 let mut i = self.url_cursor + 1;
420 while i < text.len() && !text.is_char_boundary(i) {
421 i += 1;
422 }
423 i
424 }
425
426 fn url_move_to(&mut self, offset: usize) {
427 let offset = offset.min(self.url_input.len());
428 self.url_cursor = offset;
429 self.url_sel_start = offset;
430 }
431
432 fn url_select_to(&mut self, offset: usize) {
433 self.url_cursor = offset.min(self.url_input.len());
434 }
435
436 fn url_select_all(&mut self) {
437 self.url_sel_start = 0;
438 self.url_cursor = self.url_input.len();
439 }
440
441 fn url_delete_selection(&mut self) {
442 if !self.url_has_selection() {
443 return;
444 }
445 let range = self.url_sel_range();
446 self.url_input.replace_range(range.clone(), "");
447 self.url_cursor = range.start;
448 self.url_sel_start = range.start;
449 }
450
451 fn url_insert_at_cursor(&mut self, text: &str) {
452 if self.url_has_selection() {
453 self.url_delete_selection();
454 }
455 self.url_input.insert_str(self.url_cursor, text);
456 self.url_cursor += text.len();
457 self.url_sel_start = self.url_cursor;
458 }
459
460 fn url_backspace(&mut self) {
461 if self.url_has_selection() {
462 self.url_delete_selection();
463 } else if self.url_cursor > 0 {
464 let prev = self.url_prev_boundary();
465 self.url_input.replace_range(prev..self.url_cursor, "");
466 self.url_cursor = prev;
467 self.url_sel_start = prev;
468 }
469 }
470
471 fn url_delete_forward(&mut self) {
472 if self.url_has_selection() {
473 self.url_delete_selection();
474 } else if self.url_cursor < self.url_input.len() {
475 let next = self.url_next_boundary();
476 self.url_input.replace_range(self.url_cursor..next, "");
477 }
478 }
479
480 fn url_selected_text(&self) -> String {
481 if self.url_has_selection() {
482 self.url_input[self.url_sel_range()].to_string()
483 } else {
484 String::new()
485 }
486 }
487
488 fn sync_keys_held_to_input(&self) {
489 let mut input = self.host_state.input_state.lock().unwrap();
490 input.keys_down.clear();
491 input.keys_down.extend(self.keys_held.iter().copied());
492 }
493
494 fn tick_frame(&mut self) {
495 if self.live_module.is_none() {
496 return;
497 }
498
499 let now = Instant::now();
500 let dt = now - self.last_frame;
501 self.last_frame = now;
502 let dt_ms = dt.as_millis().min(100) as u32;
503
504 self.host_state.widget_commands.lock().unwrap().clear();
505
506 if let Some(ref mut live) = self.live_module {
507 match live.tick(dt_ms) {
508 Ok(()) => {}
509 Err(e) => {
510 let msg = if e.to_string().contains("fuel") {
511 "on_frame halted: fuel limit exceeded".to_string()
512 } else {
513 format!("on_frame error: {e}")
514 };
515 crate::capabilities::console_log(
516 &self.host_state.console,
517 crate::capabilities::ConsoleLevel::Error,
518 msg.clone(),
519 );
520 *self.status.lock().unwrap() = PageStatus::Error(msg);
521 self.live_module = None;
522 }
523 }
524 }
525
526 self.host_state.widget_clicked.lock().unwrap().clear();
527 }
528
529 fn post_tick_clear_input(&mut self) {
530 let mut input = self.host_state.input_state.lock().unwrap();
531 input.keys_pressed.clear();
532 input.mouse_buttons_clicked = [false; 3];
533 input.scroll_x = 0.0;
534 input.scroll_y = 0.0;
535 }
536
537 fn update_texture_cache(&mut self, _window: &mut Window) {
538 let tab_id = self.id;
539 let canvas = self.host_state.canvas.lock().unwrap();
540 if canvas.generation != self.canvas_generation {
541 self.image_textures.clear();
542 self.canvas_generation = canvas.generation;
543 }
544 for (i, decoded) in canvas.images.iter().enumerate() {
545 self.image_textures.entry(i).or_insert_with(|| {
546 decoded_to_render_image(decoded, format!("oxide_img_{i}_tab{tab_id}"))
547 });
548 }
549 }
550
551 fn refresh_pip_texture(&mut self, _window: &mut Window) {
552 let pip = self.host_state.video.lock().unwrap().pip;
553 if !pip {
554 self.pip_texture = None;
555 self.pip_last_serial = 0;
556 return;
557 }
558
559 let serial = *self.host_state.video_pip_serial.lock().unwrap();
560 if serial != self.pip_last_serial {
561 self.pip_last_serial = serial;
562 self.pip_texture = None;
563 let frame = self.host_state.video_pip_frame.lock().unwrap().clone();
564 if let Some(decoded) = frame {
565 self.pip_texture = Some(decoded_to_render_image(
566 &decoded,
567 format!("oxide_pip_{}_{}", self.id, serial),
568 ));
569 }
570 }
571 }
572}
573
574/// Decode RGBA guest bytes into a GPU [`RenderImage`] (BGRA upload for the renderer).
575fn decoded_to_render_image(
576 decoded: &crate::capabilities::DecodedImage,
577 _debug_label: String,
578) -> Arc<RenderImage> {
579 let mut buf = image::RgbaImage::from_raw(decoded.width, decoded.height, decoded.pixels.clone())
580 .expect("decoded image dimensions");
581 for pixel in buf.chunks_exact_mut(4) {
582 pixel.swap(0, 2);
583 }
584 let frame = Frame::new(buf);
585 Arc::new(RenderImage::new(smallvec![frame]))
586}
587
588fn rgba8(r: u8, g: u8, b: u8, a: u8) -> gpui::Hsla {
589 gpui::Hsla::from(Rgba {
590 r: r as f32 / 255.0,
591 g: g as f32 / 255.0,
592 b: b as f32 / 255.0,
593 a: a as f32 / 255.0,
594 })
595}
596
597fn circle_polygon(cx: f32, cy: f32, radius: f32) -> Vec<Point<Pixels>> {
598 let n = 24;
599 (0..n)
600 .map(|i| {
601 let t = i as f32 / n as f32 * std::f32::consts::TAU;
602 point(px(cx + radius * t.cos()), px(cy + radius * t.sin()))
603 })
604 .collect()
605}
606
607/// Saved canvas state for the transform/clip/opacity stack.
608#[derive(Clone)]
609struct CanvasPaintState {
610 offset_x: f32,
611 offset_y: f32,
612 clip: Option<Bounds<Pixels>>,
613 opacity: f32,
614}
615
616fn paint_draw_commands(
617 window: &mut Window,
618 cx: &mut gpui::App,
619 bounds: Bounds<Pixels>,
620 cmds: &[DrawCommand],
621 textures: &HashMap<usize, Arc<RenderImage>>,
622) {
623 let rect = bounds;
624 let origin_x = f32::from(rect.origin.x);
625 let origin_y = f32::from(rect.origin.y);
626
627 let mut state_stack: Vec<CanvasPaintState> = Vec::new();
628 let mut off_x = origin_x;
629 let mut off_y = origin_y;
630 let mut clip: Option<Bounds<Pixels>> = None;
631 let mut opacity: f32 = 1.0;
632
633 for cmd in cmds {
634 match cmd {
635 DrawCommand::Save => {
636 state_stack.push(CanvasPaintState {
637 offset_x: off_x,
638 offset_y: off_y,
639 clip,
640 opacity,
641 });
642 }
643 DrawCommand::Restore => {
644 if let Some(prev) = state_stack.pop() {
645 off_x = prev.offset_x;
646 off_y = prev.offset_y;
647 clip = prev.clip;
648 opacity = prev.opacity;
649 }
650 }
651 DrawCommand::Transform {
652 a: _,
653 b: _,
654 c: _,
655 d: _,
656 tx,
657 ty,
658 } => {
659 off_x += *tx;
660 off_y += *ty;
661 }
662 DrawCommand::Clip { x, y, w, h } => {
663 let new_clip = Bounds::from_corners(
664 point(px(off_x + *x), px(off_y + *y)),
665 point(px(off_x + *x + *w), px(off_y + *y + *h)),
666 );
667 clip = Some(match clip {
668 Some(existing) => intersect_bounds(existing, new_clip),
669 None => new_clip,
670 });
671 }
672 DrawCommand::Opacity { alpha } => {
673 opacity *= *alpha;
674 }
675
676 DrawCommand::Clear { r, g, b, a } => {
677 let ca = apply_opacity(*a, opacity);
678 window.paint_quad(gpui::fill(rect, rgba8(*r, *g, *b, ca)));
679 }
680 DrawCommand::Rect {
681 x,
682 y,
683 w,
684 h,
685 r,
686 g,
687 b,
688 a,
689 } => {
690 let min = point(px(off_x + *x), px(off_y + *y));
691 let cmd_bounds = Bounds::from_corners(min, min + point(px(*w), px(*h)));
692 if !clipped_out(clip, cmd_bounds) {
693 let ca = apply_opacity(*a, opacity);
694 window.paint_quad(gpui::fill(cmd_bounds, rgba8(*r, *g, *b, ca)));
695 }
696 }
697 DrawCommand::Circle {
698 cx,
699 cy,
700 radius,
701 r,
702 g,
703 b,
704 a,
705 } => {
706 let pts = circle_polygon(off_x + *cx, off_y + *cy, *radius);
707 let mut pb = PathBuilder::fill();
708 pb.add_polygon(&pts, true);
709 if let Ok(path) = pb.build() {
710 let ca = apply_opacity(*a, opacity);
711 window.paint_path(path, rgba8(*r, *g, *b, ca));
712 }
713 }
714 DrawCommand::Text {
715 x,
716 y,
717 size,
718 r,
719 g,
720 b,
721 a,
722 text,
723 } => {
724 let origin = point(px(off_x + *x), px(off_y + *y));
725 let text_owned = text.clone();
726 let ca = apply_opacity(*a, opacity);
727 let run = TextRun {
728 len: text_owned.len(),
729 font: font(".SystemUIFont"),
730 color: rgba8(*r, *g, *b, ca),
731 background_color: None,
732 underline: None,
733 strikethrough: None,
734 };
735 let line = window.text_system().shape_line(
736 SharedString::from(text_owned),
737 px(*size),
738 &[run],
739 None,
740 );
741 let _ = line.paint(origin, px(*size * 1.2), window, cx);
742 }
743 DrawCommand::TextEx {
744 x,
745 y,
746 size,
747 r,
748 g,
749 b,
750 a,
751 family,
752 weight,
753 style,
754 align,
755 text,
756 } => {
757 let text_owned = text.clone();
758 let ca = apply_opacity(*a, opacity);
759 let run = TextRun {
760 len: text_owned.len(),
761 font: crate::capabilities::make_gpui_font(family, *weight, *style),
762 color: rgba8(*r, *g, *b, ca),
763 background_color: None,
764 underline: None,
765 strikethrough: None,
766 };
767 let line = window.text_system().shape_line(
768 SharedString::from(text_owned),
769 px(*size),
770 &[run],
771 None,
772 );
773 let line_x = match *align {
774 1 => off_x + *x - f32::from(line.width) / 2.0,
775 2 => off_x + *x - f32::from(line.width),
776 _ => off_x + *x,
777 };
778 let origin = point(px(line_x), px(off_y + *y));
779 let _ = line.paint(origin, px(*size * 1.2), window, cx);
780 }
781 DrawCommand::Line {
782 x1,
783 y1,
784 x2,
785 y2,
786 r,
787 g,
788 b,
789 a,
790 thickness,
791 } => {
792 let p1 = point(px(off_x + *x1), px(off_y + *y1));
793 let p2 = point(px(off_x + *x2), px(off_y + *y2));
794 let mut pb = PathBuilder::stroke(px(*thickness));
795 pb.move_to(p1);
796 pb.line_to(p2);
797 if let Ok(path) = pb.build() {
798 let ca = apply_opacity(*a, opacity);
799 window.paint_path(path, rgba8(*r, *g, *b, ca));
800 }
801 }
802 DrawCommand::Image {
803 x,
804 y,
805 w,
806 h,
807 image_id,
808 } => {
809 if let Some(tex) = textures.get(image_id) {
810 let min = point(px(off_x + *x), px(off_y + *y));
811 let img_bounds = Bounds::from_corners(min, min + point(px(*w), px(*h)));
812 let _ = window.paint_image(img_bounds, (0.).into(), tex.clone(), 0, false);
813 }
814 }
815 DrawCommand::RoundedRect {
816 x,
817 y,
818 w,
819 h,
820 radius,
821 r,
822 g,
823 b,
824 a,
825 } => {
826 let min = point(px(off_x + *x), px(off_y + *y));
827 let cmd_bounds = Bounds::from_corners(min, min + point(px(*w), px(*h)));
828 if !clipped_out(clip, cmd_bounds) {
829 let ca = apply_opacity(*a, opacity);
830 let pts = rounded_rect_polygon(off_x + *x, off_y + *y, *w, *h, *radius);
831 let mut pb = PathBuilder::fill();
832 pb.add_polygon(&pts, true);
833 if let Ok(path) = pb.build() {
834 window.paint_path(path, rgba8(*r, *g, *b, ca));
835 }
836 }
837 }
838 DrawCommand::Arc {
839 cx,
840 cy,
841 radius,
842 start_angle,
843 end_angle,
844 r,
845 g,
846 b,
847 a,
848 thickness,
849 } => {
850 let pts = arc_polyline(off_x + *cx, off_y + *cy, *radius, *start_angle, *end_angle);
851 if pts.len() >= 2 {
852 let mut pb = PathBuilder::stroke(px(*thickness));
853 pb.move_to(pts[0]);
854 for p in &pts[1..] {
855 pb.line_to(*p);
856 }
857 if let Ok(path) = pb.build() {
858 let ca = apply_opacity(*a, opacity);
859 window.paint_path(path, rgba8(*r, *g, *b, ca));
860 }
861 }
862 }
863 DrawCommand::Bezier {
864 x1,
865 y1,
866 cp1x,
867 cp1y,
868 cp2x,
869 cp2y,
870 x2,
871 y2,
872 r,
873 g,
874 b,
875 a,
876 thickness,
877 } => {
878 let p1 = point(px(off_x + *x1), px(off_y + *y1));
879 let p2 = point(px(off_x + *x2), px(off_y + *y2));
880 let c1 = point(px(off_x + *cp1x), px(off_y + *cp1y));
881 let c2 = point(px(off_x + *cp2x), px(off_y + *cp2y));
882 let mut pb = PathBuilder::stroke(px(*thickness));
883 pb.move_to(p1);
884 pb.cubic_bezier_to(p2, c1, c2);
885 if let Ok(path) = pb.build() {
886 let ca = apply_opacity(*a, opacity);
887 window.paint_path(path, rgba8(*r, *g, *b, ca));
888 }
889 }
890 DrawCommand::Gradient {
891 x,
892 y,
893 w,
894 h,
895 kind,
896 ax: _,
897 ay: _,
898 bx: _,
899 by: _,
900 stops,
901 } => {
902 paint_gradient(
903 window,
904 &GradientParams {
905 x: off_x + *x,
906 y: off_y + *y,
907 w: *w,
908 h: *h,
909 kind: *kind,
910 stops: stops.clone(),
911 opacity,
912 },
913 );
914 }
915 }
916 }
917}
918
919fn apply_opacity(a: u8, opacity: f32) -> u8 {
920 (a as f32 * opacity).round().clamp(0.0, 255.0) as u8
921}
922
923fn clipped_out(clip: Option<Bounds<Pixels>>, target: Bounds<Pixels>) -> bool {
924 if let Some(c) = clip {
925 let cl = f32::from(c.origin.x);
926 let ct = f32::from(c.origin.y);
927 let cr = cl + f32::from(c.size.width);
928 let cb = ct + f32::from(c.size.height);
929
930 let tl = f32::from(target.origin.x);
931 let tt = f32::from(target.origin.y);
932 let tr = tl + f32::from(target.size.width);
933 let tb = tt + f32::from(target.size.height);
934
935 tr <= cl || tl >= cr || tb <= ct || tt >= cb
936 } else {
937 false
938 }
939}
940
941fn intersect_bounds(a: Bounds<Pixels>, b: Bounds<Pixels>) -> Bounds<Pixels> {
942 let al = f32::from(a.origin.x);
943 let at = f32::from(a.origin.y);
944 let ar = al + f32::from(a.size.width);
945 let ab = at + f32::from(a.size.height);
946
947 let bl = f32::from(b.origin.x);
948 let bt = f32::from(b.origin.y);
949 let br = bl + f32::from(b.size.width);
950 let bb = bt + f32::from(b.size.height);
951
952 let il = al.max(bl);
953 let it = at.max(bt);
954 let ir = ar.min(br);
955 let ib = ab.min(bb);
956
957 Bounds::from_corners(point(px(il), px(it)), point(px(ir.max(il)), px(ib.max(it))))
958}
959
960fn rounded_rect_polygon(x: f32, y: f32, w: f32, h: f32, radius: f32) -> Vec<Point<Pixels>> {
961 let r = radius.min(w / 2.0).min(h / 2.0);
962 let segs = 4;
963 let mut pts = Vec::with_capacity(segs * 4 + 4);
964 for corner in 0..4 {
965 let (corner_x, corner_y, angle_start) = match corner {
966 0 => (x + w - r, y + r, -std::f32::consts::FRAC_PI_2), // top-right
967 1 => (x + w - r, y + h - r, 0.0), // bottom-right
968 2 => (x + r, y + h - r, std::f32::consts::FRAC_PI_2), // bottom-left
969 _ => (x + r, y + r, std::f32::consts::PI), // top-left
970 };
971 for i in 0..=segs {
972 let t = angle_start + (i as f32 / segs as f32) * std::f32::consts::FRAC_PI_2;
973 pts.push(point(
974 px(corner_x + r * t.cos()),
975 px(corner_y + r * t.sin()),
976 ));
977 }
978 }
979 pts
980}
981
982fn arc_polyline(cx: f32, cy: f32, radius: f32, start: f32, end: f32) -> Vec<Point<Pixels>> {
983 let sweep = end - start;
984 let n = ((sweep.abs() / std::f32::consts::TAU) * 24.0)
985 .ceil()
986 .max(2.0) as usize;
987 (0..=n)
988 .map(|i| {
989 let t = start + (i as f32 / n as f32) * sweep;
990 point(px(cx + radius * t.cos()), px(cy + radius * t.sin()))
991 })
992 .collect()
993}
994
995struct GradientParams {
996 x: f32,
997 y: f32,
998 w: f32,
999 h: f32,
1000 kind: u8,
1001 stops: Vec<GradientStop>,
1002 opacity: f32,
1003}
1004
1005fn paint_gradient(window: &mut Window, p: &GradientParams) {
1006 if p.stops.is_empty() {
1007 return;
1008 }
1009
1010 // Keep band count low — GPUI's Metal scene buffer has per-frame limits and each band
1011 // is a separate quad. 8 bands gives a smooth-enough look without overwhelming the
1012 // renderer (64 bands was causing "scene too large" at >800 quads per frame).
1013 let bands: usize = 8;
1014 for i in 0..bands {
1015 let t = i as f32 / (bands - 1).max(1) as f32;
1016 let (sr, sg, sb, sa) = sample_gradient(&p.stops, t);
1017 let ca = apply_opacity(sa, p.opacity);
1018
1019 if p.kind == 1 {
1020 // Radial: concentric rectangles from outside in.
1021 let frac = 1.0 - t;
1022 let bx = p.x + p.w * 0.5 * t;
1023 let by = p.y + p.h * 0.5 * t;
1024 let bw = p.w * frac;
1025 let bh = p.h * frac;
1026 if bw > 0.0 && bh > 0.0 {
1027 let min = point(px(bx), px(by));
1028 let band_bounds = Bounds::from_corners(min, min + point(px(bw), px(bh)));
1029 window.paint_quad(gpui::fill(band_bounds, rgba8(sr, sg, sb, ca)));
1030 }
1031 } else {
1032 // Linear: vertical bands along the gradient axis.
1033 let band_h = p.h / bands as f32;
1034 let by = p.y + i as f32 * band_h;
1035 let min = point(px(p.x), px(by));
1036 let band_bounds = Bounds::from_corners(min, min + point(px(p.w), px(band_h.ceil())));
1037 window.paint_quad(gpui::fill(band_bounds, rgba8(sr, sg, sb, ca)));
1038 }
1039 }
1040}
1041
1042fn sample_gradient(stops: &[GradientStop], t: f32) -> (u8, u8, u8, u8) {
1043 if stops.len() == 1 {
1044 let s = &stops[0];
1045 return (s.r, s.g, s.b, s.a);
1046 }
1047 let t = t.clamp(0.0, 1.0);
1048 let mut lo = &stops[0];
1049 let mut hi = &stops[stops.len() - 1];
1050 for pair in stops.windows(2) {
1051 if t >= pair[0].offset && t <= pair[1].offset {
1052 lo = &pair[0];
1053 hi = &pair[1];
1054 break;
1055 }
1056 }
1057 let range = hi.offset - lo.offset;
1058 let frac = if range > 0.0 {
1059 (t - lo.offset) / range
1060 } else {
1061 0.0
1062 };
1063 let lerp = |a: u8, b: u8| -> u8 { (a as f32 + (b as f32 - a as f32) * frac).round() as u8 };
1064 (
1065 lerp(lo.r, hi.r),
1066 lerp(lo.g, hi.g),
1067 lerp(lo.b, hi.b),
1068 lerp(lo.a, hi.a),
1069 )
1070}
1071
1072/// Result of a background `rfd` file dialog (must not run inside GPUI `App::update` — modal + focus events re-enter and panic).
1073enum FilePickDone {
1074 Chosen { path: PathBuf, bytes: Vec<u8> },
1075 Directory(PathBuf),
1076 Cancelled,
1077}
1078
1079pub struct OxideBrowserView {
1080 tabs: Vec<TabState>,
1081 active_tab: usize,
1082 next_tab_id: u64,
1083 shared_kv_db: Option<Arc<sled::Db>>,
1084 shared_module_loader: Option<Arc<ModuleLoader>>,
1085 bookmark_store: Option<BookmarkStore>,
1086 history_store: Option<HistoryStore>,
1087 show_bookmarks: bool,
1088 show_menu: bool,
1089 /// Focus for the page (canvas + guest widgets); required for keyboard to reach `on_key_down` on the root.
1090 canvas_focus: FocusHandle,
1091 url_focus: FocusHandle,
1092 /// Keyboard focus for the `oxide://forge` chat composer.
1093 forge_focus: FocusHandle,
1094 /// Keyboard focus for API key field in Forge settings.
1095 forge_settings_key_focus: FocusHandle,
1096 /// Keyboard focus for model field in Forge settings.
1097 forge_settings_model_focus: FocusHandle,
1098 /// Persisted multi-provider Forge configuration.
1099 forge_config: ForgeUserConfig,
1100 /// Provider tab selected in the settings panel.
1101 forge_settings_provider: ForgeProvider,
1102 /// Draft API key while editing settings (cleared after save).
1103 forge_settings_key_draft: String,
1104 /// Draft model id while editing settings.
1105 forge_settings_model_draft: String,
1106 /// Receiver for [`FilePickDone`]; dialog runs on a background thread so the main thread never holds `App` during `NSOpenPanel`.
1107 file_pick_rx: Option<mpsc::Receiver<FilePickDone>>,
1108 download_manager: DownloadManager,
1109 show_downloads: bool,
1110 /// Lazily-initialised Claude-backed guest app factory for `oxide://forge`.
1111 forge: Arc<Mutex<Option<ForgeState>>>,
1112 /// Whether the user is currently dragging the scrollbar thumb.
1113 scroll_dragging: bool,
1114 /// The screen Y position where the scrollbar drag started.
1115 scroll_drag_start_y: f32,
1116 /// The absolute scroll Y offset when the scrollbar drag started.
1117 scroll_drag_start_scroll_y: f32,
1118}
1119
1120impl OxideBrowserView {
1121 fn new(cx: &mut Context<Self>, host_state: HostState, status: Arc<Mutex<PageStatus>>) -> Self {
1122 let shared_kv_db = host_state.kv_db.clone();
1123 let shared_module_loader = host_state.module_loader.clone();
1124 let bookmark_store = host_state.bookmark_store.lock().unwrap().clone();
1125 let history_store = host_state.history_store.lock().unwrap().clone();
1126 let first_tab = TabState::new(0, host_state, status);
1127 Self {
1128 tabs: vec![first_tab],
1129 active_tab: 0,
1130 next_tab_id: 1,
1131 shared_kv_db,
1132 shared_module_loader,
1133 bookmark_store,
1134 history_store,
1135 show_bookmarks: false,
1136 show_menu: false,
1137 canvas_focus: cx.focus_handle(),
1138 url_focus: cx.focus_handle(),
1139 forge_focus: cx.focus_handle(),
1140 forge_settings_key_focus: cx.focus_handle(),
1141 forge_settings_model_focus: cx.focus_handle(),
1142 forge_config: ForgeUserConfig::load(),
1143 forge_settings_provider: ForgeProvider::Anthropic,
1144 forge_settings_key_draft: String::new(),
1145 forge_settings_model_draft: String::new(),
1146 file_pick_rx: None,
1147 download_manager: DownloadManager::new(),
1148 show_downloads: false,
1149 forge: Arc::new(Mutex::new(None)),
1150 scroll_dragging: false,
1151 scroll_drag_start_y: 0.0,
1152 scroll_drag_start_scroll_y: 0.0,
1153 }
1154 }
1155
1156 /// Ensure the Forge subsystem is initialised; returns `true` if available.
1157 fn ensure_forge(&mut self) -> bool {
1158 let mut g = self.forge.lock().unwrap();
1159 if g.is_none() {
1160 *g = ForgeState::from_config(&self.forge_config);
1161 }
1162 if let Some(forge) = g.as_mut() {
1163 forge.apply_config(&self.forge_config);
1164 }
1165 g.is_some()
1166 }
1167
1168 fn forge_sync_settings_drafts(&mut self) {
1169 let p = self.forge_settings_provider;
1170 let settings = self.forge_config.provider(p);
1171 self.forge_settings_model_draft = settings.model_or_default(p);
1172 self.forge_settings_key_draft.clear();
1173 }
1174
1175 fn forge_toggle_settings(&mut self) {
1176 self.forge_config.settings_open = !self.forge_config.settings_open;
1177 if self.forge_config.settings_open {
1178 self.forge_settings_provider = self.forge_config.active_provider;
1179 self.forge_sync_settings_drafts();
1180 }
1181 }
1182
1183 fn forge_select_provider(&mut self, provider: ForgeProvider) {
1184 self.forge_config.active_provider = provider;
1185 self.forge_settings_provider = provider;
1186 self.forge_sync_settings_drafts();
1187 let mut g = self.forge.lock().unwrap();
1188 if let Some(forge) = g.as_mut() {
1189 forge.apply_config(&self.forge_config);
1190 }
1191 let _ = self.forge_config.save();
1192 }
1193
1194 fn forge_save_provider_settings(&mut self) {
1195 let provider = self.forge_settings_provider;
1196 if !self.forge_settings_key_draft.trim().is_empty() {
1197 self.forge_config
1198 .set_api_key(provider, self.forge_settings_key_draft.clone());
1199 }
1200 self.forge_config
1201 .set_model(provider, self.forge_settings_model_draft.clone());
1202 let _ = self.forge_config.save();
1203 self.forge_settings_key_draft.clear();
1204
1205 let mut g = self.forge.lock().unwrap();
1206 if g.is_none() {
1207 *g = ForgeState::from_config(&self.forge_config);
1208 } else if let Some(forge) = g.as_mut() {
1209 forge.apply_config(&self.forge_config);
1210 }
1211 }
1212
1213 /// Snapshot the session active in the current tab, if any.
1214 fn forge_current_snapshot(&self) -> Option<ForgeSnapshot> {
1215 let id = self.tabs[self.active_tab].forge_session_id?;
1216 let g = self.forge.lock().ok()?;
1217 g.as_ref()?.snapshot(id)
1218 }
1219
1220 fn forge_creations(&self) -> Vec<ForgeCreationSummary> {
1221 let g = self.forge.lock().ok();
1222 g.and_then(|g| g.as_ref().map(|forge| forge.list_creations()))
1223 .unwrap_or_default()
1224 }
1225
1226 fn forge_output_dir(&self) -> Option<PathBuf> {
1227 let g = self.forge.lock().ok()?;
1228 Some(g.as_ref()?.output_dir())
1229 }
1230
1231 /// Submit the current tab's prompt. On success, the tab's
1232 /// `forge_session_id` is set and the prompt is cleared.
1233 fn forge_submit(&mut self) {
1234 let idx = self.active_tab;
1235 let prompt = self.tabs[idx].forge_prompt.trim().to_string();
1236 if prompt.is_empty() {
1237 return;
1238 }
1239 if !self.ensure_forge() {
1240 return;
1241 }
1242 let session_id = self.tabs[idx].forge_session_id;
1243 let result = {
1244 let mut g = self.forge.lock().unwrap();
1245 g.as_mut().map(|forge| match session_id {
1246 Some(id) => forge.revise(id, prompt.clone()).map(|_| id),
1247 None => forge.start(prompt.clone()),
1248 })
1249 };
1250 match result {
1251 Some(Ok(id)) => {
1252 self.tabs[idx].forge_session_id = Some(id);
1253 self.tabs[idx].forge_prompt.clear();
1254 }
1255 Some(Err(e)) => {
1256 let console = self.tabs[idx].host_state.console.clone();
1257 crate::capabilities::console_log(
1258 &console,
1259 ConsoleLevel::Error,
1260 format!("[FORGE] start failed: {e}"),
1261 );
1262 }
1263 None => {}
1264 }
1265 }
1266
1267 fn forge_pick_output_dir(&mut self) {
1268 if self.file_pick_rx.is_some() {
1269 return;
1270 }
1271 let (tx, rx) = mpsc::channel();
1272 self.file_pick_rx = Some(rx);
1273 std::thread::spawn(move || {
1274 let msg = rfd::FileDialog::new()
1275 .set_title("Choose Oxide Forge Output Folder")
1276 .pick_folder()
1277 .map(FilePickDone::Directory)
1278 .unwrap_or(FilePickDone::Cancelled);
1279 let _ = tx.send(msg);
1280 });
1281 }
1282
1283 /// Kick off a `cargo build` for the current tab's session.
1284 fn forge_build(&self) {
1285 let id = match self.tabs[self.active_tab].forge_session_id {
1286 Some(id) => id,
1287 None => return,
1288 };
1289 let mut g = self.forge.lock().unwrap();
1290 if let Some(forge) = g.as_mut() {
1291 if let Err(e) = forge.build(id) {
1292 crate::capabilities::console_log(
1293 &self.tabs[self.active_tab].host_state.console,
1294 ConsoleLevel::Error,
1295 format!("[FORGE] build failed: {e}"),
1296 );
1297 }
1298 }
1299 }
1300
1301 fn forge_delete_current_creation(&mut self) {
1302 let id = match self.tabs[self.active_tab].forge_session_id {
1303 Some(id) => id,
1304 None => return,
1305 };
1306 let result = {
1307 let mut g = self.forge.lock().unwrap();
1308 g.as_mut().map(|forge| forge.delete_creation(id))
1309 };
1310 match result {
1311 Some(Ok(())) => {
1312 for tab in &mut self.tabs {
1313 if tab.forge_session_id == Some(id) {
1314 tab.forge_session_id = None;
1315 tab.forge_prompt.clear();
1316 }
1317 }
1318 }
1319 Some(Err(e)) => {
1320 crate::capabilities::console_log(
1321 &self.tabs[self.active_tab].host_state.console,
1322 ConsoleLevel::Error,
1323 format!("[FORGE] delete failed: {e}"),
1324 );
1325 }
1326 None => {}
1327 }
1328 }
1329
1330 /// Load the built `.wasm` for the current session into a new tab.
1331 fn forge_run_in_new_tab(&mut self) {
1332 let id = match self.tabs[self.active_tab].forge_session_id {
1333 Some(id) => id,
1334 None => return,
1335 };
1336 let bytes = {
1337 let g = self.forge.lock().unwrap();
1338 g.as_ref().and_then(|f| f.artifact_bytes(id))
1339 };
1340 let Some(bytes) = bytes else {
1341 return;
1342 };
1343 let slug = {
1344 let g = self.forge.lock().unwrap();
1345 g.as_ref()
1346 .and_then(|f| f.snapshot(id))
1347 .map(|s| s.slug)
1348 .unwrap_or_else(|| format!("session-{id}"))
1349 };
1350 let new_idx = self.create_tab();
1351 self.active_tab = new_idx;
1352 let tab = &mut self.tabs[new_idx];
1353 tab.url_input = format!("oxide://forge/run/{slug}");
1354 tab.url_cursor = tab.url_input.len();
1355 tab.url_sel_start = tab.url_input.len();
1356 tab.internal_page = None;
1357 let _ = tab.run_tx.send(RunRequest::LoadLocal(bytes));
1358 }
1359
1360 fn poll_file_pick(&mut self, cx: &mut Context<Self>) {
1361 let rx = match self.file_pick_rx.take() {
1362 Some(r) => r,
1363 None => return,
1364 };
1365 match rx.try_recv() {
1366 Ok(FilePickDone::Chosen { path, bytes }) => {
1367 let file_url = format!("file://{}", path.display());
1368 let tab = &mut self.tabs[self.active_tab];
1369 tab.url_input = file_url.clone();
1370 tab.pending_history_url = Some(file_url);
1371 tab.internal_page = None;
1372 let _ = tab.run_tx.send(RunRequest::LoadLocal(bytes));
1373 cx.notify();
1374 }
1375 Ok(FilePickDone::Directory(path)) => {
1376 if self.ensure_forge() {
1377 let result = {
1378 let mut g = self.forge.lock().unwrap();
1379 g.as_mut().map(|forge| forge.set_output_dir(path.clone()))
1380 };
1381 if let Some(Err(e)) = result {
1382 crate::capabilities::console_log(
1383 &self.tabs[self.active_tab].host_state.console,
1384 ConsoleLevel::Error,
1385 format!("[FORGE] output folder failed: {e}"),
1386 );
1387 } else {
1388 self.tabs[self.active_tab].forge_session_id = None;
1389 }
1390 }
1391 cx.notify();
1392 }
1393 Ok(FilePickDone::Cancelled) => {}
1394 Err(TryRecvError::Empty) => {
1395 self.file_pick_rx = Some(rx);
1396 }
1397 Err(TryRecvError::Disconnected) => {}
1398 }
1399 }
1400
1401 fn create_tab(&mut self) -> usize {
1402 let bm_shared: crate::bookmarks::SharedBookmarkStore =
1403 Arc::new(Mutex::new(self.bookmark_store.clone()));
1404 let hist_shared: crate::history::SharedHistoryStore =
1405 Arc::new(Mutex::new(self.history_store.clone()));
1406 let host_state = HostState {
1407 kv_db: self.shared_kv_db.clone(),
1408 module_loader: self.shared_module_loader.clone(),
1409 bookmark_store: bm_shared,
1410 history_store: hist_shared,
1411 ..Default::default()
1412 };
1413 let status = Arc::new(Mutex::new(PageStatus::Idle));
1414 let tab = TabState::new(self.next_tab_id, host_state, status);
1415 self.next_tab_id += 1;
1416 self.tabs.push(tab);
1417 self.tabs.len() - 1
1418 }
1419
1420 /// Keep `active_tab` in range. Stale close handlers can fire with an old tab index after the strip shrinks.
1421 fn clamp_active_tab(&mut self) {
1422 if self.tabs.is_empty() {
1423 self.active_tab = 0;
1424 return;
1425 }
1426 self.active_tab = self.active_tab.min(self.tabs.len() - 1);
1427 }
1428
1429 fn close_tab(&mut self, idx: usize) {
1430 if self.tabs.len() <= 1 {
1431 return;
1432 }
1433 if idx >= self.tabs.len() {
1434 self.clamp_active_tab();
1435 return;
1436 }
1437 self.tabs.remove(idx);
1438 if self.active_tab > idx {
1439 self.active_tab -= 1;
1440 } else if self.active_tab == idx && self.active_tab >= self.tabs.len() {
1441 self.active_tab = self.tabs.len().saturating_sub(1);
1442 }
1443 self.clamp_active_tab();
1444 }
1445
1446 fn toggle_active_bookmark(&self) {
1447 let url = self.tabs[self.active_tab].url_input.trim().to_string();
1448 if url.is_empty() || url == "https://" {
1449 return;
1450 }
1451 if let Some(store) = &self.bookmark_store {
1452 if store.contains(&url) {
1453 let _ = store.remove(&url);
1454 } else {
1455 let title = url_to_title(&url);
1456 let _ = store.add(&url, &title);
1457 }
1458 }
1459 }
1460}
1461
1462impl Render for OxideBrowserView {
1463 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1464 self.clamp_active_tab();
1465 self.poll_file_pick(cx);
1466 let dm = self.download_manager.clone();
1467 for tab in &mut self.tabs {
1468 tab.drain_results();
1469 tab.handle_pending_navigation(&dm);
1470 tab.sync_url_bar();
1471 }
1472
1473 let active = self.active_tab;
1474 let canvas_focused = self.canvas_focus.is_focused(window);
1475 {
1476 let tab = &mut self.tabs[active];
1477 tab.host_state
1478 .focused
1479 .store(canvas_focused, Ordering::Relaxed);
1480 tab.sync_keys_held_to_input();
1481 // Expose the window's text system to the guest only for the
1482 // duration of `on_frame`, so `canvas_measure_text` can shape
1483 // synchronously. Cleared after tick to avoid leaking the handle.
1484 *tab.host_state.text_system.lock().unwrap() = Some(window.text_system().clone());
1485 tab.tick_frame();
1486 *tab.host_state.text_system.lock().unwrap() = None;
1487 tab.update_texture_cache(window);
1488 tab.refresh_pip_texture(window);
1489 }
1490
1491 let canvas_offset = self.tabs[active].host_state.canvas_offset.clone();
1492 let cmds = self.tabs[active]
1493 .host_state
1494 .canvas
1495 .lock()
1496 .unwrap()
1497 .commands
1498 .clone();
1499 let hyperlinks = self.tabs[active]
1500 .host_state
1501 .hyperlinks
1502 .lock()
1503 .unwrap()
1504 .clone();
1505 let hyperlinks_hover = hyperlinks.clone();
1506 let widget_commands = self.tabs[active]
1507 .host_state
1508 .widget_commands
1509 .lock()
1510 .unwrap()
1511 .clone();
1512 let widget_cmds_overlay = widget_commands.clone();
1513 let textures = self.tabs[active].image_textures.clone();
1514 let show_console = self.tabs[active].show_console;
1515 let pip_tex = self.tabs[active].pip_texture.clone();
1516
1517 self.tabs[active].post_tick_clear_input();
1518
1519 cx.on_next_frame(window, |_this, _window, cx| {
1520 cx.notify();
1521 });
1522
1523 let tab_titles: Vec<String> = self.tabs.iter().map(|t| t.display_title()).collect();
1524 let num_tabs = self.tabs.len();
1525 let active_tab = self.active_tab;
1526 let bm = self.bookmark_store.clone();
1527 let current_url = self.tabs[active].url_input.clone();
1528 let is_bookmarked = bm
1529 .as_ref()
1530 .map(|s| s.contains(¤t_url))
1531 .unwrap_or(false);
1532 let url_focused = self.url_focus.is_focused(window);
1533 let caret_blink_on = SystemTime::now()
1534 .duration_since(UNIX_EPOCH)
1535 .map(|d| (d.as_millis() / 530) % 2 == 0)
1536 .unwrap_or(true);
1537 let can_back = self.tabs[active]
1538 .host_state
1539 .navigation
1540 .lock()
1541 .unwrap()
1542 .can_go_back();
1543 let can_fwd = self.tabs[active]
1544 .host_state
1545 .navigation
1546 .lock()
1547 .unwrap()
1548 .can_go_forward();
1549
1550 let mut root = div()
1551 .id("oxide_root")
1552 .track_focus(&self.canvas_focus)
1553 .focusable()
1554 .size_full()
1555 .flex()
1556 .flex_col()
1557 .bg(gpui::rgb(0x1a1a20))
1558 .on_key_down(cx.listener(
1559 |this: &mut OxideBrowserView, event: &KeyDownEvent, window, cx| {
1560 {
1561 let tab = &this.tabs[this.active_tab];
1562 let mut input = tab.host_state.input_state.lock().unwrap();
1563 input.modifiers_shift = event.keystroke.modifiers.shift;
1564 input.modifiers_ctrl =
1565 event.keystroke.modifiers.control || event.keystroke.modifiers.platform;
1566 input.modifiers_alt = event.keystroke.modifiers.alt;
1567 }
1568 if event.keystroke.modifiers.secondary() && event.keystroke.key == "r" {
1569 this.tabs[this.active_tab].reload();
1570 cx.notify();
1571 return;
1572 }
1573 if event.keystroke.modifiers.secondary() && event.keystroke.key == "t" {
1574 let i = this.create_tab();
1575 this.active_tab = i;
1576 cx.notify();
1577 return;
1578 }
1579 if event.keystroke.modifiers.secondary() && event.keystroke.key == "w" {
1580 if this.tabs.len() > 1 {
1581 let a = this.active_tab;
1582 this.close_tab(a);
1583 }
1584 cx.notify();
1585 return;
1586 }
1587 if event.keystroke.modifiers.control
1588 && !event.keystroke.modifiers.shift
1589 && event.keystroke.key == "tab"
1590 {
1591 if !this.tabs.is_empty() {
1592 this.active_tab = (this.active_tab + 1) % this.tabs.len();
1593 }
1594 cx.notify();
1595 return;
1596 }
1597 if event.keystroke.modifiers.control
1598 && event.keystroke.modifiers.shift
1599 && event.keystroke.key == "tab"
1600 {
1601 if !this.tabs.is_empty() {
1602 if this.active_tab == 0 {
1603 this.active_tab = this.tabs.len() - 1;
1604 } else {
1605 this.active_tab -= 1;
1606 }
1607 }
1608 cx.notify();
1609 return;
1610 }
1611 if event.keystroke.modifiers.secondary() && event.keystroke.key == "d" {
1612 this.toggle_active_bookmark();
1613 cx.notify();
1614 return;
1615 }
1616 if event.keystroke.modifiers.secondary() && event.keystroke.key == "b" {
1617 this.show_bookmarks = !this.show_bookmarks;
1618 cx.notify();
1619 return;
1620 }
1621 if this.forge_settings_key_focus.is_focused(window)
1622 || this.forge_settings_model_focus.is_focused(window)
1623 {
1624 let editing_key = this.forge_settings_key_focus.is_focused(window);
1625 let draft = if editing_key {
1626 &mut this.forge_settings_key_draft
1627 } else {
1628 &mut this.forge_settings_model_draft
1629 };
1630 match event.keystroke.key.as_str() {
1631 "enter" => {
1632 this.forge_save_provider_settings();
1633 cx.notify();
1634 return;
1635 }
1636 "escape" => {
1637 if editing_key {
1638 this.forge_settings_key_draft.clear();
1639 } else {
1640 this.forge_sync_settings_drafts();
1641 }
1642 cx.notify();
1643 return;
1644 }
1645 "backspace" => {
1646 draft.pop();
1647 cx.notify();
1648 return;
1649 }
1650 _ => {}
1651 }
1652 if event.keystroke.modifiers.secondary() && event.keystroke.key == "v" {
1653 if let Ok(mut cb) = arboard::Clipboard::new() {
1654 if let Ok(pasted) = cb.get_text() {
1655 draft.push_str(pasted.trim());
1656 cx.notify();
1657 }
1658 }
1659 return;
1660 }
1661 if let Some(s) = text_insert_from_keystroke(&event.keystroke) {
1662 draft.push_str(&s);
1663 cx.notify();
1664 }
1665 return;
1666 }
1667 if this.forge_focus.is_focused(window) {
1668 let active = this.active_tab;
1669 match event.keystroke.key.as_str() {
1670 "enter" => {
1671 if !event.keystroke.modifiers.shift {
1672 this.forge_submit();
1673 cx.notify();
1674 return;
1675 }
1676 // Shift+Enter → insert newline
1677 this.tabs[active].forge_prompt.push('\n');
1678 cx.notify();
1679 return;
1680 }
1681 "escape" => {
1682 this.tabs[active].forge_prompt.clear();
1683 cx.notify();
1684 return;
1685 }
1686 "backspace" => {
1687 this.tabs[active].forge_prompt.pop();
1688 cx.notify();
1689 return;
1690 }
1691 _ => {}
1692 }
1693 if event.keystroke.modifiers.secondary() && event.keystroke.key == "v" {
1694 if let Ok(mut cb) = arboard::Clipboard::new() {
1695 if let Ok(pasted) = cb.get_text() {
1696 this.tabs[active].forge_prompt.push_str(&pasted);
1697 cx.notify();
1698 }
1699 }
1700 return;
1701 }
1702 if let Some(s) = text_insert_from_keystroke(&event.keystroke) {
1703 this.tabs[active].forge_prompt.push_str(&s);
1704 cx.notify();
1705 }
1706 return;
1707 }
1708 if this.url_focus.is_focused(window) {
1709 return;
1710 }
1711 if let Some(id) = this.tabs[this.active_tab].text_input_focus {
1712 let mut states = this.tabs[this.active_tab]
1713 .host_state
1714 .widget_states
1715 .lock()
1716 .unwrap();
1717 let mut text = match states.get(&id) {
1718 Some(WidgetValue::Text(t)) => t.clone(),
1719 _ => String::new(),
1720 };
1721 if event.keystroke.modifiers.secondary() {
1722 match event.keystroke.key.as_str() {
1723 "c" => {
1724 if let Ok(mut cb) = arboard::Clipboard::new() {
1725 let _ = cb.set_text(&text);
1726 }
1727 }
1728 "v" => {
1729 if let Ok(mut cb) = arboard::Clipboard::new() {
1730 if let Ok(pasted) = cb.get_text() {
1731 text.push_str(&pasted);
1732 states.insert(id, WidgetValue::Text(text));
1733 }
1734 }
1735 }
1736 "x" => {
1737 if let Ok(mut cb) = arboard::Clipboard::new() {
1738 let _ = cb.set_text(&text);
1739 }
1740 states.insert(id, WidgetValue::Text(String::new()));
1741 }
1742 "a" => {}
1743 _ => {}
1744 }
1745 cx.notify();
1746 return;
1747 }
1748 if event.keystroke.key == "backspace" {
1749 text.pop();
1750 } else if let Some(s) = text_insert_from_keystroke(&event.keystroke) {
1751 text.push_str(&s);
1752 }
1753 states.insert(id, WidgetValue::Text(text));
1754 cx.notify();
1755 return;
1756 }
1757 if let Some(code) = keystroke_to_oxide(&event.keystroke) {
1758 let tab = &mut this.tabs[this.active_tab];
1759 tab.keys_held.insert(code);
1760 tab.host_state
1761 .input_state
1762 .lock()
1763 .unwrap()
1764 .keys_pressed
1765 .push(code);
1766 cx.notify();
1767 }
1768 },
1769 ))
1770 .on_key_up(cx.listener(|this, event: &KeyUpEvent, _, _cx| {
1771 let tab = &mut this.tabs[this.active_tab];
1772 {
1773 let mut input = tab.host_state.input_state.lock().unwrap();
1774 input.modifiers_shift = event.keystroke.modifiers.shift;
1775 input.modifiers_ctrl =
1776 event.keystroke.modifiers.control || event.keystroke.modifiers.platform;
1777 input.modifiers_alt = event.keystroke.modifiers.alt;
1778 }
1779 if let Some(code) = keystroke_to_oxide(&event.keystroke) {
1780 tab.keys_held.remove(&code);
1781 }
1782 }));
1783
1784 // Tab strip
1785 root = root.child(
1786 div()
1787 .h(px(40.0))
1788 .flex()
1789 .flex_row()
1790 .items_center()
1791 .px_1()
1792 .border_b_1()
1793 .border_color(gpui::rgb(0x2a2a32))
1794 .children((0..num_tabs).map(|i| {
1795 let title = tab_titles[i].clone();
1796 let display = truncate_tab_title(&title);
1797 let is_active = i == active_tab;
1798 div()
1799 .id(("oxide_tab", i))
1800 .flex()
1801 .flex_row()
1802 .items_center()
1803 .gap_1()
1804 .min_w(px(140.0))
1805 .px_3()
1806 .py_2()
1807 .rounded_md()
1808 .cursor_pointer()
1809 .when(is_active, |d| d.bg(gpui::rgb(0x373741)))
1810 .text_sm()
1811 .text_color(if is_active {
1812 gpui::rgb(0xdcdce6)
1813 } else {
1814 gpui::rgb(0x9696a0)
1815 })
1816 .child(
1817 div()
1818 .flex_1()
1819 .min_w(px(0.0))
1820 .overflow_hidden()
1821 .child(display),
1822 )
1823 .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
1824 this.active_tab = i;
1825 cx.notify();
1826 }))
1827 .when(num_tabs > 1, |d| {
1828 d.child(
1829 div()
1830 .id(("oxide_tab_close", i))
1831 .flex_shrink_0()
1832 .cursor_pointer()
1833 .text_color(gpui::rgb(0xa0a0aa))
1834 .child("×")
1835 .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
1836 this.close_tab(i);
1837 cx.notify();
1838 })),
1839 )
1840 })
1841 }))
1842 .child(
1843 div()
1844 .id("oxide_new_tab")
1845 .ml_1()
1846 .cursor_pointer()
1847 .text_color(gpui::rgb(0xc0c0cc))
1848 .child("+")
1849 .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
1850 let i = this.create_tab();
1851 this.active_tab = i;
1852 cx.notify();
1853 })),
1854 ),
1855 );
1856
1857 // Toolbar
1858 let (status_icon, status_color) = {
1859 let status = self.tabs[active].status.lock().unwrap();
1860 let icon = match &*status {
1861 PageStatus::Idle => "○",
1862 PageStatus::Loading(_) => "↻",
1863 PageStatus::Running(_) => "●",
1864 PageStatus::Error(_) => "●",
1865 };
1866 let color = match &*status {
1867 PageStatus::Error(_) => gpui::rgb(0xf05050),
1868 PageStatus::Running(_) => gpui::rgb(0x50e070),
1869 _ => gpui::rgb(0xa0a0a8),
1870 };
1871 (icon, color)
1872 };
1873
1874 root = root.child(
1875 div()
1876 .h(px(44.0))
1877 .flex()
1878 .flex_row()
1879 .items_center()
1880 .gap_2()
1881 .px_2()
1882 .border_b_1()
1883 .border_color(gpui::rgb(0x2a2a32))
1884 .child(
1885 div()
1886 .id("oxide_back")
1887 .when(can_back, |el| el.cursor_pointer())
1888 .text_sm()
1889 .text_color(if can_back {
1890 gpui::rgb(0xb8b8c4)
1891 } else {
1892 gpui::rgb(0x50505a)
1893 })
1894 .child("◀")
1895 .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
1896 this.tabs[this.active_tab].go_back();
1897 cx.notify();
1898 })),
1899 )
1900 .child(
1901 div()
1902 .id("oxide_forward")
1903 .when(can_fwd, |el| el.cursor_pointer())
1904 .text_sm()
1905 .text_color(if can_fwd {
1906 gpui::rgb(0xb8b8c4)
1907 } else {
1908 gpui::rgb(0x50505a)
1909 })
1910 .child("▶")
1911 .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
1912 this.tabs[this.active_tab].go_forward();
1913 cx.notify();
1914 })),
1915 )
1916 .child(
1917 div()
1918 .id("oxide_reload")
1919 .cursor_pointer()
1920 .text_sm()
1921 .text_color(gpui::rgb(0xb8b8c4))
1922 .hover(|style| style.text_color(gpui::rgb(0xffffff)))
1923 .child("↻")
1924 .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
1925 this.tabs[this.active_tab].reload();
1926 cx.notify();
1927 })),
1928 )
1929 .child(
1930 div()
1931 .text_sm()
1932 .text_color(status_color)
1933 .child(status_icon.to_string()),
1934 )
1935 .child({
1936 let url_text_for_canvas =
1937 SharedString::from(self.tabs[active].url_input.clone());
1938 let url_cursor = self.tabs[active].url_cursor;
1939 let url_sel_start = self.tabs[active].url_sel_start;
1940 let url_bounds_ref = self.tabs[active].url_text_bounds.clone();
1941 div()
1942 .id("oxide_url_bar")
1943 .flex_1()
1944 .flex()
1945 .flex_row()
1946 .items_center()
1947 .h(px(32.0))
1948 .px_2()
1949 .rounded_md()
1950 .bg(gpui::rgb(0x121218))
1951 .border_1()
1952 .border_color(if url_focused {
1953 gpui::rgb(0x6a6aff)
1954 } else {
1955 gpui::rgb(0x121218)
1956 })
1957 .track_focus(&self.url_focus)
1958 .overflow_hidden()
1959 .on_key_down(cx.listener(
1960 |this: &mut OxideBrowserView, event: &KeyDownEvent, window, cx| {
1961 if !this.url_focus.is_focused(window) {
1962 return;
1963 }
1964 let shift = event.keystroke.modifiers.shift;
1965 if event.keystroke.modifiers.secondary() {
1966 let tab = &mut this.tabs[this.active_tab];
1967 match event.keystroke.key.as_str() {
1968 "a" => {
1969 tab.url_select_all();
1970 cx.notify();
1971 return;
1972 }
1973 "c" => {
1974 let text = tab.url_selected_text();
1975 if !text.is_empty() {
1976 if let Ok(mut cb) = arboard::Clipboard::new() {
1977 let _ = cb.set_text(text);
1978 }
1979 }
1980 return;
1981 }
1982 "x" => {
1983 let text = tab.url_selected_text();
1984 if !text.is_empty() {
1985 if let Ok(mut cb) = arboard::Clipboard::new() {
1986 let _ = cb.set_text(text);
1987 }
1988 tab.url_delete_selection();
1989 cx.notify();
1990 }
1991 return;
1992 }
1993 "v" => {
1994 if let Ok(mut cb) = arboard::Clipboard::new() {
1995 if let Ok(text) = cb.get_text() {
1996 tab.url_insert_at_cursor(&text);
1997 cx.notify();
1998 }
1999 }
2000 return;
2001 }
2002 _ => {}
2003 }
2004 }
2005 let tab = &mut this.tabs[this.active_tab];
2006 match event.keystroke.key.as_str() {
2007 "left" => {
2008 if shift {
2009 tab.url_select_to(tab.url_prev_boundary());
2010 } else if tab.url_has_selection() {
2011 let lo = tab.url_sel_range().start;
2012 tab.url_move_to(lo);
2013 } else {
2014 let prev = tab.url_prev_boundary();
2015 tab.url_move_to(prev);
2016 }
2017 cx.notify();
2018 return;
2019 }
2020 "right" => {
2021 if shift {
2022 tab.url_select_to(tab.url_next_boundary());
2023 } else if tab.url_has_selection() {
2024 let hi = tab.url_sel_range().end;
2025 tab.url_move_to(hi);
2026 } else {
2027 let next = tab.url_next_boundary();
2028 tab.url_move_to(next);
2029 }
2030 cx.notify();
2031 return;
2032 }
2033 "home" => {
2034 if shift {
2035 tab.url_select_to(0);
2036 } else {
2037 tab.url_move_to(0);
2038 }
2039 cx.notify();
2040 return;
2041 }
2042 "end" => {
2043 let len = tab.url_input.len();
2044 if shift {
2045 tab.url_select_to(len);
2046 } else {
2047 tab.url_move_to(len);
2048 }
2049 cx.notify();
2050 return;
2051 }
2052 "backspace" => {
2053 tab.url_backspace();
2054 cx.notify();
2055 return;
2056 }
2057 "delete" => {
2058 tab.url_delete_forward();
2059 cx.notify();
2060 return;
2061 }
2062 "enter" => {
2063 tab.navigate(&this.download_manager);
2064 this.show_downloads = this.download_manager.has_active()
2065 || this.show_downloads;
2066 cx.notify();
2067 return;
2068 }
2069 _ => {}
2070 }
2071 if let Some(s) = text_insert_from_keystroke(&event.keystroke) {
2072 tab.url_insert_at_cursor(&s);
2073 cx.notify();
2074 }
2075 },
2076 ))
2077 .on_mouse_down(
2078 MouseButton::Left,
2079 cx.listener(move |this, event: &MouseDownEvent, window, cx| {
2080 if !this.url_focus.is_focused(window) {
2081 this.tabs[this.active_tab].url_select_all();
2082 cx.notify();
2083 return;
2084 }
2085 let tab = &mut this.tabs[this.active_tab];
2086 let bounds = *tab.url_text_bounds.lock().unwrap();
2087 let rel_x =
2088 f32::from(event.position.x) - f32::from(bounds.origin.x);
2089 let text = SharedString::from(tab.url_input.clone());
2090 if text.is_empty() {
2091 tab.url_move_to(0);
2092 } else {
2093 let run = TextRun {
2094 len: text.len(),
2095 font: font(".SystemUIFont"),
2096 color: rgba8(0xdc, 0xdc, 0xe6, 0xff),
2097 background_color: None,
2098 underline: None,
2099 strikethrough: None,
2100 };
2101 let line = window.text_system().shape_line(
2102 text,
2103 px(14.0),
2104 &[run],
2105 None,
2106 );
2107 let idx = line.closest_index_for_x(px(rel_x));
2108 if event.modifiers.shift {
2109 tab.url_select_to(idx);
2110 } else {
2111 tab.url_move_to(idx);
2112 tab.url_selecting = true;
2113 }
2114 }
2115 cx.notify();
2116 }),
2117 )
2118 .on_mouse_up(
2119 MouseButton::Left,
2120 cx.listener(|this, _: &MouseUpEvent, _, _cx| {
2121 this.tabs[this.active_tab].url_selecting = false;
2122 }),
2123 )
2124 .on_mouse_move(cx.listener(
2125 move |this, event: &gpui::MouseMoveEvent, window, _cx| {
2126 let tab = &mut this.tabs[this.active_tab];
2127 if !tab.url_selecting {
2128 return;
2129 }
2130 let bounds = *tab.url_text_bounds.lock().unwrap();
2131 let rel_x =
2132 f32::from(event.position.x) - f32::from(bounds.origin.x);
2133 let text = SharedString::from(tab.url_input.clone());
2134 if text.is_empty() {
2135 return;
2136 }
2137 let run = TextRun {
2138 len: text.len(),
2139 font: font(".SystemUIFont"),
2140 color: rgba8(0xdc, 0xdc, 0xe6, 0xff),
2141 background_color: None,
2142 underline: None,
2143 strikethrough: None,
2144 };
2145 let line =
2146 window
2147 .text_system()
2148 .shape_line(text, px(14.0), &[run], None);
2149 let idx = line.closest_index_for_x(px(rel_x));
2150 tab.url_select_to(idx);
2151 _cx.notify();
2152 },
2153 ))
2154 .child({
2155 let url_bounds_store = url_bounds_ref.clone();
2156 canvas(
2157 {
2158 let text = url_text_for_canvas.clone();
2159 let bounds_store = url_bounds_store.clone();
2160 move |bounds, window, _cx| {
2161 *bounds_store.lock().unwrap() = bounds;
2162 if text.is_empty() {
2163 return None;
2164 }
2165 let run = TextRun {
2166 len: text.len(),
2167 font: font(".SystemUIFont"),
2168 color: rgba8(0xdc, 0xdc, 0xe6, 0xff),
2169 background_color: None,
2170 underline: None,
2171 strikethrough: None,
2172 };
2173 Some(window.text_system().shape_line(
2174 text.clone(),
2175 px(14.0),
2176 &[run],
2177 None,
2178 ))
2179 }
2180 },
2181 {
2182 let focused = url_focused;
2183 let blink = caret_blink_on;
2184 move |bounds, line_opt: Option<gpui::ShapedLine>, window, cx| {
2185 let has_sel = url_cursor != url_sel_start;
2186 let sel_lo = url_cursor.min(url_sel_start);
2187 let sel_hi = url_cursor.max(url_sel_start);
2188
2189 if let Some(ref line) = line_opt {
2190 if has_sel {
2191 let sx = line.x_for_index(sel_lo);
2192 let ex = line.x_for_index(sel_hi);
2193 let sel_bounds = Bounds::from_corners(
2194 point(bounds.origin.x + sx, bounds.origin.y),
2195 point(
2196 bounds.origin.x + ex,
2197 bounds.origin.y + bounds.size.height,
2198 ),
2199 );
2200 window.paint_quad(gpui::fill(
2201 sel_bounds,
2202 rgba8(0x44, 0x66, 0xcc, 0x70),
2203 ));
2204 }
2205
2206 let _ = line.paint(
2207 bounds.origin,
2208 bounds.size.height,
2209 window,
2210 cx,
2211 );
2212
2213 if focused && !has_sel && blink {
2214 let cx_pos = line.x_for_index(url_cursor);
2215 let cursor_bounds = Bounds::from_corners(
2216 point(
2217 bounds.origin.x + cx_pos,
2218 bounds.origin.y,
2219 ),
2220 point(
2221 bounds.origin.x + cx_pos + px(2.0),
2222 bounds.origin.y + bounds.size.height,
2223 ),
2224 );
2225 window.paint_quad(gpui::fill(
2226 cursor_bounds,
2227 rgba8(0xe8, 0xe8, 0xf0, 0xff),
2228 ));
2229 }
2230 } else if focused && blink {
2231 let cursor_bounds = Bounds::from_corners(
2232 bounds.origin,
2233 point(
2234 bounds.origin.x + px(2.0),
2235 bounds.origin.y + bounds.size.height,
2236 ),
2237 );
2238 window.paint_quad(gpui::fill(
2239 cursor_bounds,
2240 rgba8(0xe8, 0xe8, 0xf0, 0xff),
2241 ));
2242 }
2243 }
2244 },
2245 )
2246 .flex_1()
2247 .h(px(16.0))
2248 })
2249 })
2250 .child(
2251 div()
2252 .id("oxide_bookmark")
2253 .cursor_pointer()
2254 .text_lg()
2255 .text_color(if is_bookmarked {
2256 gpui::rgb(0xffc832)
2257 } else {
2258 gpui::rgb(0xa0a0a8)
2259 })
2260 .child(if is_bookmarked { "★" } else { "☆" })
2261 .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
2262 this.toggle_active_bookmark();
2263 cx.notify();
2264 })),
2265 )
2266 .child(
2267 div()
2268 .id("oxide_open_file")
2269 .cursor_pointer()
2270 .text_sm()
2271 .text_color(gpui::rgb(0xc8c8d4))
2272 .child("Open")
2273 .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
2274 if this.file_pick_rx.is_some() {
2275 return;
2276 }
2277 let (tx, rx) = mpsc::channel();
2278 this.file_pick_rx = Some(rx);
2279 std::thread::spawn(move || {
2280 let path = rfd::FileDialog::new()
2281 .add_filter("WebAssembly", &["wasm"])
2282 .set_title("Open .wasm Application")
2283 .pick_file();
2284 let msg = match path {
2285 Some(p) => match std::fs::read(&p) {
2286 Ok(bytes) => FilePickDone::Chosen { path: p, bytes },
2287 Err(_) => FilePickDone::Cancelled,
2288 },
2289 None => FilePickDone::Cancelled,
2290 };
2291 let _ = tx.send(msg);
2292 });
2293 cx.notify();
2294 })),
2295 )
2296 .child({
2297 let has_active_dl = self.download_manager.has_active();
2298 let dl_count = self.download_manager.downloads().lock().unwrap().len();
2299 div()
2300 .id("oxide_downloads_btn")
2301 .cursor_pointer()
2302 .w(px(28.0))
2303 .h(px(28.0))
2304 .flex()
2305 .items_center()
2306 .justify_center()
2307 .rounded_md()
2308 .hover(|s| s.bg(gpui::rgb(0x373741)))
2309 .text_color(if has_active_dl {
2310 gpui::rgb(0x50b0e0)
2311 } else if dl_count > 0 {
2312 gpui::rgb(0xc8c8d4)
2313 } else {
2314 gpui::rgb(0x60606a)
2315 })
2316 .child("⬇")
2317 .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
2318 this.show_downloads = !this.show_downloads;
2319 cx.notify();
2320 }))
2321 })
2322 .child(
2323 div()
2324 .id("oxide_menu_btn")
2325 .relative()
2326 .cursor_pointer()
2327 .w(px(28.0))
2328 .h(px(28.0))
2329 .flex()
2330 .items_center()
2331 .justify_center()
2332 .rounded_md()
2333 .hover(|s| s.bg(gpui::rgb(0x373741)))
2334 .text_color(gpui::rgb(0xc8c8d4))
2335 .child("⋮")
2336 .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
2337 this.show_menu = !this.show_menu;
2338 cx.notify();
2339 })),
2340 ),
2341 );
2342
2343 // Main row: optional bookmarks + content
2344 let mut main_row = div().flex_1().flex().flex_row().min_h_0();
2345
2346 if self.show_bookmarks {
2347 if let Some(store) = &self.bookmark_store {
2348 let items = store.list_all();
2349 main_row = main_row.child(
2350 div()
2351 .id("oxide_bookmarks_panel")
2352 .w(px(260.0))
2353 .h_full()
2354 .overflow_scroll()
2355 .border_r_1()
2356 .border_color(gpui::rgb(0x2a2a32))
2357 .p_2()
2358 .children(items.iter().enumerate().map(|(bi, bm)| {
2359 let url = bm.url.clone();
2360 let label = if bm.title.is_empty() {
2361 url_to_title(&bm.url)
2362 } else {
2363 bm.title.clone()
2364 };
2365 div()
2366 .id(("oxide_bm", bi))
2367 .py_1()
2368 .cursor_pointer()
2369 .text_sm()
2370 .text_color(gpui::rgb(0xaab4ff))
2371 .child(truncate_tab_title(&label))
2372 .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
2373 this.tabs[this.active_tab].navigate_to(
2374 url.clone(),
2375 true,
2376 &this.download_manager,
2377 );
2378 this.show_downloads =
2379 this.download_manager.has_active() || this.show_downloads;
2380 cx.notify();
2381 }))
2382 })),
2383 );
2384 }
2385 }
2386
2387 let mut content_col = div().flex_1().flex().flex_col().min_h_0();
2388
2389 if let Some(ref page) = self.tabs[active].internal_page {
2390 match page {
2391 InternalPage::Home => {
2392 content_col = content_col.child(
2393 div()
2394 .id("oxide_home_page")
2395 .flex_1()
2396 .flex()
2397 .items_center()
2398 .justify_center()
2399 .p_4()
2400 .child(
2401 div()
2402 .w(px(560.0))
2403 .p_5()
2404 .rounded_lg()
2405 .bg(gpui::rgb(0x222228))
2406 .border_1()
2407 .border_color(gpui::rgb(0x3a3a44))
2408 .child(
2409 div()
2410 .text_xl()
2411 .font_weight(gpui::FontWeight::BOLD)
2412 .text_color(gpui::rgb(0x80d8d0))
2413 .child("Oxide Browser"),
2414 )
2415 .child(
2416 div()
2417 .mt_2()
2418 .text_sm()
2419 .text_color(gpui::rgb(0xc8c8d4))
2420 .child("A binary-first browser for WebAssembly apps. Oxide loads .wasm modules directly, runs them in a capability-based Wasmtime sandbox, and gives them a native GPU-accelerated canvas instead of an HTML/JavaScript runtime."),
2421 )
2422 .child(
2423 div()
2424 .mt_4()
2425 .flex()
2426 .flex_col()
2427 .gap_2()
2428 .text_xs()
2429 .text_color(gpui::rgb(0xa6a6b8))
2430 .child("Open a local .wasm file, enter an HTTP(S) .wasm URL, or build a new app with Forge.")
2431 .child("Guest apps start with no filesystem, environment, or socket access. Every host interaction goes through explicit Oxide capabilities."),
2432 )
2433 .child(
2434 div()
2435 .mt_4()
2436 .h(px(1.0))
2437 .bg(gpui::rgb(0x3a3a44)),
2438 )
2439 .child(
2440 div()
2441 .mt_4()
2442 .flex()
2443 .flex_row()
2444 .items_center()
2445 .justify_between()
2446 .gap_3()
2447 .child(
2448 div()
2449 .flex_1()
2450 .min_w_0()
2451 .child(
2452 div()
2453 .text_sm()
2454 .font_weight(gpui::FontWeight::SEMIBOLD)
2455 .text_color(gpui::rgb(0xe8e8f4))
2456 .child("Create with Oxide Forge"),
2457 )
2458 .child(
2459 div()
2460 .mt_1()
2461 .text_xs()
2462 .text_color(gpui::rgb(0x8a8aa0))
2463 .child("Describe an app and Forge will generate, build, and hot-load a sandboxed guest WASM module."),
2464 ),
2465 )
2466 .child(
2467 div()
2468 .id("oxide_home_forge")
2469 .px_3()
2470 .py_2()
2471 .rounded_md()
2472 .bg(gpui::rgb(0x2f6f68))
2473 .text_sm()
2474 .text_color(gpui::rgb(0xffffff))
2475 .cursor_pointer()
2476 .child("oxide://forge")
2477 .on_click(cx.listener(
2478 |this, _: &ClickEvent, _, cx| {
2479 this.tabs[this.active_tab].navigate_to(
2480 "oxide://forge".to_string(),
2481 true,
2482 &this.download_manager,
2483 );
2484 cx.notify();
2485 },
2486 )),
2487 ),
2488 ),
2489 ),
2490 );
2491 }
2492 InternalPage::History => {
2493 let all_entries: Vec<(Vec<u8>, String, String, u64)> = self
2494 .history_store
2495 .as_ref()
2496 .map(|store| {
2497 store
2498 .list_all()
2499 .into_iter()
2500 .map(|(key, item)| (key, item.url, item.title, item.visited_at_ms))
2501 .collect()
2502 })
2503 .unwrap_or_default();
2504 let has_entries = !all_entries.is_empty();
2505
2506 content_col = content_col.child(
2507 div()
2508 .id("oxide_history_page")
2509 .flex_1()
2510 .overflow_scroll()
2511 .p_4()
2512 .child(
2513 div()
2514 .flex()
2515 .flex_row()
2516 .items_center()
2517 .justify_between()
2518 .child(
2519 div()
2520 .child(
2521 div()
2522 .text_lg()
2523 .font_weight(gpui::FontWeight::BOLD)
2524 .text_color(gpui::rgb(0xb478ff))
2525 .child("History"),
2526 )
2527 .child(
2528 div()
2529 .mt_1()
2530 .text_xs()
2531 .text_color(gpui::rgb(0x7a7a90))
2532 .child(format!(
2533 "{} visited page{}",
2534 all_entries.len(),
2535 if all_entries.len() == 1 {
2536 ""
2537 } else {
2538 "s"
2539 }
2540 )),
2541 ),
2542 )
2543 .when(has_entries, |d| {
2544 d.child(
2545 div()
2546 .id("oxide_hist_clear_all")
2547 .flex()
2548 .flex_row()
2549 .items_center()
2550 .gap_1()
2551 .px_3()
2552 .py(px(6.0))
2553 .rounded_md()
2554 .cursor_pointer()
2555 .bg(gpui::rgb(0x2a2a34))
2556 .hover(|s| s.bg(gpui::rgb(0x3a2a2a)))
2557 .text_xs()
2558 .text_color(gpui::rgb(0xf05050))
2559 .child("🗑")
2560 .child("Clear All")
2561 .on_click(cx.listener(
2562 |this, _: &ClickEvent, _, cx| {
2563 if let Some(store) = &this.history_store {
2564 let _ = store.clear();
2565 }
2566 cx.notify();
2567 },
2568 )),
2569 )
2570 }),
2571 )
2572 .child(div().mt_3().h(px(1.0)).bg(gpui::rgb(0x2a2a32)))
2573 .when(!has_entries, |d| {
2574 d.child(
2575 div()
2576 .mt_4()
2577 .text_sm()
2578 .text_color(gpui::rgb(0x7a7a90))
2579 .child(
2580 "No history yet. Navigate to a page to see it here.",
2581 ),
2582 )
2583 })
2584 .children(all_entries.into_iter().enumerate().map(
2585 |(i, (key, url, title, ts))| {
2586 let url_nav = url.clone();
2587 let key_for_delete = key.clone();
2588 let display_title = if title.is_empty() {
2589 url_to_title(&url)
2590 } else {
2591 title
2592 };
2593 let friendly = format_friendly_timestamp(ts);
2594 div()
2595 .id(("oxide_hist", i))
2596 .flex()
2597 .flex_row()
2598 .items_center()
2599 .justify_between()
2600 .py_2()
2601 .px_2()
2602 .rounded_md()
2603 .hover(|s| s.bg(gpui::rgb(0x2a2a34)))
2604 .border_b_1()
2605 .border_color(gpui::rgb(0x222230))
2606 .child(
2607 div()
2608 .id(("oxide_hist_link", i))
2609 .flex_1()
2610 .min_w_0()
2611 .overflow_hidden()
2612 .cursor_pointer()
2613 .child(
2614 div()
2615 .text_sm()
2616 .text_color(gpui::rgb(0xaab4ff))
2617 .child(display_title),
2618 )
2619 .child(
2620 div()
2621 .text_xs()
2622 .text_color(gpui::rgb(0x6a6a80))
2623 .mt(px(2.0))
2624 .child(url.clone()),
2625 )
2626 .on_click(cx.listener(
2627 move |this, _: &ClickEvent, _, cx| {
2628 this.tabs[this.active_tab].navigate_to(
2629 url_nav.clone(),
2630 true,
2631 &this.download_manager,
2632 );
2633 this.show_downloads =
2634 this.download_manager.has_active()
2635 || this.show_downloads;
2636 cx.notify();
2637 },
2638 )),
2639 )
2640 .child(
2641 div()
2642 .flex_shrink_0()
2643 .ml_3()
2644 .text_xs()
2645 .text_color(gpui::rgb(0x7a7a90))
2646 .child(friendly),
2647 )
2648 .child(
2649 div()
2650 .id(("oxide_hist_del", i))
2651 .flex_shrink_0()
2652 .ml_2()
2653 .w(px(24.0))
2654 .h(px(24.0))
2655 .flex()
2656 .items_center()
2657 .justify_center()
2658 .rounded_sm()
2659 .cursor_pointer()
2660 .hover(|s| s.bg(gpui::rgb(0x3a2a2a)))
2661 .text_xs()
2662 .text_color(gpui::rgb(0x9696a0))
2663 .child("🗑")
2664 .on_click(cx.listener(
2665 move |this, _: &ClickEvent, _, cx| {
2666 if let Some(store) = &this.history_store {
2667 let _ = store
2668 .remove_by_key(&key_for_delete);
2669 }
2670 cx.notify();
2671 },
2672 )),
2673 )
2674 },
2675 )),
2676 );
2677 }
2678 InternalPage::Bookmarks => {
2679 let items = self
2680 .bookmark_store
2681 .as_ref()
2682 .map(|s| s.list_all())
2683 .unwrap_or_default();
2684
2685 content_col = content_col.child(
2686 div()
2687 .id("oxide_bookmarks_page")
2688 .flex_1()
2689 .overflow_scroll()
2690 .p_4()
2691 .child(
2692 div()
2693 .text_lg()
2694 .font_weight(gpui::FontWeight::BOLD)
2695 .text_color(gpui::rgb(0xb478ff))
2696 .child("Bookmarks"),
2697 )
2698 .child(
2699 div()
2700 .mt_1()
2701 .text_xs()
2702 .text_color(gpui::rgb(0x7a7a90))
2703 .child(format!(
2704 "{} bookmark{}",
2705 items.len(),
2706 if items.len() == 1 { "" } else { "s" }
2707 )),
2708 )
2709 .child(
2710 div()
2711 .mt_3()
2712 .h(px(1.0))
2713 .bg(gpui::rgb(0x2a2a32)),
2714 )
2715 .when(items.is_empty(), |d| {
2716 d.child(
2717 div()
2718 .mt_4()
2719 .text_sm()
2720 .text_color(gpui::rgb(0x7a7a90))
2721 .child("No bookmarks yet. Press ☆ in the toolbar to bookmark a page."),
2722 )
2723 })
2724 .children(items.into_iter().enumerate().map(|(i, bm)| {
2725 let url = bm.url.clone();
2726 let url_nav = bm.url.clone();
2727 let label = if bm.title.is_empty() {
2728 url_to_title(&bm.url)
2729 } else {
2730 bm.title.clone()
2731 };
2732 div()
2733 .id(("oxide_bmp", i))
2734 .flex()
2735 .flex_row()
2736 .items_center()
2737 .py_2()
2738 .px_2()
2739 .rounded_md()
2740 .cursor_pointer()
2741 .hover(|s| s.bg(gpui::rgb(0x2a2a34)))
2742 .border_b_1()
2743 .border_color(gpui::rgb(0x222230))
2744 .child(
2745 div()
2746 .flex_1()
2747 .min_w_0()
2748 .overflow_hidden()
2749 .child(
2750 div()
2751 .text_sm()
2752 .text_color(gpui::rgb(0xaab4ff))
2753 .child(label),
2754 )
2755 .child(
2756 div()
2757 .text_xs()
2758 .text_color(gpui::rgb(0x6a6a80))
2759 .mt(px(2.0))
2760 .child(url),
2761 ),
2762 )
2763 .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
2764 this.tabs[this.active_tab]
2765 .navigate_to(url_nav.clone(), true, &this.download_manager);
2766 this.show_downloads = this.download_manager.has_active() || this.show_downloads;
2767 cx.notify();
2768 }))
2769 })),
2770 );
2771 }
2772 InternalPage::About => {
2773 content_col = content_col.child(
2774 div()
2775 .flex_1()
2776 .flex()
2777 .items_center()
2778 .justify_center()
2779 .p_4()
2780 .child(
2781 div()
2782 .w(px(480.0))
2783 .p_5()
2784 .rounded_lg()
2785 .bg(gpui::rgb(0x222228))
2786 .border_1()
2787 .border_color(gpui::rgb(0x3a3a44))
2788 .child(
2789 div()
2790 .text_xl()
2791 .font_weight(gpui::FontWeight::BOLD)
2792 .text_color(gpui::rgb(0xb478ff))
2793 .child("Oxide Browser"),
2794 )
2795 .child(
2796 div()
2797 .mt_1()
2798 .text_sm()
2799 .text_color(gpui::rgb(0x8888a0))
2800 .child(format!(
2801 "Version {}",
2802 env!("CARGO_PKG_VERSION")
2803 )),
2804 )
2805 .child(
2806 div()
2807 .mt_3()
2808 .h(px(1.0))
2809 .bg(gpui::rgb(0x3a3a44)),
2810 )
2811 .child(
2812 div()
2813 .mt_3()
2814 .text_sm()
2815 .text_color(gpui::rgb(0xc0c0cc))
2816 .child("A binary-first browser that fetches and runs .wasm modules in a secure sandbox, powered by a GPU-accelerated native UI."),
2817 )
2818 .child(
2819 div()
2820 .mt_3()
2821 .flex()
2822 .flex_col()
2823 .gap_1()
2824 .text_xs()
2825 .text_color(gpui::rgb(0x9696a0))
2826 .child(
2827 div().flex().flex_row().gap_2()
2828 .child(div().w(px(70.0)).text_color(gpui::rgb(0x7a7a90)).child("Engine"))
2829 .child(div().child("Wasmtime sandbox")),
2830 )
2831 .child(
2832 div().flex().flex_row().gap_2()
2833 .child(div().w(px(70.0)).text_color(gpui::rgb(0x7a7a90)).child("UI"))
2834 .child(div().child("GPUI (Zed's GPU-accelerated framework)")),
2835 )
2836 .child(
2837 div().flex().flex_row().gap_2()
2838 .child(div().w(px(70.0)).text_color(gpui::rgb(0x7a7a90)).child("Graphics"))
2839 .child(div().child("Metal / wgpu")),
2840 )
2841 .child(
2842 div().flex().flex_row().gap_2()
2843 .child(div().w(px(70.0)).text_color(gpui::rgb(0x7a7a90)).child("License"))
2844 .child(div().child("MIT")),
2845 ),
2846 )
2847 .child(
2848 div()
2849 .mt_3()
2850 .text_xs()
2851 .text_color(gpui::rgb(0x6a6a80))
2852 .child("github.com/niklabh/oxide"),
2853 ),
2854 ),
2855 );
2856 }
2857 InternalPage::Forge => {
2858 let forge_ready = self.ensure_forge();
2859 let snapshot = self.forge_current_snapshot();
2860 let creations = self.forge_creations();
2861 let output_dir = self
2862 .forge_output_dir()
2863 .map(|p| p.display().to_string())
2864 .unwrap_or_else(|| "(not configured)".to_string());
2865 let prompt_draft = self.tabs[active].forge_prompt.clone();
2866 let prompt_focused = self.forge_focus.is_focused(window);
2867 let settings_open = self.forge_config.settings_open;
2868 let active_provider = self.forge_config.active_provider;
2869 let active_model = self.forge_config.active_model();
2870 let settings_provider = self.forge_settings_provider;
2871 let settings_key_focused = self.forge_settings_key_focus.is_focused(window);
2872 let settings_model_focused = self.forge_settings_model_focus.is_focused(window);
2873 let settings_key_draft = self.forge_settings_key_draft.clone();
2874 let settings_model_draft = self.forge_settings_model_draft.clone();
2875 let saved_key = self.forge_config.provider(settings_provider).api_key;
2876 let settings_key_hint = if settings_key_draft.is_empty() {
2877 if saved_key.is_empty() {
2878 format!("Paste {} API key", settings_provider.label())
2879 } else {
2880 mask_api_key(&saved_key)
2881 }
2882 } else {
2883 "•".repeat(settings_key_draft.chars().count().min(28))
2884 };
2885 let settings_save_enabled = !settings_key_draft.trim().is_empty()
2886 || !settings_model_draft.trim().is_empty()
2887 || !saved_key.is_empty();
2888
2889 let (status_word, status_color, status_hint) = if !forge_ready {
2890 (
2891 "Configure AI".to_string(),
2892 gpui::rgb(0xf08050),
2893 "Open Settings and add an API key for your preferred provider."
2894 .to_string(),
2895 )
2896 } else {
2897 (
2898 "ready".to_string(),
2899 gpui::rgb(0x80d090),
2900 format!(
2901 "Using {} · {}. Chat to generate sandboxed guest WASM apps. Output: {output_dir}",
2902 active_provider.label(),
2903 active_model
2904 ),
2905 )
2906 };
2907
2908 let phase = snapshot.as_ref().map(|s| s.phase);
2909 let can_build = matches!(
2910 phase,
2911 Some(ForgePhase::StreamComplete)
2912 | Some(ForgePhase::Error)
2913 | Some(ForgePhase::BuildOk),
2914 );
2915 let can_run = matches!(phase, Some(ForgePhase::BuildOk));
2916 let can_delete = snapshot
2917 .as_ref()
2918 .map(|s| !matches!(s.phase, ForgePhase::Streaming | ForgePhase::Building))
2919 .unwrap_or(false);
2920
2921 let caret = if prompt_focused && caret_blink_on {
2922 "\u{2588}"
2923 } else {
2924 ""
2925 };
2926 let prompt_display = if prompt_draft.is_empty() {
2927 "Describe an app…".to_string()
2928 } else {
2929 prompt_draft.clone()
2930 };
2931 let prompt_color = if prompt_draft.is_empty() {
2932 gpui::rgb(0x5a5a6a)
2933 } else {
2934 gpui::rgb(0xe0e0ff)
2935 };
2936
2937 let submit_enabled = forge_ready && !prompt_draft.trim().is_empty();
2938 let submit_label = if self.tabs[active].forge_session_id.is_some() {
2939 "Apply changes"
2940 } else {
2941 "Create app"
2942 };
2943
2944 let selected_id = self.tabs[active].forge_session_id;
2945
2946 let list_panel = div()
2947 .id("oxide_forge_list")
2948 .w(px(260.0))
2949 .h_full()
2950 .flex()
2951 .flex_col()
2952 .min_h_0()
2953 .border_r_1()
2954 .border_color(gpui::rgb(0x2a2a32))
2955 .pr_3()
2956 .child(
2957 div()
2958 .flex()
2959 .flex_row()
2960 .items_center()
2961 .justify_between()
2962 .child(
2963 div()
2964 .text_sm()
2965 .font_weight(gpui::FontWeight::BOLD)
2966 .text_color(gpui::rgb(0xe8e8f4))
2967 .child("Creations"),
2968 )
2969 .child(
2970 div()
2971 .id("oxide_forge_new_btn")
2972 .px_2()
2973 .py(px(5.0))
2974 .rounded_sm()
2975 .bg(gpui::rgb(0x2a2a36))
2976 .text_xs()
2977 .text_color(gpui::rgb(0xc8c8d4))
2978 .cursor_pointer()
2979 .child("New")
2980 .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
2981 let tab = &mut this.tabs[this.active_tab];
2982 tab.forge_session_id = None;
2983 tab.forge_prompt.clear();
2984 cx.notify();
2985 })),
2986 ),
2987 )
2988 .child(
2989 div()
2990 .mt_2()
2991 .text_xs()
2992 .text_color(gpui::rgb(0x7a7a90))
2993 .child(format!(
2994 "{} app{}",
2995 creations.len(),
2996 if creations.len() == 1 { "" } else { "s" }
2997 )),
2998 )
2999 .child(
3000 div()
3001 .id("oxide_forge_creation_scroll")
3002 .mt_3()
3003 .flex_1()
3004 .min_h_0()
3005 .overflow_scroll()
3006 .children(creations.iter().enumerate().map(|(i, item)| {
3007 let id = item.id;
3008 let selected = Some(id) == selected_id;
3009 let title = if item.prompt.trim().is_empty() {
3010 item.slug.clone()
3011 } else {
3012 truncate_tab_title(&item.prompt)
3013 };
3014 let path = item.project_dir.display().to_string();
3015 div()
3016 .id(("oxide_forge_creation", i))
3017 .mb_2()
3018 .p_2()
3019 .rounded_md()
3020 .cursor_pointer()
3021 .bg(if selected {
3022 gpui::rgb(0x2f2944)
3023 } else {
3024 gpui::rgb(0x202028)
3025 })
3026 .border_1()
3027 .border_color(if selected {
3028 gpui::rgb(0x7a5ae0)
3029 } else {
3030 gpui::rgb(0x2e2e38)
3031 })
3032 .child(
3033 div()
3034 .flex()
3035 .flex_row()
3036 .items_center()
3037 .justify_between()
3038 .gap_2()
3039 .child(
3040 div()
3041 .flex_1()
3042 .min_w_0()
3043 .text_sm()
3044 .text_color(gpui::rgb(0xe4e4f0))
3045 .child(title),
3046 )
3047 .child(
3048 div()
3049 .text_xs()
3050 .text_color(phase_color_for(item.phase))
3051 .child(phase_label(item.phase)),
3052 ),
3053 )
3054 .child(
3055 div()
3056 .mt_1()
3057 .text_xs()
3058 .text_color(gpui::rgb(0x747488))
3059 .child(item.slug.clone()),
3060 )
3061 .child(
3062 div()
3063 .mt_1()
3064 .text_xs()
3065 .text_color(gpui::rgb(0x5f5f72))
3066 .child(path),
3067 )
3068 .on_click(cx.listener(
3069 move |this, _: &ClickEvent, _, cx| {
3070 this.tabs[this.active_tab].forge_session_id =
3071 Some(id);
3072 this.tabs[this.active_tab].forge_prompt.clear();
3073 cx.notify();
3074 },
3075 ))
3076 })),
3077 );
3078
3079 let mut chat_panel = div()
3080 .id("oxide_forge_chat")
3081 .flex_1()
3082 .min_h_0()
3083 .flex()
3084 .flex_col()
3085 .rounded_md()
3086 .bg(gpui::rgb(0x181820))
3087 .border_1()
3088 .border_color(gpui::rgb(0x2a2a34));
3089
3090 let mut code_panel = div()
3091 .id("oxide_forge_code_panel")
3092 .w(px(340.0))
3093 .min_h_0()
3094 .flex()
3095 .flex_col()
3096 .rounded_md()
3097 .bg(gpui::rgb(0x15151c))
3098 .border_1()
3099 .border_color(gpui::rgb(0x2a2a34));
3100
3101 if let Some(snap) = snapshot.clone() {
3102 let phase_text = if snap.retries_used > 0 {
3103 format!(
3104 "{} (auto-fix {}/{})",
3105 phase_label(snap.phase),
3106 snap.retries_used,
3107 snap.max_retries
3108 )
3109 } else {
3110 phase_label(snap.phase).to_string()
3111 };
3112 let phase_color = phase_color_for(snap.phase);
3113 let prompt_preview = truncate_tab_title(&snap.prompt);
3114 let code_text = snap.code.clone();
3115 let build_log = snap.build_log.clone();
3116 let err = snap.error.clone();
3117 let artifact = snap
3118 .artifact_path
3119 .as_ref()
3120 .map(|p| p.display().to_string())
3121 .unwrap_or_else(|| "No wasm artifact yet".to_string());
3122
3123 chat_panel = chat_panel
3124 .child(
3125 div()
3126 .px_3()
3127 .py_2()
3128 .border_b_1()
3129 .border_color(gpui::rgb(0x2a2a34))
3130 .flex()
3131 .flex_row()
3132 .items_center()
3133 .justify_between()
3134 .child(
3135 div()
3136 .child(
3137 div()
3138 .text_sm()
3139 .font_weight(gpui::FontWeight::BOLD)
3140 .text_color(gpui::rgb(0xe8e8f4))
3141 .child(prompt_preview),
3142 )
3143 .child(
3144 div()
3145 .mt_1()
3146 .text_xs()
3147 .text_color(gpui::rgb(0x7a7a90))
3148 .child(format!(
3149 "{} · {} · {}",
3150 snap.slug,
3151 snap.provider.label(),
3152 snap.model
3153 )),
3154 ),
3155 )
3156 .child(
3157 div()
3158 .px_2()
3159 .py(px(2.0))
3160 .rounded_sm()
3161 .bg(gpui::rgb(0x22222c))
3162 .text_xs()
3163 .text_color(phase_color)
3164 .child(phase_text),
3165 ),
3166 )
3167 .child(
3168 div()
3169 .id("oxide_forge_messages")
3170 .flex_1()
3171 .min_h_0()
3172 .overflow_scroll()
3173 .p_3()
3174 .flex()
3175 .flex_col()
3176 .gap_2()
3177 .children(
3178 snap.messages
3179 .iter()
3180 .enumerate()
3181 .map(|(i, msg)| forge_chat_bubble(i, msg, snap.phase)),
3182 ),
3183 );
3184
3185 code_panel = code_panel
3186 .child(
3187 div()
3188 .px_3()
3189 .py_2()
3190 .border_b_1()
3191 .border_color(gpui::rgb(0x2a2a34))
3192 .text_xs()
3193 .font_weight(gpui::FontWeight::BOLD)
3194 .text_color(gpui::rgb(0xc0c0d8))
3195 .child("Generated code"),
3196 )
3197 .child(
3198 div()
3199 .id("oxide_forge_code")
3200 .flex_1()
3201 .min_h_0()
3202 .overflow_scroll()
3203 .px_3()
3204 .py_2()
3205 .text_xs()
3206 .text_color(gpui::rgb(0xc0c0d8))
3207 .font(font("Menlo"))
3208 .child(if code_text.is_empty() {
3209 "(awaiting stream…)".to_string()
3210 } else {
3211 code_text
3212 }),
3213 )
3214 .child(
3215 div()
3216 .id("oxide_forge_log")
3217 .max_h(px(120.0))
3218 .overflow_scroll()
3219 .px_3()
3220 .py_2()
3221 .border_t_1()
3222 .border_color(gpui::rgb(0x2a2a34))
3223 .text_xs()
3224 .text_color(gpui::rgb(0xf0c0a0))
3225 .font(font("Menlo"))
3226 .child(if let Some(e) = err {
3227 format!("error: {e}\n\n{build_log}")
3228 } else if build_log.is_empty() {
3229 artifact
3230 } else {
3231 build_log
3232 }),
3233 )
3234 .child(
3235 div()
3236 .p_2()
3237 .flex()
3238 .flex_row()
3239 .gap_2()
3240 .child(
3241 div()
3242 .id("oxide_forge_build_btn")
3243 .flex_1()
3244 .px_2()
3245 .py_2()
3246 .rounded_md()
3247 .bg(if can_build {
3248 gpui::rgb(0x3a6a8a)
3249 } else {
3250 gpui::rgb(0x3a3a44)
3251 })
3252 .text_xs()
3253 .text_color(gpui::rgb(0xffffff))
3254 .cursor_pointer()
3255 .child("Build")
3256 .on_click(cx.listener(
3257 |this, _: &ClickEvent, _, cx| {
3258 this.forge_build();
3259 cx.notify();
3260 },
3261 )),
3262 )
3263 .child(
3264 div()
3265 .id("oxide_forge_run_btn")
3266 .flex_1()
3267 .px_2()
3268 .py_2()
3269 .rounded_md()
3270 .bg(if can_run {
3271 gpui::rgb(0x4a9a6a)
3272 } else {
3273 gpui::rgb(0x3a3a44)
3274 })
3275 .text_xs()
3276 .text_color(gpui::rgb(0xffffff))
3277 .cursor_pointer()
3278 .child("Run")
3279 .on_click(cx.listener(
3280 |this, _: &ClickEvent, _, cx| {
3281 this.forge_run_in_new_tab();
3282 cx.notify();
3283 },
3284 )),
3285 )
3286 .child(
3287 div()
3288 .id("oxide_forge_delete_btn")
3289 .px_2()
3290 .py_2()
3291 .rounded_md()
3292 .bg(if can_delete {
3293 gpui::rgb(0x8a3a3a)
3294 } else {
3295 gpui::rgb(0x3a3a44)
3296 })
3297 .text_xs()
3298 .text_color(gpui::rgb(0xffffff))
3299 .cursor_pointer()
3300 .child("Del")
3301 .on_click(cx.listener(
3302 |this, _: &ClickEvent, _, cx| {
3303 this.forge_delete_current_creation();
3304 cx.notify();
3305 },
3306 )),
3307 ),
3308 );
3309 } else {
3310 chat_panel = chat_panel.child(
3311 div()
3312 .flex_1()
3313 .flex()
3314 .flex_col()
3315 .items_center()
3316 .justify_center()
3317 .p_4()
3318 .child(
3319 div()
3320 .text_sm()
3321 .text_color(gpui::rgb(0x9a9ab0))
3322 .child("Welcome to Oxide Forge"),
3323 )
3324 .child(
3325 div()
3326 .mt_2()
3327 .text_xs()
3328 .text_color(gpui::rgb(0x6a6a80))
3329 .child(
3330 "Describe an app below — like Cursor chat, Forge writes Rust, builds WASM, and runs it in a tab.",
3331 ),
3332 ),
3333 );
3334 code_panel = code_panel.child(
3335 div()
3336 .flex_1()
3337 .flex()
3338 .items_center()
3339 .justify_center()
3340 .text_xs()
3341 .text_color(gpui::rgb(0x6a6a80))
3342 .child("Code appears here after generation."),
3343 );
3344 }
3345
3346 let workspace_panel = div()
3347 .flex_1()
3348 .min_h_0()
3349 .flex()
3350 .flex_row()
3351 .gap_3()
3352 .child(list_panel)
3353 .child(chat_panel)
3354 .child(code_panel);
3355
3356 content_col = content_col.child(
3357 div()
3358 .id("oxide_forge_page")
3359 .flex_1()
3360 .flex()
3361 .flex_col()
3362 .min_h_0()
3363 .p_4()
3364 .child(
3365 div()
3366 .flex()
3367 .flex_row()
3368 .items_center()
3369 .justify_between()
3370 .gap_3()
3371 .child(
3372 div()
3373 .flex_1()
3374 .min_w_0()
3375 .child(
3376 div()
3377 .text_lg()
3378 .font_weight(gpui::FontWeight::BOLD)
3379 .text_color(gpui::rgb(0xb478ff))
3380 .child("Oxide Forge"),
3381 )
3382 .child(
3383 div()
3384 .mt_1()
3385 .text_xs()
3386 .text_color(gpui::rgb(0x8a8aa0))
3387 .child(status_hint.clone()),
3388 ),
3389 )
3390 .child(
3391 div()
3392 .flex()
3393 .flex_row()
3394 .gap_1()
3395 .children(ForgeProvider::ALL.iter().enumerate().map(
3396 |(i, provider)| {
3397 let selected = *provider == active_provider;
3398 let configured =
3399 self.forge_config.provider(*provider)
3400 .has_key();
3401 let dot = if configured { "● " } else { "" };
3402 div()
3403 .id(("oxide_forge_provider", i))
3404 .px_2()
3405 .py_1()
3406 .rounded_sm()
3407 .bg(if selected {
3408 gpui::rgb(0x4a3a7a)
3409 } else {
3410 gpui::rgb(0x2a2a36)
3411 })
3412 .text_xs()
3413 .text_color(if selected {
3414 gpui::rgb(0xffffff)
3415 } else {
3416 gpui::rgb(0xb0b0c0)
3417 })
3418 .cursor_pointer()
3419 .child(format!(
3420 "{dot}{}",
3421 provider.label()
3422 ))
3423 .on_click(cx.listener(
3424 move |this, _: &ClickEvent, _, cx| {
3425 this.forge_select_provider(
3426 *provider,
3427 );
3428 cx.notify();
3429 },
3430 ))
3431 },
3432 )),
3433 )
3434 .child(
3435 div()
3436 .id("oxide_forge_settings_btn")
3437 .px_2()
3438 .py_1()
3439 .rounded_sm()
3440 .bg(if settings_open {
3441 gpui::rgb(0x3a4a5a)
3442 } else {
3443 gpui::rgb(0x2a2a36)
3444 })
3445 .text_xs()
3446 .text_color(gpui::rgb(0xd0d0dc))
3447 .cursor_pointer()
3448 .child("Settings")
3449 .on_click(cx.listener(
3450 |this, _: &ClickEvent, _, cx| {
3451 this.forge_toggle_settings();
3452 cx.notify();
3453 },
3454 )),
3455 )
3456 .child(
3457 div()
3458 .id("oxide_forge_choose_folder")
3459 .px_2()
3460 .py_1()
3461 .rounded_sm()
3462 .bg(gpui::rgb(0x2a2a36))
3463 .text_xs()
3464 .text_color(gpui::rgb(0xd0d0dc))
3465 .cursor_pointer()
3466 .child("Folder")
3467 .on_click(cx.listener(
3468 |this, _: &ClickEvent, _, cx| {
3469 this.forge_pick_output_dir();
3470 cx.notify();
3471 },
3472 )),
3473 )
3474 .child(
3475 div()
3476 .px_2()
3477 .py_1()
3478 .rounded_sm()
3479 .bg(gpui::rgb(0x22222c))
3480 .text_xs()
3481 .text_color(status_color)
3482 .child(status_word),
3483 ),
3484 )
3485 .child(div().mt_3().h(px(1.0)).bg(gpui::rgb(0x2a2a32)))
3486 .when(settings_open, |panel| {
3487 panel.child(
3488 div()
3489 .id("oxide_forge_settings")
3490 .mt_3()
3491 .p_3()
3492 .rounded_md()
3493 .bg(gpui::rgb(0x1a1a22))
3494 .border_1()
3495 .border_color(gpui::rgb(0x33333f))
3496 .child(
3497 div()
3498 .text_sm()
3499 .font_weight(gpui::FontWeight::BOLD)
3500 .text_color(gpui::rgb(0xe0e0f0))
3501 .child("AI provider settings"),
3502 )
3503 .child(
3504 div()
3505 .mt_2()
3506 .flex()
3507 .flex_row()
3508 .gap_1()
3509 .children(
3510 ForgeProvider::ALL.iter().enumerate().map(
3511 |(i, provider)| {
3512 let selected =
3513 *provider == settings_provider;
3514 let configured = self
3515 .forge_config
3516 .provider(*provider)
3517 .has_key();
3518 div()
3519 .id(("oxide_forge_settings_tab", i))
3520 .px_2()
3521 .py_1()
3522 .rounded_sm()
3523 .bg(if selected {
3524 gpui::rgb(0x3a3a50)
3525 } else {
3526 gpui::rgb(0x252530)
3527 })
3528 .text_xs()
3529 .text_color(if configured {
3530 gpui::rgb(0x90d0a0)
3531 } else {
3532 gpui::rgb(0x9090a8)
3533 })
3534 .cursor_pointer()
3535 .child(provider.label())
3536 .on_click(cx.listener(
3537 move |this,
3538 _: &ClickEvent,
3539 _,
3540 cx| {
3541 this.forge_settings_provider =
3542 *provider;
3543 this.forge_sync_settings_drafts();
3544 cx.notify();
3545 },
3546 ))
3547 },
3548 ),
3549 ),
3550 )
3551 .child(
3552 div()
3553 .mt_2()
3554 .text_xs()
3555 .text_color(gpui::rgb(0x7a7a90))
3556 .child(format!(
3557 "Keys are stored locally at {}",
3558 ForgeUserConfig::config_path().display()
3559 )),
3560 )
3561 .child(
3562 div()
3563 .mt_2()
3564 .text_xs()
3565 .text_color(gpui::rgb(0x8a8aa0))
3566 .child("Model"),
3567 )
3568 .child(
3569 div()
3570 .id("oxide_forge_settings_model")
3571 .mt_1()
3572 .track_focus(&self.forge_settings_model_focus)
3573 .focusable()
3574 .px_3()
3575 .py_2()
3576 .rounded_md()
3577 .bg(gpui::rgb(0x121218))
3578 .border_1()
3579 .border_color(if settings_model_focused {
3580 gpui::rgb(0x7a5ae0)
3581 } else {
3582 gpui::rgb(0x33333f)
3583 })
3584 .text_sm()
3585 .text_color(gpui::rgb(0xe0e0ff))
3586 .child(if settings_model_focused && caret_blink_on
3587 {
3588 format!("{settings_model_draft}\u{2588}")
3589 } else {
3590 settings_model_draft.clone()
3591 })
3592 .on_click(cx.listener(
3593 |this, _: &ClickEvent, window, cx| {
3594 window.focus(
3595 &this.forge_settings_model_focus,
3596 );
3597 cx.notify();
3598 },
3599 )),
3600 )
3601 .child(
3602 div()
3603 .mt_2()
3604 .text_xs()
3605 .text_color(gpui::rgb(0x8a8aa0))
3606 .child("API key"),
3607 )
3608 .child(
3609 div()
3610 .mt_1()
3611 .flex()
3612 .flex_row()
3613 .gap_2()
3614 .child(
3615 div()
3616 .id("oxide_forge_settings_key")
3617 .track_focus(&self.forge_settings_key_focus)
3618 .focusable()
3619 .flex_1()
3620 .px_3()
3621 .py_2()
3622 .rounded_md()
3623 .bg(gpui::rgb(0x121218))
3624 .border_1()
3625 .border_color(if settings_key_focused {
3626 gpui::rgb(0x4ea39a)
3627 } else {
3628 gpui::rgb(0x33333f)
3629 })
3630 .text_sm()
3631 .text_color(gpui::rgb(0xe0e0ff))
3632 .child(
3633 if settings_key_focused && caret_blink_on
3634 {
3635 format!("{settings_key_hint}\u{2588}")
3636 } else {
3637 settings_key_hint.clone()
3638 },
3639 )
3640 .on_click(cx.listener(
3641 |this, _: &ClickEvent, window, cx| {
3642 window.focus(
3643 &this.forge_settings_key_focus,
3644 );
3645 cx.notify();
3646 },
3647 )),
3648 )
3649 .child(
3650 div()
3651 .id("oxide_forge_settings_save")
3652 .px_3()
3653 .py_2()
3654 .rounded_md()
3655 .bg(if settings_save_enabled {
3656 gpui::rgb(0x2f6f68)
3657 } else {
3658 gpui::rgb(0x3a3a44)
3659 })
3660 .text_sm()
3661 .text_color(gpui::rgb(0xffffff))
3662 .cursor_pointer()
3663 .child("Save")
3664 .on_click(cx.listener(
3665 |this, _: &ClickEvent, _, cx| {
3666 this.forge_save_provider_settings();
3667 cx.notify();
3668 },
3669 )),
3670 ),
3671 ),
3672 )
3673 })
3674 .child(
3675 div()
3676 .mt_3()
3677 .flex_1()
3678 .min_h_0()
3679 .flex()
3680 .flex_col()
3681 .child(workspace_panel)
3682 .child(
3683 div()
3684 .id("oxide_forge_prompt_row")
3685 .flex()
3686 .flex_row()
3687 .gap_2()
3688 .items_center()
3689 .child(
3690 div()
3691 .id("oxide_forge_prompt_input")
3692 .track_focus(&self.forge_focus)
3693 .focusable()
3694 .flex_1()
3695 .px_3()
3696 .py_2()
3697 .rounded_md()
3698 .bg(gpui::rgb(0x22222c))
3699 .border_1()
3700 .border_color(if prompt_focused {
3701 gpui::rgb(0x7a5ae0)
3702 } else {
3703 gpui::rgb(0x33333f)
3704 })
3705 .text_sm()
3706 .text_color(prompt_color)
3707 .child(format!("{prompt_display}{caret}"))
3708 .on_click(cx.listener(
3709 |this, _: &ClickEvent, window, cx| {
3710 window.focus(&this.forge_focus);
3711 cx.notify();
3712 },
3713 )),
3714 )
3715 .child(
3716 div()
3717 .id("oxide_forge_submit")
3718 .px_3()
3719 .py_2()
3720 .rounded_md()
3721 .bg(if submit_enabled {
3722 gpui::rgb(0x7a5ae0)
3723 } else {
3724 gpui::rgb(0x3a3a44)
3725 })
3726 .text_sm()
3727 .text_color(gpui::rgb(0xffffff))
3728 .cursor_pointer()
3729 .child(submit_label)
3730 .on_click(cx.listener(
3731 |this, _: &ClickEvent, _, cx| {
3732 this.forge_submit();
3733 cx.notify();
3734 },
3735 )),
3736 ),
3737 )
3738 .child(
3739 div()
3740 .mt_1()
3741 .text_xs()
3742 .text_color(gpui::rgb(0x6a6a80))
3743 .child(
3744 "Enter to send · Shift+Enter for newline · Pick a provider above · Settings for API keys",
3745 ),
3746 ),
3747 ),
3748 );
3749 }
3750 }
3751 } else {
3752 let text_input_focus_id = self.tabs[active].text_input_focus;
3753 let caret_blink_on = SystemTime::now()
3754 .duration_since(UNIX_EPOCH)
3755 .map(|d| (d.as_millis() / 530) % 2 == 0)
3756 .unwrap_or(true);
3757
3758 let canvas_area = div()
3759 .id("oxide_canvas_area")
3760 .flex_1()
3761 .flex()
3762 .flex_col()
3763 .min_h_0()
3764 .relative()
3765 .on_mouse_move(cx.listener({
3766 let hyperlinks_hover = hyperlinks_hover.clone();
3767 move |this, event: &gpui::MouseMoveEvent, _, cx| {
3768 let tab = &mut this.tabs[this.active_tab];
3769 let mut input = tab.host_state.input_state.lock().unwrap();
3770 input.mouse_x = f32::from(event.position.x);
3771 input.mouse_y = f32::from(event.position.y);
3772 drop(input);
3773
3774 if this.scroll_dragging {
3775 let viewport_h = tab.host_state.canvas.lock().unwrap().height as f32;
3776 let content_h = *tab.host_state.content_height.lock().unwrap() as f32;
3777 if content_h > viewport_h && viewport_h > 0.0 {
3778 let max_scroll_y = content_h - viewport_h;
3779 let thumb_height =
3780 ((viewport_h / content_h) * viewport_h).max(20.0);
3781 let max_thumb_top = viewport_h - thumb_height;
3782 if max_thumb_top > 0.0 {
3783 let dy = f32::from(event.position.y) - this.scroll_drag_start_y;
3784 let d_scroll = dy * (max_scroll_y / max_thumb_top);
3785 let new_scroll_y = (this.scroll_drag_start_scroll_y + d_scroll)
3786 .clamp(0.0, max_scroll_y);
3787 *tab.host_state.scroll_y.lock().unwrap() = new_scroll_y;
3788 }
3789 }
3790 cx.notify();
3791 }
3792
3793 let (ox, oy) = *tab.host_state.canvas_offset.lock().unwrap();
3794 let lx = f32::from(event.position.x) - ox;
3795 let ly = f32::from(event.position.y) - oy;
3796 let mut hovered = None;
3797 for link in &hyperlinks_hover {
3798 if lx >= link.x
3799 && ly >= link.y
3800 && lx <= link.x + link.w
3801 && ly <= link.y + link.h
3802 {
3803 hovered = Some(link.url.clone());
3804 break;
3805 }
3806 }
3807 tab.hovered_link_url = hovered;
3808 }
3809 }))
3810 .on_any_mouse_down(cx.listener(|this, event: &MouseDownEvent, _, _cx| {
3811 let tab = &mut this.tabs[this.active_tab];
3812 let mut input = tab.host_state.input_state.lock().unwrap();
3813 let b = match event.button {
3814 MouseButton::Left => 0,
3815 MouseButton::Right => 1,
3816 MouseButton::Middle => 2,
3817 _ => return,
3818 };
3819 input.mouse_buttons_down[b] = true;
3820 }))
3821 .on_mouse_up(
3822 MouseButton::Left,
3823 cx.listener(|this, _: &MouseUpEvent, _, _cx| {
3824 this.scroll_dragging = false;
3825 let tab = &mut this.tabs[this.active_tab];
3826 let mut input = tab.host_state.input_state.lock().unwrap();
3827 input.mouse_buttons_down[0] = false;
3828 input.mouse_buttons_clicked[0] = true;
3829 }),
3830 )
3831 .on_mouse_up(
3832 MouseButton::Right,
3833 cx.listener(|this, _: &MouseUpEvent, _, _cx| {
3834 let tab = &mut this.tabs[this.active_tab];
3835 let mut input = tab.host_state.input_state.lock().unwrap();
3836 input.mouse_buttons_down[1] = false;
3837 input.mouse_buttons_clicked[1] = true;
3838 }),
3839 )
3840 .on_mouse_up(
3841 MouseButton::Middle,
3842 cx.listener(|this, _: &MouseUpEvent, _, _cx| {
3843 let tab = &mut this.tabs[this.active_tab];
3844 let mut input = tab.host_state.input_state.lock().unwrap();
3845 input.mouse_buttons_down[2] = false;
3846 input.mouse_buttons_clicked[2] = true;
3847 }),
3848 )
3849 .on_click(cx.listener(move |this, event: &ClickEvent, window, cx| {
3850 if let Some(pos) = event.mouse_position() {
3851 let tab = &mut this.tabs[this.active_tab];
3852 let (ox, oy) = *tab.host_state.canvas_offset.lock().unwrap();
3853 let lx = f32::from(pos.x) - ox;
3854 let ly = f32::from(pos.y) - oy;
3855 if canvas_point_hits_widget(lx, ly, &widget_cmds_overlay) {
3856 return;
3857 }
3858 let links = tab.host_state.hyperlinks.lock().unwrap().clone();
3859 for link in links.iter().rev() {
3860 if lx >= link.x
3861 && ly >= link.y
3862 && lx <= link.x + link.w
3863 && ly <= link.y + link.h
3864 {
3865 tab.navigate_to(link.url.clone(), true, &this.download_manager);
3866 this.show_downloads =
3867 this.download_manager.has_active() || this.show_downloads;
3868 cx.notify();
3869 return;
3870 }
3871 }
3872 tab.text_input_focus = None;
3873 this.canvas_focus.focus(window);
3874 }
3875 }))
3876 .on_scroll_wheel(cx.listener(|this, event: &ScrollWheelEvent, _, cx| {
3877 let tab = &mut this.tabs[this.active_tab];
3878 let mut input = tab.host_state.input_state.lock().unwrap();
3879 let (dx, dy);
3880 match event.delta {
3881 ScrollDelta::Pixels(p) => {
3882 input.scroll_x += f32::from(p.x);
3883 input.scroll_y += f32::from(p.y);
3884 dx = f32::from(p.x);
3885 dy = f32::from(p.y);
3886 }
3887 ScrollDelta::Lines(l) => {
3888 input.scroll_x += l.x * 20.0;
3889 input.scroll_y += l.y * 20.0;
3890 dx = l.x * 20.0;
3891 dy = l.y * 20.0;
3892 }
3893 }
3894 drop(input);
3895
3896 let viewport_w = tab.host_state.canvas.lock().unwrap().width as f32;
3897 let viewport_h = tab.host_state.canvas.lock().unwrap().height as f32;
3898 let content_w = *tab.host_state.content_width.lock().unwrap() as f32;
3899 let content_h = *tab.host_state.content_height.lock().unwrap() as f32;
3900
3901 let max_x = (content_w - viewport_w).max(0.0);
3902 let max_y = (content_h - viewport_h).max(0.0);
3903
3904 let mut sx = tab.host_state.scroll_x.lock().unwrap();
3905 let mut sy = tab.host_state.scroll_y.lock().unwrap();
3906 *sx = (*sx - dx).clamp(0.0, max_x);
3907 *sy = (*sy - dy).clamp(0.0, max_y);
3908 cx.notify();
3909 }))
3910 .on_drop(cx.listener(|this, paths: &gpui::ExternalPaths, _, _cx| {
3911 let tab = &mut this.tabs[this.active_tab];
3912 crate::events::enqueue_drop_files(&tab.host_state.events, paths.paths());
3913 }))
3914 .child({
3915 let cmds = cmds.clone();
3916 let textures = textures.clone();
3917 let canvas_offset = canvas_offset.clone();
3918 let canvas_state_for_dims = self.tabs[active].host_state.canvas.clone();
3919 canvas(
3920 move |bounds, _window, _cx| {
3921 *canvas_offset.lock().unwrap() =
3922 (f32::from(bounds.origin.x), f32::from(bounds.origin.y));
3923 let mut cs = canvas_state_for_dims.lock().unwrap();
3924 cs.width = f32::from(bounds.size.width) as u32;
3925 cs.height = f32::from(bounds.size.height) as u32;
3926 },
3927 move |bounds, (), window, cx| {
3928 if cmds.is_empty() {
3929 let _ = window
3930 .text_system()
3931 .shape_line(
3932 "Oxide Browser".into(),
3933 px(28.0),
3934 &[TextRun {
3935 len: 13,
3936 font: font(".SystemUIFont"),
3937 color: gpui::hsla(0.75, 0.5, 0.7, 1.0),
3938 background_color: None,
3939 underline: None,
3940 strikethrough: None,
3941 }],
3942 None,
3943 )
3944 .paint(
3945 bounds.origin + point(px(24.0), px(24.0)),
3946 px(32.0),
3947 window,
3948 cx,
3949 );
3950 } else {
3951 paint_draw_commands(window, cx, bounds, &cmds, &textures);
3952 }
3953 },
3954 )
3955 .flex_1()
3956 });
3957
3958 let widget_states_snapshot = self.tabs[active]
3959 .host_state
3960 .widget_states
3961 .lock()
3962 .unwrap()
3963 .clone();
3964
3965 let canvas_with_widgets =
3966 widget_commands
3967 .into_iter()
3968 .fold(canvas_area, |el, cmd| match cmd {
3969 WidgetCommand::Button {
3970 id,
3971 x,
3972 y,
3973 w,
3974 h,
3975 label,
3976 } => el.child(
3977 div()
3978 .id(("oxide_btn", id as usize))
3979 .absolute()
3980 .left(px(x))
3981 .top(px(y))
3982 .w(px(w))
3983 .h(px(h))
3984 .flex()
3985 .items_center()
3986 .justify_center()
3987 .rounded_md()
3988 .bg(gpui::rgb(0x3a3a48))
3989 .cursor_pointer()
3990 .text_sm()
3991 .text_color(gpui::rgb(0xe8e8f0))
3992 .child(label)
3993 .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
3994 this.tabs[this.active_tab]
3995 .host_state
3996 .widget_clicked
3997 .lock()
3998 .unwrap()
3999 .insert(id);
4000 cx.notify();
4001 })),
4002 ),
4003 WidgetCommand::Checkbox { id, x, y, label } => {
4004 let checked = widget_states_snapshot
4005 .get(&id)
4006 .and_then(|v| match v {
4007 WidgetValue::Bool(b) => Some(*b),
4008 _ => None,
4009 })
4010 .unwrap_or(false);
4011 el.child(
4012 div()
4013 .id(("oxide_cb", id as usize))
4014 .absolute()
4015 .left(px(x))
4016 .top(px(y))
4017 .w(px(220.0))
4018 .h(px(30.0))
4019 .flex()
4020 .flex_row()
4021 .items_center()
4022 .gap_2()
4023 .cursor_pointer()
4024 .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
4025 let mut states = this.tabs[this.active_tab]
4026 .host_state
4027 .widget_states
4028 .lock()
4029 .unwrap();
4030 let cur = states
4031 .get(&id)
4032 .and_then(|v| match v {
4033 WidgetValue::Bool(b) => Some(*b),
4034 _ => None,
4035 })
4036 .unwrap_or(false);
4037 states.insert(id, WidgetValue::Bool(!cur));
4038 cx.notify();
4039 }))
4040 .child(
4041 div()
4042 .text_sm()
4043 .text_color(gpui::rgb(0xa0a0aa))
4044 .child(if checked { "☑" } else { "☐" }),
4045 )
4046 .child(
4047 div()
4048 .text_sm()
4049 .text_color(gpui::rgb(0xd0d0dc))
4050 .child(label),
4051 ),
4052 )
4053 }
4054 WidgetCommand::Slider {
4055 id,
4056 x,
4057 y,
4058 w,
4059 min,
4060 max,
4061 } => {
4062 let cur = widget_states_snapshot
4063 .get(&id)
4064 .and_then(|v| match v {
4065 WidgetValue::Float(f) => Some(*f),
4066 _ => None,
4067 })
4068 .unwrap_or(min);
4069 let frac = if max > min {
4070 ((cur - min) / (max - min)).clamp(0.0, 1.0)
4071 } else {
4072 0.0
4073 };
4074 let handle_size: f32 = 14.0;
4075 let handle_left =
4076 (frac * w - handle_size / 2.0).clamp(0.0, w - handle_size);
4077 el.child(
4078 div()
4079 .id(("oxide_sl", id as usize))
4080 .absolute()
4081 .left(px(x))
4082 .top(px(y))
4083 .w(px(w))
4084 .h(px(28.0))
4085 .flex()
4086 .items_center()
4087 .justify_end()
4088 .pr_2()
4089 .rounded_md()
4090 .bg(gpui::rgb(0x2a2a32))
4091 .on_click(cx.listener(
4092 move |this, event: &ClickEvent, _, cx| {
4093 if let Some(pos) = event.mouse_position() {
4094 let tab = &mut this.tabs[this.active_tab];
4095 let (ox, _) =
4096 *tab.host_state.canvas_offset.lock().unwrap();
4097 let lx = f32::from(pos.x) - ox;
4098 let frac = ((lx - x) / w).clamp(0.0, 1.0);
4099 let v = min + frac * (max - min);
4100 tab.host_state
4101 .widget_states
4102 .lock()
4103 .unwrap()
4104 .insert(id, WidgetValue::Float(v));
4105 cx.notify();
4106 }
4107 },
4108 ))
4109 .child(
4110 div()
4111 .absolute()
4112 .left(px(0.0))
4113 .top(px(12.0))
4114 .w(px(frac * w))
4115 .h(px(4.0))
4116 .rounded_sm()
4117 .bg(gpui::rgb(0x5a5a8a)),
4118 )
4119 .child(
4120 div()
4121 .absolute()
4122 .left(px(handle_left))
4123 .top(px((28.0 - handle_size) / 2.0))
4124 .w(px(handle_size))
4125 .h(px(handle_size))
4126 .rounded_full()
4127 .bg(gpui::rgb(0xe4e4ec)),
4128 )
4129 .child(
4130 div()
4131 .text_xs()
4132 .text_color(gpui::rgb(0xb0b0c0))
4133 .child(format!("{cur:.1}")),
4134 ),
4135 )
4136 }
4137 WidgetCommand::TextInput { id, x, y, w } => {
4138 let value = widget_states_snapshot
4139 .get(&id)
4140 .and_then(|v| match v {
4141 WidgetValue::Text(t) => Some(t.clone()),
4142 _ => None,
4143 })
4144 .unwrap_or_default();
4145 let show_caret = text_input_focus_id == Some(id) && caret_blink_on;
4146 el.child(
4147 div()
4148 .id(("oxide_ti", id as usize))
4149 .absolute()
4150 .left(px(x))
4151 .top(px(y))
4152 .w(px(w))
4153 .h(px(28.0))
4154 .px_2()
4155 .rounded_md()
4156 .bg(gpui::rgb(0x121218))
4157 .border_1()
4158 .border_color(if text_input_focus_id == Some(id) {
4159 gpui::rgb(0x6a6a8a)
4160 } else {
4161 gpui::rgb(0x3a3a48)
4162 })
4163 .cursor_pointer()
4164 .flex()
4165 .flex_row()
4166 .items_center()
4167 .justify_start()
4168 .gap_1()
4169 .min_w_0()
4170 .child(
4171 div()
4172 .flex_initial()
4173 .min_w_0()
4174 .overflow_hidden()
4175 .text_sm()
4176 .text_color(gpui::rgb(0xe4e4ec))
4177 .child(SharedString::from(value)),
4178 )
4179 .when(show_caret, |d| {
4180 d.child(
4181 div()
4182 .flex_shrink_0()
4183 .w(px(2.0))
4184 .h(px(16.0))
4185 .mt(px(1.0))
4186 .rounded_sm()
4187 .bg(gpui::rgb(0xe8e8f0)),
4188 )
4189 })
4190 .on_click(cx.listener(
4191 move |this, _: &ClickEvent, window, cx| {
4192 this.tabs[this.active_tab].text_input_focus = Some(id);
4193 this.canvas_focus.focus(window);
4194 cx.notify();
4195 },
4196 )),
4197 )
4198 }
4199 });
4200
4201 let viewport_h = {
4202 let canvas_state = self.tabs[active].host_state.canvas.lock().unwrap();
4203 canvas_state.height as f32
4204 };
4205 let content_h = *self.tabs[active].host_state.content_height.lock().unwrap() as f32;
4206 let scroll_y = *self.tabs[active].host_state.scroll_y.lock().unwrap();
4207
4208 let canvas_with_widgets = if content_h > viewport_h && viewport_h > 0.0 {
4209 let max_scroll_y = content_h - viewport_h;
4210 let thumb_height = ((viewport_h / content_h) * viewport_h).max(20.0);
4211 let max_thumb_top = viewport_h - thumb_height;
4212 let thumb_top = (scroll_y / max_scroll_y) * max_thumb_top;
4213
4214 let thumb = div()
4215 .id("oxide_scrollbar_thumb")
4216 .absolute()
4217 .top(px(thumb_top))
4218 .left(px(0.0))
4219 .w(px(8.0))
4220 .h(px(thumb_height))
4221 .rounded_full()
4222 .bg(rgba8(0xff, 0xff, 0xff, 0x44))
4223 .hover(|style| style.bg(rgba8(0xff, 0xff, 0xff, 0x66)))
4224 .active(|style| style.bg(rgba8(0xff, 0xff, 0xff, 0x88)))
4225 .on_mouse_down(
4226 MouseButton::Left,
4227 cx.listener(move |this, event: &MouseDownEvent, _, cx| {
4228 this.scroll_dragging = true;
4229 this.scroll_drag_start_y = f32::from(event.position.y);
4230 this.scroll_drag_start_scroll_y = *this.tabs[this.active_tab]
4231 .host_state
4232 .scroll_y
4233 .lock()
4234 .unwrap();
4235 cx.notify();
4236 }),
4237 );
4238
4239 let track = div()
4240 .id("oxide_scrollbar_track")
4241 .absolute()
4242 .right(px(2.0))
4243 .top(px(0.0))
4244 .bottom(px(0.0))
4245 .w(px(8.0))
4246 .rounded_full()
4247 .bg(rgba8(0x00, 0x00, 0x00, 0x11))
4248 .child(thumb);
4249
4250 canvas_with_widgets.child(track)
4251 } else {
4252 canvas_with_widgets
4253 };
4254
4255 content_col = content_col.child(canvas_with_widgets);
4256 }
4257
4258 if let Some(tex) = pip_tex {
4259 content_col = content_col.child(
4260 div()
4261 .id("oxide_pip")
4262 .absolute()
4263 .bottom(px(16.0))
4264 .right(px(16.0))
4265 .w(px(320.0))
4266 .h(px(200.0))
4267 .rounded_md()
4268 .overflow_hidden()
4269 .border_1()
4270 .border_color(gpui::rgb(0x2a2a32))
4271 .child(img(ImageSource::from(tex)).object_fit(gpui::ObjectFit::Contain)),
4272 );
4273 }
4274
4275 if show_console {
4276 let entries = self.tabs[active].host_state.console.lock().unwrap().clone();
4277 content_col = content_col.child(
4278 div()
4279 .id("oxide_console")
4280 .h(px(160.0))
4281 .border_t_1()
4282 .border_color(gpui::rgb(0x2a2a32))
4283 .flex()
4284 .flex_col()
4285 .child(
4286 div()
4287 .flex()
4288 .flex_row()
4289 .items_center()
4290 .justify_between()
4291 .h(px(28.0))
4292 .px_2()
4293 .border_b_1()
4294 .border_color(gpui::rgb(0x2a2a32))
4295 .child(
4296 div()
4297 .text_xs()
4298 .font_weight(gpui::FontWeight::SEMIBOLD)
4299 .text_color(gpui::rgb(0x9696a0))
4300 .child("Console"),
4301 )
4302 .child(
4303 div()
4304 .id("oxide_console_close")
4305 .cursor_pointer()
4306 .w(px(20.0))
4307 .h(px(20.0))
4308 .flex()
4309 .items_center()
4310 .justify_center()
4311 .rounded_sm()
4312 .hover(|s| s.bg(gpui::rgb(0x3a3a48)))
4313 .text_xs()
4314 .text_color(gpui::rgb(0x9696a0))
4315 .child("✕")
4316 .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
4317 this.tabs[this.active_tab].show_console = false;
4318 cx.notify();
4319 })),
4320 ),
4321 )
4322 .child(
4323 div()
4324 .id("oxide_console_entries")
4325 .flex_1()
4326 .overflow_scroll()
4327 .p_2()
4328 .font_family("Monaco")
4329 .text_xs()
4330 .children(entries.into_iter().map(|e| {
4331 let color = match e.level {
4332 ConsoleLevel::Log => gpui::rgb(0xc8c8c8),
4333 ConsoleLevel::Warn => gpui::rgb(0xf0c83c),
4334 ConsoleLevel::Error => gpui::rgb(0xf05050),
4335 };
4336 div()
4337 .flex()
4338 .flex_row()
4339 .gap_2()
4340 .child(
4341 div()
4342 .text_color(gpui::rgb(0x646464))
4343 .child(e.timestamp.clone()),
4344 )
4345 .child(div().text_color(color).child(e.message.clone()))
4346 })),
4347 ),
4348 );
4349 }
4350
4351 main_row = main_row.child(content_col);
4352
4353 root = root.child(main_row);
4354
4355 // Downloads panel
4356 {
4357 let downloads = self.download_manager.downloads();
4358 let list = downloads.lock().unwrap().clone();
4359 if self.show_downloads && !list.is_empty() {
4360 let panel_height = (list.len() as f32 * 56.0 + 32.0).min(240.0);
4361 root = root.child(
4362 div()
4363 .id("oxide_downloads_panel")
4364 .h(px(panel_height))
4365 .border_t_1()
4366 .border_color(gpui::rgb(0x2a2a32))
4367 .flex()
4368 .flex_col()
4369 .child(
4370 div()
4371 .flex()
4372 .flex_row()
4373 .items_center()
4374 .justify_between()
4375 .h(px(28.0))
4376 .px_2()
4377 .border_b_1()
4378 .border_color(gpui::rgb(0x2a2a32))
4379 .child(
4380 div()
4381 .text_xs()
4382 .font_weight(gpui::FontWeight::SEMIBOLD)
4383 .text_color(gpui::rgb(0x9696a0))
4384 .child("Downloads"),
4385 )
4386 .child(
4387 div()
4388 .id("oxide_downloads_close")
4389 .cursor_pointer()
4390 .w(px(20.0))
4391 .h(px(20.0))
4392 .flex()
4393 .items_center()
4394 .justify_center()
4395 .rounded_sm()
4396 .hover(|s| s.bg(gpui::rgb(0x3a3a48)))
4397 .text_xs()
4398 .text_color(gpui::rgb(0x9696a0))
4399 .child("✕")
4400 .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
4401 this.show_downloads = false;
4402 cx.notify();
4403 })),
4404 ),
4405 )
4406 .child(
4407 div()
4408 .id("oxide_downloads_list")
4409 .flex_1()
4410 .overflow_y_scroll()
4411 .children(list.iter().enumerate().map(|(idx, dl)| {
4412 let dl_id = dl.id;
4413 let filename = SharedString::from(dl.filename.clone());
4414 let (status_text, status_color) = match &dl.state {
4415 DownloadState::InProgress => {
4416 let downloaded = format_bytes(dl.bytes_downloaded);
4417 let total = dl
4418 .total_bytes
4419 .map(format_bytes)
4420 .unwrap_or_else(|| "?".to_string());
4421 let speed = format_bytes(dl.speed_bytes_per_sec as u64);
4422 let pct = dl
4423 .percent()
4424 .map(|p| format!("{p:.0}%"))
4425 .unwrap_or_default();
4426 (
4427 format!("{downloaded} / {total} {speed}/s {pct}"),
4428 gpui::rgb(0x50b0e0),
4429 )
4430 }
4431 DownloadState::Completed => {
4432 let total = format_bytes(dl.bytes_downloaded);
4433 (format!("Complete — {total}"), gpui::rgb(0x50e070))
4434 }
4435 DownloadState::Failed(msg) => {
4436 (format!("Failed: {msg}"), gpui::rgb(0xf05050))
4437 }
4438 DownloadState::Cancelled => {
4439 ("Cancelled".to_string(), gpui::rgb(0x9696a0))
4440 }
4441 };
4442
4443 let progress_fraction = match &dl.state {
4444 DownloadState::InProgress => {
4445 dl.percent().map(|p| (p / 100.0) as f32).unwrap_or(0.0)
4446 }
4447 DownloadState::Completed => 1.0,
4448 _ => 0.0,
4449 };
4450
4451 let is_active = dl.state == DownloadState::InProgress;
4452
4453 div()
4454 .id(("oxide_dl", idx))
4455 .flex()
4456 .flex_row()
4457 .items_center()
4458 .gap_2()
4459 .px_2()
4460 .py_1()
4461 .border_b_1()
4462 .border_color(gpui::rgb(0x24242c))
4463 .child(
4464 div()
4465 .flex_1()
4466 .min_w_0()
4467 .flex()
4468 .flex_col()
4469 .gap(px(2.0))
4470 .child(
4471 div()
4472 .text_sm()
4473 .text_color(gpui::rgb(0xe4e4ec))
4474 .overflow_hidden()
4475 .child(filename),
4476 )
4477 .child(
4478 div()
4479 .text_xs()
4480 .text_color(status_color)
4481 .child(SharedString::from(status_text)),
4482 )
4483 .when(is_active, |d| {
4484 d.child(
4485 div()
4486 .h(px(4.0))
4487 .w_full()
4488 .rounded_sm()
4489 .bg(gpui::rgb(0x2a2a32))
4490 .child(
4491 div()
4492 .h_full()
4493 .rounded_sm()
4494 .bg(gpui::rgb(0x50b0e0))
4495 .w(gpui::relative(
4496 progress_fraction,
4497 )),
4498 ),
4499 )
4500 }),
4501 )
4502 .child(if is_active {
4503 div()
4504 .id(("oxide_dl_cancel", idx))
4505 .cursor_pointer()
4506 .flex_shrink_0()
4507 .px_2()
4508 .py(px(4.0))
4509 .rounded_sm()
4510 .text_xs()
4511 .text_color(gpui::rgb(0xf05050))
4512 .hover(|s| s.bg(gpui::rgb(0x3a3a48)))
4513 .child("Cancel")
4514 .on_click(cx.listener(
4515 move |this, _: &ClickEvent, _, cx| {
4516 this.download_manager.cancel(dl_id);
4517 cx.notify();
4518 },
4519 ))
4520 } else {
4521 div()
4522 .id(("oxide_dl_dismiss", idx))
4523 .cursor_pointer()
4524 .flex_shrink_0()
4525 .px_2()
4526 .py(px(4.0))
4527 .rounded_sm()
4528 .text_xs()
4529 .text_color(gpui::rgb(0x9696a0))
4530 .hover(|s| s.bg(gpui::rgb(0x3a3a48)))
4531 .child("Dismiss")
4532 .on_click(cx.listener(
4533 move |this, _: &ClickEvent, _, cx| {
4534 this.download_manager.dismiss(dl_id);
4535 cx.notify();
4536 },
4537 ))
4538 })
4539 })),
4540 ),
4541 );
4542 }
4543 }
4544
4545 if let Some(url) = self.tabs[active].hovered_link_url.clone() {
4546 root = root.child(
4547 div()
4548 .id("oxide_link_status")
4549 .h(px(18.0))
4550 .border_t_1()
4551 .border_color(gpui::rgb(0x2a2a32))
4552 .px_2()
4553 .font_family("Monaco")
4554 .text_xs()
4555 .text_color(gpui::rgb(0x8c8cb4))
4556 .child(url),
4557 );
4558 }
4559
4560 if self.show_menu {
4561 root = root.child(
4562 div()
4563 .id("oxide_menu_scrim")
4564 .absolute()
4565 .size_full()
4566 .top_0()
4567 .left_0()
4568 .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
4569 this.show_menu = false;
4570 cx.notify();
4571 })),
4572 );
4573 root = root.child(
4574 div()
4575 .id("oxide_menu_dropdown")
4576 .absolute()
4577 .top(px(88.0))
4578 .right(px(8.0))
4579 .w(px(180.0))
4580 .rounded_md()
4581 .bg(gpui::rgb(0x2c2c36))
4582 .border_1()
4583 .border_color(gpui::rgb(0x3a3a44))
4584 .py_1()
4585 .shadow_lg()
4586 .child(
4587 div()
4588 .id("oxide_menu_new_tab")
4589 .px_3()
4590 .py(px(8.0))
4591 .cursor_pointer()
4592 .text_sm()
4593 .text_color(gpui::rgb(0xdcdce6))
4594 .hover(|s| s.bg(gpui::rgb(0x3a3a48)))
4595 .rounded_sm()
4596 .child(" New Tab")
4597 .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
4598 let i = this.create_tab();
4599 this.active_tab = i;
4600 this.show_menu = false;
4601 cx.notify();
4602 })),
4603 )
4604 .child(div().h(px(1.0)).mx_2().my_1().bg(gpui::rgb(0x3a3a44)))
4605 .child(
4606 div()
4607 .id("oxide_menu_bookmarks")
4608 .px_3()
4609 .py(px(8.0))
4610 .cursor_pointer()
4611 .text_sm()
4612 .text_color(gpui::rgb(0xdcdce6))
4613 .hover(|s| s.bg(gpui::rgb(0x3a3a48)))
4614 .rounded_sm()
4615 .child(if self.show_bookmarks {
4616 "✓ Bookmarks"
4617 } else {
4618 " Bookmarks"
4619 })
4620 .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
4621 this.show_bookmarks = !this.show_bookmarks;
4622 this.show_menu = false;
4623 cx.notify();
4624 })),
4625 )
4626 .child(
4627 div()
4628 .id("oxide_menu_console")
4629 .px_3()
4630 .py(px(8.0))
4631 .cursor_pointer()
4632 .text_sm()
4633 .text_color(gpui::rgb(0xdcdce6))
4634 .hover(|s| s.bg(gpui::rgb(0x3a3a48)))
4635 .rounded_sm()
4636 .child(if show_console {
4637 "✓ Console"
4638 } else {
4639 " Console"
4640 })
4641 .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
4642 this.tabs[this.active_tab].show_console =
4643 !this.tabs[this.active_tab].show_console;
4644 this.show_menu = false;
4645 cx.notify();
4646 })),
4647 )
4648 .child(
4649 div()
4650 .id("oxide_menu_downloads")
4651 .px_3()
4652 .py(px(8.0))
4653 .cursor_pointer()
4654 .text_sm()
4655 .text_color(gpui::rgb(0xdcdce6))
4656 .hover(|s| s.bg(gpui::rgb(0x3a3a48)))
4657 .rounded_sm()
4658 .child(if self.show_downloads {
4659 "✓ Downloads"
4660 } else {
4661 " Downloads"
4662 })
4663 .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
4664 this.show_downloads = !this.show_downloads;
4665 this.show_menu = false;
4666 cx.notify();
4667 })),
4668 )
4669 .child(
4670 div()
4671 .id("oxide_menu_history")
4672 .px_3()
4673 .py(px(8.0))
4674 .cursor_pointer()
4675 .text_sm()
4676 .text_color(gpui::rgb(0xdcdce6))
4677 .hover(|s| s.bg(gpui::rgb(0x3a3a48)))
4678 .rounded_sm()
4679 .child(" History")
4680 .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
4681 let i = this.create_tab();
4682 this.active_tab = i;
4683 this.tabs[i].navigate_to(
4684 "oxide://history".to_string(),
4685 true,
4686 &this.download_manager,
4687 );
4688 this.show_menu = false;
4689 cx.notify();
4690 })),
4691 )
4692 .child(div().h(px(1.0)).mx_2().my_1().bg(gpui::rgb(0x3a3a44)))
4693 .child(
4694 div()
4695 .id("oxide_menu_about")
4696 .px_3()
4697 .py(px(8.0))
4698 .cursor_pointer()
4699 .text_sm()
4700 .text_color(gpui::rgb(0xdcdce6))
4701 .hover(|s| s.bg(gpui::rgb(0x3a3a48)))
4702 .rounded_sm()
4703 .child(" About Oxide")
4704 .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
4705 let i = this.create_tab();
4706 this.active_tab = i;
4707 this.tabs[i].navigate_to(
4708 "oxide://about".to_string(),
4709 true,
4710 &this.download_manager,
4711 );
4712 this.show_menu = false;
4713 cx.notify();
4714 })),
4715 ),
4716 );
4717 }
4718
4719 root
4720 }
4721}
4722
4723/// Render one chat bubble in the Forge conversation panel.
4724fn forge_chat_bubble(index: usize, msg: &ForgeChatMessage, phase: ForgePhase) -> impl IntoElement {
4725 let is_user = msg.role == ForgeMessageRole::User;
4726 let preview = if msg.content.len() > 4000 {
4727 format!("{}…", &msg.content[..4000])
4728 } else {
4729 msg.content.clone()
4730 };
4731 let streaming_tail = if !is_user && phase == ForgePhase::Streaming && msg.content.is_empty() {
4732 "▍"
4733 } else {
4734 ""
4735 };
4736 let (bg, fg, label) = if is_user {
4737 (gpui::rgb(0x3a2f5a), gpui::rgb(0xf0ecff), "You")
4738 } else {
4739 (gpui::rgb(0x242430), gpui::rgb(0xd8d8e8), "Forge")
4740 };
4741 div()
4742 .id(("oxide_forge_msg", index))
4743 .w_full()
4744 .flex()
4745 .flex_col()
4746 .items_start()
4747 .child(div().text_xs().text_color(gpui::rgb(0x7a7a90)).child(label))
4748 .child(
4749 div()
4750 .mt_1()
4751 .max_w(px(520.0))
4752 .px_3()
4753 .py_2()
4754 .rounded_md()
4755 .bg(bg)
4756 .text_sm()
4757 .text_color(fg)
4758 .font(if is_user {
4759 font("Inter")
4760 } else {
4761 font("Menlo")
4762 })
4763 .child(format!("{preview}{streaming_tail}")),
4764 )
4765}
4766
4767/// Human-readable label for a [`ForgePhase`].
4768fn phase_label(p: ForgePhase) -> &'static str {
4769 match p {
4770 ForgePhase::Idle => "idle",
4771 ForgePhase::Streaming => "streaming",
4772 ForgePhase::StreamComplete => "ready to build",
4773 ForgePhase::Building => "building",
4774 ForgePhase::BuildOk => "build ok",
4775 ForgePhase::Error => "error",
4776 }
4777}
4778
4779/// Themed color for a [`ForgePhase`] badge.
4780fn phase_color_for(p: ForgePhase) -> Rgba {
4781 match p {
4782 ForgePhase::Idle => gpui::rgb(0x8888a0),
4783 ForgePhase::Streaming => gpui::rgb(0xffc060),
4784 ForgePhase::StreamComplete => gpui::rgb(0x70b0ff),
4785 ForgePhase::Building => gpui::rgb(0xffa040),
4786 ForgePhase::BuildOk => gpui::rgb(0x80d090),
4787 ForgePhase::Error => gpui::rgb(0xf06070),
4788 }
4789}
4790
4791/// Guest widget bounds in canvas-local coordinates (must match overlay hit-test skip logic).
4792fn widget_bounds(cmd: &WidgetCommand) -> (f32, f32, f32, f32) {
4793 match cmd {
4794 WidgetCommand::Button { x, y, w, h, .. } => (*x, *y, *w, *h),
4795 WidgetCommand::Checkbox { x, y, .. } => (*x, *y, 220.0, 30.0),
4796 WidgetCommand::Slider { x, y, w, .. } => (*x, *y, *w, 28.0),
4797 WidgetCommand::TextInput { x, y, w, .. } => (*x, *y, *w, 28.0),
4798 }
4799}
4800
4801/// True if `(lx, ly)` lies inside any guest widget rect (canvas space).
4802fn canvas_point_hits_widget(lx: f32, ly: f32, cmds: &[WidgetCommand]) -> bool {
4803 for cmd in cmds {
4804 let (x, y, w, h) = widget_bounds(cmd);
4805 if lx >= x && ly >= y && lx <= x + w && ly <= y + h {
4806 return true;
4807 }
4808 }
4809 false
4810}
4811
4812fn truncate_tab_title(title: &str) -> String {
4813 let max_len = 30;
4814 if title.chars().count() > max_len {
4815 let t: String = title.chars().take(max_len).collect();
4816 format!("{t}\u{2026}")
4817 } else {
4818 title.to_string()
4819 }
4820}
4821
4822/// Typed character for URL bar and guest [`WidgetCommand::TextInput`] fields.
4823/// Uses `key_char` when set; otherwise mirrors [`Keystroke::with_simulated_ime`] for plain typing.
4824fn text_insert_from_keystroke(ks: &Keystroke) -> Option<String> {
4825 if ks.modifiers.control || ks.modifiers.platform || ks.modifiers.function || ks.modifiers.alt {
4826 return None;
4827 }
4828 if let Some(ref c) = ks.key_char {
4829 return Some(c.clone());
4830 }
4831 ks.clone().with_simulated_ime().key_char
4832}
4833
4834fn keystroke_to_oxide(k: &Keystroke) -> Option<u32> {
4835 let key = k.key.as_str();
4836 match key {
4837 "a" => Some(0),
4838 "b" => Some(1),
4839 "c" => Some(2),
4840 "d" => Some(3),
4841 "e" => Some(4),
4842 "f" => Some(5),
4843 "g" => Some(6),
4844 "h" => Some(7),
4845 "i" => Some(8),
4846 "j" => Some(9),
4847 "k" => Some(10),
4848 "l" => Some(11),
4849 "m" => Some(12),
4850 "n" => Some(13),
4851 "o" => Some(14),
4852 "p" => Some(15),
4853 "q" => Some(16),
4854 "r" => Some(17),
4855 "s" => Some(18),
4856 "t" => Some(19),
4857 "u" => Some(20),
4858 "v" => Some(21),
4859 "w" => Some(22),
4860 "x" => Some(23),
4861 "y" => Some(24),
4862 "z" => Some(25),
4863 "0" => Some(26),
4864 "1" => Some(27),
4865 "2" => Some(28),
4866 "3" => Some(29),
4867 "4" => Some(30),
4868 "5" => Some(31),
4869 "6" => Some(32),
4870 "7" => Some(33),
4871 "8" => Some(34),
4872 "9" => Some(35),
4873 "enter" => Some(36),
4874 "escape" => Some(37),
4875 "tab" => Some(38),
4876 "backspace" => Some(39),
4877 "delete" => Some(40),
4878 "space" => Some(41),
4879 "up" => Some(42),
4880 "down" => Some(43),
4881 "left" => Some(44),
4882 "right" => Some(45),
4883 "home" => Some(46),
4884 "end" => Some(47),
4885 "pageup" => Some(48),
4886 "pagedown" => Some(49),
4887 _ => None,
4888 }
4889}
4890
4891/// Returns `true` when `url` clearly points to a downloadable file rather
4892/// than a WASM module. Heuristic: the URL path has a file extension and that
4893/// extension is *not* `.wasm`. Bare directories and extensionless paths are
4894/// assumed to be WASM endpoints (they get `/index.wasm` appended by the
4895/// runtime).
4896fn is_downloadable_url(url: &str) -> bool {
4897 let trimmed = url.trim();
4898 if trimmed.is_empty() {
4899 return false;
4900 }
4901 if let Ok(parsed) = url::Url::parse(trimmed) {
4902 if !matches!(parsed.scheme(), "http" | "https") {
4903 return false;
4904 }
4905 let path = parsed.path();
4906 if path.ends_with('/') || path == "/" || path.is_empty() {
4907 return false;
4908 }
4909 if let Some(last_segment) = path.rsplit('/').next() {
4910 if let Some(dot) = last_segment.rfind('.') {
4911 let ext = &last_segment[dot + 1..];
4912 return !ext.eq_ignore_ascii_case("wasm");
4913 }
4914 }
4915 }
4916 false
4917}
4918
4919fn url_to_title(url: &str) -> String {
4920 if url == "(local)" {
4921 return "Local Module".to_string();
4922 }
4923 match url {
4924 "oxide://home" => return "Home".to_string(),
4925 "oxide://history" => return "History".to_string(),
4926 "oxide://bookmarks" => return "Bookmarks".to_string(),
4927 "oxide://about" => return "About Oxide".to_string(),
4928 "oxide://forge" => return "Forge".to_string(),
4929 _ => {}
4930 }
4931 if let Some(stripped) = url
4932 .strip_prefix("https://")
4933 .or_else(|| url.strip_prefix("http://"))
4934 {
4935 stripped.split('/').next().unwrap_or(stripped).to_string()
4936 } else if let Some(stripped) = url.strip_prefix("file://") {
4937 stripped
4938 .rsplit('/')
4939 .next()
4940 .unwrap_or("Local File")
4941 .to_string()
4942 } else {
4943 let max = 20;
4944 if url.chars().count() > max {
4945 let truncated: String = url.chars().take(max).collect();
4946 format!("{truncated}\u{2026}")
4947 } else {
4948 url.to_string()
4949 }
4950 }
4951}
4952
4953/// Start the Oxide desktop shell: GPUI event loop and one main window.
4954///
4955/// Call this after constructing a [`crate::runtime::BrowserHost`] and cloning its [`HostState`]
4956/// and status mutex,
4957/// as the `oxide` binary does.
4958/// This function does not return until the application exits.
4959pub fn run_browser(host_state: HostState, status: Arc<Mutex<PageStatus>>) -> anyhow::Result<()> {
4960 Application::new().run(move |cx: &mut gpui::App| {
4961 cx.on_window_closed(|cx| {
4962 if cx.windows().is_empty() {
4963 cx.quit();
4964 }
4965 })
4966 .detach();
4967
4968 let opts = WindowOptions {
4969 window_bounds: Some(WindowBounds::centered(size(px(1024.0), px(720.0)), cx)),
4970 titlebar: Some(TitlebarOptions {
4971 title: Some("Oxide Browser".into()),
4972 ..Default::default()
4973 }),
4974 window_min_size: Some(size(px(600.0), px(400.0))),
4975 kind: WindowKind::Normal,
4976 ..Default::default()
4977 };
4978 cx.open_window(opts, move |_, cx| {
4979 cx.new(|cx| OxideBrowserView::new(cx, host_state.clone(), status.clone()))
4980 })
4981 .expect("open window");
4982 });
4983 Ok(())
4984}