Skip to main content

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, WidgetVariant,
34};
35
36/// shadcn-inspired neutral dark palette used across the desktop shell and guest widgets.
37///
38/// Mirrors the default zinc-based dark theme: subtle borders, low-contrast surfaces,
39/// high-contrast foreground text. Kept as raw RGB so the values match GPUI helpers exactly.
40#[allow(dead_code)]
41mod theme {
42    use gpui::Rgba;
43
44    pub const BG: u32 = 0x0a0a0b; // background — page chrome
45    pub const SURFACE: u32 = 0x18181b; // raised panel
46    pub const SURFACE_HOVER: u32 = 0x27272a; // hover state on surfaces
47    pub const MUTED: u32 = 0x27272a; // muted control fill (input, switch off)
48    pub const BORDER: u32 = 0x27272a; // 1px outlines
49    pub const BORDER_STRONG: u32 = 0x3f3f46; // emphasised outlines
50    pub const RING: u32 = 0xd4d4d8; // focus ring
51    pub const PRIMARY: u32 = 0xfafafa; // primary fill (button default)
52    pub const PRIMARY_FG: u32 = 0x18181b; // text on primary fill
53    pub const FG: u32 = 0xfafafa; // body text
54    pub const FG_MUTED: u32 = 0xa1a1aa; // secondary text
55    pub const FG_DIM: u32 = 0x71717a; // disabled / placeholder text
56    pub const ACCENT: u32 = 0x60a5fa; // info / link
57    pub const DESTRUCTIVE: u32 = 0x7f1d1d; // destructive button
58    pub const SUCCESS: u32 = 0x16a34a; // success badge
59
60    /// Translucent selection highlight (matches a thin ring on dark backgrounds).
61    pub fn selection() -> Rgba {
62        gpui::rgba(0x60a5fa55)
63    }
64}
65use crate::download::{format_bytes, DownloadManager, DownloadState};
66use crate::engine::ModuleLoader;
67use crate::forge::{
68    ForgeChatMessage, ForgeCreationSummary, ForgeMessageRole, ForgePhase, ForgeSnapshot, ForgeState,
69};
70use crate::forge_config::{mask_api_key, ForgeProvider, ForgeUserConfig};
71use crate::history::HistoryStore;
72use crate::navigation::HistoryEntry;
73use crate::runtime::{LiveModule, PageStatus};
74
75enum RunRequest {
76    FetchAndRun {
77        url: String,
78    },
79    LoadLocal {
80        bytes: Vec<u8>,
81        /// Source URL the module's origin/storage/permissions are scoped to.
82        url: String,
83        manifest: Option<crate::manifest::AppManifest>,
84    },
85}
86
87struct RunResult {
88    error: Option<String>,
89    live_module: Option<LiveModule>,
90}
91
92// SAFETY: `LiveModule` contains a wasmtime `Store<HostState>` whose fields are
93// behind `Arc<Mutex<…>>`, making them safe to send across threads. The `error`
94// field is a plain `Option<String>`.
95unsafe impl Send for RunResult {}
96
97#[derive(Clone, PartialEq)]
98enum InternalPage {
99    Home,
100    History,
101    Bookmarks,
102    About,
103    Forge,
104}
105
106fn try_internal_page(url: &str) -> Option<InternalPage> {
107    match url {
108        "oxide://home" => Some(InternalPage::Home),
109        "oxide://history" => Some(InternalPage::History),
110        "oxide://bookmarks" => Some(InternalPage::Bookmarks),
111        "oxide://about" => Some(InternalPage::About),
112        "oxide://forge" => Some(InternalPage::Forge),
113        _ => None,
114    }
115}
116
117fn format_friendly_timestamp(timestamp_ms: u64) -> String {
118    let now_ms = SystemTime::now()
119        .duration_since(UNIX_EPOCH)
120        .unwrap_or_default()
121        .as_millis() as u64;
122    let diff_secs = now_ms.saturating_sub(timestamp_ms) / 1000;
123    if diff_secs < 60 {
124        "Just now".to_string()
125    } else if diff_secs < 3600 {
126        let m = diff_secs / 60;
127        if m == 1 {
128            "1 minute ago".to_string()
129        } else {
130            format!("{m} minutes ago")
131        }
132    } else if diff_secs < 86400 {
133        let h = diff_secs / 3600;
134        if h == 1 {
135            "1 hour ago".to_string()
136        } else {
137            format!("{h} hours ago")
138        }
139    } else {
140        let d = diff_secs / 86400;
141        if d == 1 {
142            "Yesterday".to_string()
143        } else {
144            format!("{d} days ago")
145        }
146    }
147}
148
149/// Caret + selection + scroll offsets for an editable widget, keyed off the widget id.
150///
151/// Persisted on [`TabState`] across frames so guests don't have to thread cursor state
152/// through their own UI loop — the host owns the editing model.
153#[derive(Clone, Debug, Default)]
154struct WidgetEditState {
155    /// Caret byte offset within the widget text.
156    cursor: usize,
157    /// Selection anchor; equal to `cursor` when there is no selection.
158    sel_start: usize,
159    /// True while the user is drag-selecting with the mouse.
160    selecting: bool,
161    /// Vertical scroll offset for the textarea, in pixels.
162    scroll_y: f32,
163}
164
165impl WidgetEditState {
166    fn move_to(&mut self, offset: usize, max: usize) {
167        let o = offset.min(max);
168        self.cursor = o;
169        self.sel_start = o;
170    }
171
172    fn select_to(&mut self, offset: usize, max: usize) {
173        self.cursor = offset.min(max);
174    }
175}
176
177struct TabState {
178    id: u64,
179    url_input: String,
180    host_state: HostState,
181    status: Arc<Mutex<PageStatus>>,
182    show_console: bool,
183    run_tx: std::sync::mpsc::Sender<RunRequest>,
184    run_rx: Arc<Mutex<std::sync::mpsc::Receiver<RunResult>>>,
185    /// GPU texture cache for decoded canvas images (video frames use the same path).
186    image_textures: HashMap<usize, Arc<RenderImage>>,
187    pip_texture: Option<Arc<RenderImage>>,
188    pip_last_serial: u64,
189    canvas_generation: u64,
190    pending_history_url: Option<String>,
191    hovered_link_url: Option<String>,
192    live_module: Option<LiveModule>,
193    last_frame: Instant,
194    keys_held: HashSet<u32>,
195    /// Guest `TextInput` widget id with keyboard focus, if any.
196    text_input_focus: Option<u32>,
197    /// Cursor/selection state per editable widget id (text input + textarea).
198    widget_edits: HashMap<u32, WidgetEditState>,
199    /// Bounds of editable widget text areas in window coords, for mouse hit-testing.
200    widget_bounds_cache: HashMap<u32, Arc<Mutex<Bounds<Pixels>>>>,
201    /// Cursor byte offset in `url_input`.
202    url_cursor: usize,
203    /// Selection anchor byte offset; when != `url_cursor`, the range between them is selected.
204    url_sel_start: usize,
205    /// True while the mouse button is held to drag-select in the URL bar.
206    url_selecting: bool,
207    /// Bounds of the URL text canvas element, for mouse hit-testing.
208    url_text_bounds: Arc<Mutex<Bounds<Pixels>>>,
209    internal_page: Option<InternalPage>,
210    /// Draft prompt for `oxide://forge`. Cleared when a session is started.
211    forge_prompt: String,
212    /// Forge session id being viewed / generated in this tab, if any.
213    forge_session_id: Option<u64>,
214}
215
216impl TabState {
217    fn new(id: u64, host_state: HostState, status: Arc<Mutex<PageStatus>>) -> Self {
218        let (req_tx, req_rx) = std::sync::mpsc::channel::<RunRequest>();
219        let (res_tx, res_rx) = std::sync::mpsc::channel::<RunResult>();
220
221        let hs = host_state.clone();
222        let st = status.clone();
223
224        std::thread::spawn(move || {
225            let rt = tokio::runtime::Runtime::new().unwrap();
226            while let Ok(request) = req_rx.recv() {
227                let mut host = crate::runtime::BrowserHost::recreate(hs.clone(), st.clone());
228                let result = match request {
229                    RunRequest::FetchAndRun { url } => rt.block_on(host.fetch_and_run(&url)),
230                    RunRequest::LoadLocal {
231                        bytes,
232                        url,
233                        manifest,
234                    } => host.run_bytes(&bytes, &url, manifest),
235                };
236                let (error, live_module) = match result {
237                    Ok(live) => (None, live),
238                    Err(e) => (Some(e.to_string()), None),
239                };
240                let _ = res_tx.send(RunResult { error, live_module });
241            }
242        });
243
244        Self {
245            id,
246            url_input: String::from("oxide://home"),
247            host_state,
248            status,
249            show_console: false,
250            run_tx: req_tx,
251            run_rx: Arc::new(Mutex::new(res_rx)),
252            image_textures: HashMap::new(),
253            pip_texture: None,
254            pip_last_serial: 0,
255            canvas_generation: 0,
256            pending_history_url: None,
257            hovered_link_url: None,
258            live_module: None,
259            last_frame: Instant::now(),
260            keys_held: HashSet::new(),
261            text_input_focus: None,
262            widget_edits: HashMap::new(),
263            widget_bounds_cache: HashMap::new(),
264            url_cursor: 12,
265            url_sel_start: 12,
266            url_selecting: false,
267            url_text_bounds: Arc::new(Mutex::new(Bounds::default())),
268            internal_page: Some(InternalPage::Home),
269            forge_prompt: String::new(),
270            forge_session_id: None,
271        }
272        .with_home_status()
273    }
274
275    fn with_home_status(self) -> Self {
276        *self.status.lock().unwrap() = PageStatus::Running("oxide://home".to_string());
277        self
278    }
279
280    fn display_title(&self) -> String {
281        let status = self.status.lock().unwrap().clone();
282        match status {
283            PageStatus::Idle => "New Tab".to_string(),
284            PageStatus::Loading(_) => "Loading\u{2026}".to_string(),
285            PageStatus::Running(ref url) => {
286                // Prefer the app's manifest name over a URL-derived title — but not on
287                // internal oxide:// pages, which would otherwise keep showing the name of
288                // a previously loaded app (the manifest isn't cleared when switching to
289                // an internal page).
290                if self.internal_page.is_none() {
291                    if let Some(m) = self.host_state.manifest.lock().unwrap().as_ref() {
292                        if !m.name.trim().is_empty() {
293                            return m.name.clone();
294                        }
295                    }
296                }
297                url_to_title(url)
298            }
299            PageStatus::Error(_) => "Error".to_string(),
300        }
301    }
302
303    fn navigate(&mut self, dm: &DownloadManager) {
304        let mut url = self.url_input.trim().to_string();
305        if url.is_empty() {
306            return;
307        }
308        let is_search = (!url.contains('.')
309            && !url.starts_with("oxide://")
310            && !url.starts_with("http://")
311            && !url.starts_with("https://"))
312            || url.contains(' ');
313        if is_search {
314            self.forge_prompt = url;
315            url = "oxide://forge".to_string();
316            self.url_input = url.clone();
317            self.url_clamp_cursor();
318        }
319        if let Some(page) = try_internal_page(&url) {
320            self.internal_page = Some(page);
321            self.live_module = None;
322            *self.status.lock().unwrap() = PageStatus::Running(url.clone());
323            let mut nav = self.host_state.navigation.lock().unwrap();
324            nav.push(HistoryEntry::new(&url));
325            return;
326        }
327        if is_downloadable_url(&url) {
328            dm.start_download(url);
329            return;
330        }
331        self.internal_page = None;
332        self.pending_history_url = Some(url.clone());
333        let _ = self.run_tx.send(RunRequest::FetchAndRun { url });
334    }
335
336    fn navigate_to(&mut self, mut url: String, push_history: bool, dm: &DownloadManager) {
337        let is_search = (!url.contains('.')
338            && !url.starts_with("oxide://")
339            && !url.starts_with("http://")
340            && !url.starts_with("https://"))
341            || url.contains(' ');
342        if is_search {
343            self.forge_prompt = url;
344            url = "oxide://forge".to_string();
345        }
346        self.url_input = url.clone();
347        let len = self.url_input.len();
348        self.url_cursor = len;
349        self.url_sel_start = len;
350        if let Some(page) = try_internal_page(&url) {
351            self.internal_page = Some(page);
352            self.live_module = None;
353            *self.status.lock().unwrap() = PageStatus::Running(url.clone());
354            if push_history {
355                let mut nav = self.host_state.navigation.lock().unwrap();
356                nav.push(HistoryEntry::new(&url));
357            }
358            return;
359        }
360        if is_downloadable_url(&url) {
361            dm.start_download(url);
362            return;
363        }
364        self.internal_page = None;
365        if push_history {
366            self.pending_history_url = Some(url.clone());
367        }
368        let _ = self.run_tx.send(RunRequest::FetchAndRun { url });
369    }
370
371    fn reload(&mut self) {
372        *self.host_state.scroll_x.lock().unwrap() = 0.0;
373        *self.host_state.scroll_y.lock().unwrap() = 0.0;
374        let url = self.url_input.clone();
375        if !url.is_empty() {
376            if let Some(page) = try_internal_page(&url) {
377                self.internal_page = Some(page);
378                self.live_module = None;
379                *self.status.lock().unwrap() = PageStatus::Running(url.clone());
380            } else {
381                self.internal_page = None;
382                let _ = self.run_tx.send(RunRequest::FetchAndRun { url });
383            }
384        }
385    }
386
387    fn go_back(&mut self) {
388        let entry = {
389            let mut nav = self.host_state.navigation.lock().unwrap();
390            nav.go_back().cloned()
391        };
392        if let Some(entry) = entry {
393            self.url_input = entry.url.clone();
394            self.url_clamp_cursor();
395            *self.host_state.current_url.lock().unwrap() = entry.url.clone();
396            if let Some(page) = try_internal_page(&entry.url) {
397                self.internal_page = Some(page);
398                self.live_module = None;
399                *self.status.lock().unwrap() = PageStatus::Running(entry.url);
400            } else {
401                self.internal_page = None;
402                let _ = self.run_tx.send(RunRequest::FetchAndRun { url: entry.url });
403            }
404        }
405    }
406
407    fn go_forward(&mut self) {
408        let entry = {
409            let mut nav = self.host_state.navigation.lock().unwrap();
410            nav.go_forward().cloned()
411        };
412        if let Some(entry) = entry {
413            self.url_input = entry.url.clone();
414            self.url_clamp_cursor();
415            *self.host_state.current_url.lock().unwrap() = entry.url.clone();
416            if let Some(page) = try_internal_page(&entry.url) {
417                self.internal_page = Some(page);
418                self.live_module = None;
419                *self.status.lock().unwrap() = PageStatus::Running(entry.url);
420            } else {
421                self.internal_page = None;
422                let _ = self.run_tx.send(RunRequest::FetchAndRun { url: entry.url });
423            }
424        }
425    }
426
427    fn drain_results(&mut self) {
428        if let Ok(rx) = self.run_rx.lock() {
429            while let Ok(result) = rx.try_recv() {
430                if let Some(err) = result.error {
431                    *self.status.lock().unwrap() = PageStatus::Error(err);
432                    self.pending_history_url = None;
433                    self.live_module = None;
434                } else {
435                    self.internal_page = None;
436                    if let Some(url) = self.pending_history_url.take() {
437                        let mut nav = self.host_state.navigation.lock().unwrap();
438                        nav.push(HistoryEntry::new(&url));
439                        drop(nav);
440                        if let Some(store) = self.host_state.history_store.lock().unwrap().as_ref()
441                        {
442                            let title = url_to_title(&url);
443                            let _ = store.record(&url, &title);
444                        }
445                    }
446                    self.host_state.widget_states.lock().unwrap().clear();
447                    self.host_state.widget_clicked.lock().unwrap().clear();
448                    self.host_state.widget_commands.lock().unwrap().clear();
449                    self.live_module = result.live_module;
450                    self.last_frame = Instant::now();
451                }
452            }
453        }
454    }
455
456    fn handle_pending_navigation(&mut self, dm: &DownloadManager) {
457        let pending = self.host_state.pending_navigation.lock().unwrap().take();
458        if let Some(url) = pending {
459            self.navigate_to(url, true, dm);
460        }
461    }
462
463    fn sync_url_bar(&mut self) {
464        let cur = self.host_state.current_url.lock().unwrap().clone();
465        if !cur.is_empty() && cur != self.url_input {
466            let status = self.status.lock().unwrap().clone();
467            if matches!(status, PageStatus::Running(_)) {
468                self.url_input = cur;
469                self.url_clamp_cursor();
470            }
471        }
472    }
473
474    fn url_clamp_cursor(&mut self) {
475        let len = self.url_input.len();
476        self.url_cursor = self.url_cursor.min(len);
477        self.url_sel_start = self.url_sel_start.min(len);
478    }
479
480    fn url_has_selection(&self) -> bool {
481        self.url_cursor != self.url_sel_start
482    }
483
484    fn url_sel_range(&self) -> std::ops::Range<usize> {
485        let lo = self.url_cursor.min(self.url_sel_start);
486        let hi = self.url_cursor.max(self.url_sel_start);
487        lo..hi
488    }
489
490    fn url_prev_boundary(&self) -> usize {
491        let text = &self.url_input;
492        if self.url_cursor == 0 {
493            return 0;
494        }
495        let mut i = self.url_cursor - 1;
496        while i > 0 && !text.is_char_boundary(i) {
497            i -= 1;
498        }
499        i
500    }
501
502    fn url_next_boundary(&self) -> usize {
503        let text = &self.url_input;
504        if self.url_cursor >= text.len() {
505            return text.len();
506        }
507        let mut i = self.url_cursor + 1;
508        while i < text.len() && !text.is_char_boundary(i) {
509            i += 1;
510        }
511        i
512    }
513
514    fn url_move_to(&mut self, offset: usize) {
515        let offset = offset.min(self.url_input.len());
516        self.url_cursor = offset;
517        self.url_sel_start = offset;
518    }
519
520    fn url_select_to(&mut self, offset: usize) {
521        self.url_cursor = offset.min(self.url_input.len());
522    }
523
524    fn url_select_all(&mut self) {
525        self.url_sel_start = 0;
526        self.url_cursor = self.url_input.len();
527    }
528
529    fn url_delete_selection(&mut self) {
530        if !self.url_has_selection() {
531            return;
532        }
533        let range = self.url_sel_range();
534        self.url_input.replace_range(range.clone(), "");
535        self.url_cursor = range.start;
536        self.url_sel_start = range.start;
537    }
538
539    fn url_insert_at_cursor(&mut self, text: &str) {
540        if self.url_has_selection() {
541            self.url_delete_selection();
542        }
543        self.url_input.insert_str(self.url_cursor, text);
544        self.url_cursor += text.len();
545        self.url_sel_start = self.url_cursor;
546    }
547
548    fn url_backspace(&mut self) {
549        if self.url_has_selection() {
550            self.url_delete_selection();
551        } else if self.url_cursor > 0 {
552            let prev = self.url_prev_boundary();
553            self.url_input.replace_range(prev..self.url_cursor, "");
554            self.url_cursor = prev;
555            self.url_sel_start = prev;
556        }
557    }
558
559    fn url_delete_forward(&mut self) {
560        if self.url_has_selection() {
561            self.url_delete_selection();
562        } else if self.url_cursor < self.url_input.len() {
563            let next = self.url_next_boundary();
564            self.url_input.replace_range(self.url_cursor..next, "");
565        }
566    }
567
568    fn url_selected_text(&self) -> String {
569        if self.url_has_selection() {
570            self.url_input[self.url_sel_range()].to_string()
571        } else {
572            String::new()
573        }
574    }
575
576    fn sync_keys_held_to_input(&self) {
577        let mut input = self.host_state.input_state.lock().unwrap();
578        input.keys_down.clear();
579        input.keys_down.extend(self.keys_held.iter().copied());
580    }
581
582    fn tick_frame(&mut self) {
583        if self.live_module.is_none() {
584            return;
585        }
586
587        let now = Instant::now();
588        let dt = now - self.last_frame;
589        self.last_frame = now;
590        let dt_ms = dt.as_millis().min(100) as u32;
591
592        self.host_state.widget_commands.lock().unwrap().clear();
593
594        if let Some(ref mut live) = self.live_module {
595            match live.tick(dt_ms) {
596                Ok(()) => {}
597                Err(e) => {
598                    let msg = if e.to_string().contains("fuel") {
599                        "on_frame halted: fuel limit exceeded".to_string()
600                    } else {
601                        format!("on_frame error: {e}")
602                    };
603                    crate::capabilities::console_log(
604                        &self.host_state.console,
605                        crate::capabilities::ConsoleLevel::Error,
606                        msg.clone(),
607                    );
608                    *self.status.lock().unwrap() = PageStatus::Error(msg);
609                    self.live_module = None;
610                }
611            }
612        }
613
614        self.host_state.widget_clicked.lock().unwrap().clear();
615    }
616
617    fn post_tick_clear_input(&mut self) {
618        let mut input = self.host_state.input_state.lock().unwrap();
619        input.keys_pressed.clear();
620        input.mouse_buttons_clicked = [false; 3];
621        input.scroll_x = 0.0;
622        input.scroll_y = 0.0;
623    }
624
625    fn update_texture_cache(&mut self, _window: &mut Window) {
626        let tab_id = self.id;
627        let canvas = self.host_state.canvas.lock().unwrap();
628        if canvas.generation != self.canvas_generation {
629            self.image_textures.clear();
630            self.canvas_generation = canvas.generation;
631        }
632        for (i, decoded) in canvas.images.iter().enumerate() {
633            self.image_textures.entry(i).or_insert_with(|| {
634                decoded_to_render_image(decoded, format!("oxide_img_{i}_tab{tab_id}"))
635            });
636        }
637    }
638
639    fn refresh_pip_texture(&mut self, _window: &mut Window) {
640        let pip = self.host_state.video.lock().unwrap().pip;
641        if !pip {
642            self.pip_texture = None;
643            self.pip_last_serial = 0;
644            return;
645        }
646
647        let serial = *self.host_state.video_pip_serial.lock().unwrap();
648        if serial != self.pip_last_serial {
649            self.pip_last_serial = serial;
650            self.pip_texture = None;
651            let frame = self.host_state.video_pip_frame.lock().unwrap().clone();
652            if let Some(decoded) = frame {
653                self.pip_texture = Some(decoded_to_render_image(
654                    &decoded,
655                    format!("oxide_pip_{}_{}", self.id, serial),
656                ));
657            }
658        }
659    }
660}
661
662/// Decode RGBA guest bytes into a GPU [`RenderImage`] (BGRA upload for the renderer).
663fn decoded_to_render_image(
664    decoded: &crate::capabilities::DecodedImage,
665    _debug_label: String,
666) -> Arc<RenderImage> {
667    let mut buf = image::RgbaImage::from_raw(decoded.width, decoded.height, decoded.pixels.clone())
668        .expect("decoded image dimensions");
669    for pixel in buf.chunks_exact_mut(4) {
670        pixel.swap(0, 2);
671    }
672    let frame = Frame::new(buf);
673    Arc::new(RenderImage::new(smallvec![frame]))
674}
675
676fn rgba8(r: u8, g: u8, b: u8, a: u8) -> gpui::Hsla {
677    gpui::Hsla::from(Rgba {
678        r: r as f32 / 255.0,
679        g: g as f32 / 255.0,
680        b: b as f32 / 255.0,
681        a: a as f32 / 255.0,
682    })
683}
684
685fn circle_polygon(cx: f32, cy: f32, radius: f32) -> Vec<Point<Pixels>> {
686    let n = 24;
687    (0..n)
688        .map(|i| {
689            let t = i as f32 / n as f32 * std::f32::consts::TAU;
690            point(px(cx + radius * t.cos()), px(cy + radius * t.sin()))
691        })
692        .collect()
693}
694
695/// Saved canvas state for the transform/clip/opacity stack.
696#[derive(Clone)]
697struct CanvasPaintState {
698    offset_x: f32,
699    offset_y: f32,
700    clip: Option<Bounds<Pixels>>,
701    opacity: f32,
702}
703
704fn paint_draw_commands(
705    window: &mut Window,
706    cx: &mut gpui::App,
707    bounds: Bounds<Pixels>,
708    cmds: &[DrawCommand],
709    textures: &HashMap<usize, Arc<RenderImage>>,
710) {
711    let rect = bounds;
712    let origin_x = f32::from(rect.origin.x);
713    let origin_y = f32::from(rect.origin.y);
714
715    let mut state_stack: Vec<CanvasPaintState> = Vec::new();
716    let mut off_x = origin_x;
717    let mut off_y = origin_y;
718    let mut clip: Option<Bounds<Pixels>> = None;
719    let mut opacity: f32 = 1.0;
720
721    for cmd in cmds {
722        match cmd {
723            DrawCommand::Save => {
724                state_stack.push(CanvasPaintState {
725                    offset_x: off_x,
726                    offset_y: off_y,
727                    clip,
728                    opacity,
729                });
730            }
731            DrawCommand::Restore => {
732                if let Some(prev) = state_stack.pop() {
733                    off_x = prev.offset_x;
734                    off_y = prev.offset_y;
735                    clip = prev.clip;
736                    opacity = prev.opacity;
737                }
738            }
739            DrawCommand::Transform {
740                a: _,
741                b: _,
742                c: _,
743                d: _,
744                tx,
745                ty,
746            } => {
747                off_x += *tx;
748                off_y += *ty;
749            }
750            DrawCommand::Clip { x, y, w, h } => {
751                let new_clip = Bounds::from_corners(
752                    point(px(off_x + *x), px(off_y + *y)),
753                    point(px(off_x + *x + *w), px(off_y + *y + *h)),
754                );
755                clip = Some(match clip {
756                    Some(existing) => intersect_bounds(existing, new_clip),
757                    None => new_clip,
758                });
759            }
760            DrawCommand::Opacity { alpha } => {
761                opacity *= *alpha;
762            }
763
764            DrawCommand::Clear { r, g, b, a } => {
765                let ca = apply_opacity(*a, opacity);
766                window.paint_quad(gpui::fill(rect, rgba8(*r, *g, *b, ca)));
767            }
768            DrawCommand::Rect {
769                x,
770                y,
771                w,
772                h,
773                r,
774                g,
775                b,
776                a,
777            } => {
778                let min = point(px(off_x + *x), px(off_y + *y));
779                let cmd_bounds = Bounds::from_corners(min, min + point(px(*w), px(*h)));
780                if !clipped_out(clip, cmd_bounds) {
781                    let ca = apply_opacity(*a, opacity);
782                    window.paint_quad(gpui::fill(cmd_bounds, rgba8(*r, *g, *b, ca)));
783                }
784            }
785            DrawCommand::Circle {
786                cx,
787                cy,
788                radius,
789                r,
790                g,
791                b,
792                a,
793            } => {
794                let pts = circle_polygon(off_x + *cx, off_y + *cy, *radius);
795                let mut pb = PathBuilder::fill();
796                pb.add_polygon(&pts, true);
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::Text {
803                x,
804                y,
805                size,
806                r,
807                g,
808                b,
809                a,
810                text,
811            } => {
812                let origin = point(px(off_x + *x), px(off_y + *y));
813                let text_owned = text.clone();
814                let ca = apply_opacity(*a, opacity);
815                let run = TextRun {
816                    len: text_owned.len(),
817                    font: font(".SystemUIFont"),
818                    color: rgba8(*r, *g, *b, ca),
819                    background_color: None,
820                    underline: None,
821                    strikethrough: None,
822                };
823                let line = window.text_system().shape_line(
824                    SharedString::from(text_owned),
825                    px(*size),
826                    &[run],
827                    None,
828                );
829                let _ = line.paint(origin, px(*size * 1.2), window, cx);
830            }
831            DrawCommand::TextEx {
832                x,
833                y,
834                size,
835                r,
836                g,
837                b,
838                a,
839                family,
840                weight,
841                style,
842                align,
843                text,
844            } => {
845                let text_owned = text.clone();
846                let ca = apply_opacity(*a, opacity);
847                let run = TextRun {
848                    len: text_owned.len(),
849                    font: crate::capabilities::make_gpui_font(family, *weight, *style),
850                    color: rgba8(*r, *g, *b, ca),
851                    background_color: None,
852                    underline: None,
853                    strikethrough: None,
854                };
855                let line = window.text_system().shape_line(
856                    SharedString::from(text_owned),
857                    px(*size),
858                    &[run],
859                    None,
860                );
861                let line_x = match *align {
862                    1 => off_x + *x - f32::from(line.width) / 2.0,
863                    2 => off_x + *x - f32::from(line.width),
864                    _ => off_x + *x,
865                };
866                let origin = point(px(line_x), px(off_y + *y));
867                let _ = line.paint(origin, px(*size * 1.2), window, cx);
868            }
869            DrawCommand::Line {
870                x1,
871                y1,
872                x2,
873                y2,
874                r,
875                g,
876                b,
877                a,
878                thickness,
879            } => {
880                let p1 = point(px(off_x + *x1), px(off_y + *y1));
881                let p2 = point(px(off_x + *x2), px(off_y + *y2));
882                let mut pb = PathBuilder::stroke(px(*thickness));
883                pb.move_to(p1);
884                pb.line_to(p2);
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::Image {
891                x,
892                y,
893                w,
894                h,
895                image_id,
896            } => {
897                if let Some(tex) = textures.get(image_id) {
898                    let min = point(px(off_x + *x), px(off_y + *y));
899                    let img_bounds = Bounds::from_corners(min, min + point(px(*w), px(*h)));
900                    let _ = window.paint_image(img_bounds, (0.).into(), tex.clone(), 0, false);
901                }
902            }
903            DrawCommand::RoundedRect {
904                x,
905                y,
906                w,
907                h,
908                radius,
909                r,
910                g,
911                b,
912                a,
913            } => {
914                let min = point(px(off_x + *x), px(off_y + *y));
915                let cmd_bounds = Bounds::from_corners(min, min + point(px(*w), px(*h)));
916                if !clipped_out(clip, cmd_bounds) {
917                    let ca = apply_opacity(*a, opacity);
918                    let pts = rounded_rect_polygon(off_x + *x, off_y + *y, *w, *h, *radius);
919                    let mut pb = PathBuilder::fill();
920                    pb.add_polygon(&pts, true);
921                    if let Ok(path) = pb.build() {
922                        window.paint_path(path, rgba8(*r, *g, *b, ca));
923                    }
924                }
925            }
926            DrawCommand::Arc {
927                cx,
928                cy,
929                radius,
930                start_angle,
931                end_angle,
932                r,
933                g,
934                b,
935                a,
936                thickness,
937            } => {
938                let pts = arc_polyline(off_x + *cx, off_y + *cy, *radius, *start_angle, *end_angle);
939                if pts.len() >= 2 {
940                    let mut pb = PathBuilder::stroke(px(*thickness));
941                    pb.move_to(pts[0]);
942                    for p in &pts[1..] {
943                        pb.line_to(*p);
944                    }
945                    if let Ok(path) = pb.build() {
946                        let ca = apply_opacity(*a, opacity);
947                        window.paint_path(path, rgba8(*r, *g, *b, ca));
948                    }
949                }
950            }
951            DrawCommand::Bezier {
952                x1,
953                y1,
954                cp1x,
955                cp1y,
956                cp2x,
957                cp2y,
958                x2,
959                y2,
960                r,
961                g,
962                b,
963                a,
964                thickness,
965            } => {
966                let p1 = point(px(off_x + *x1), px(off_y + *y1));
967                let p2 = point(px(off_x + *x2), px(off_y + *y2));
968                let c1 = point(px(off_x + *cp1x), px(off_y + *cp1y));
969                let c2 = point(px(off_x + *cp2x), px(off_y + *cp2y));
970                let mut pb = PathBuilder::stroke(px(*thickness));
971                pb.move_to(p1);
972                pb.cubic_bezier_to(p2, c1, c2);
973                if let Ok(path) = pb.build() {
974                    let ca = apply_opacity(*a, opacity);
975                    window.paint_path(path, rgba8(*r, *g, *b, ca));
976                }
977            }
978            DrawCommand::Gradient {
979                x,
980                y,
981                w,
982                h,
983                kind,
984                ax: _,
985                ay: _,
986                bx: _,
987                by: _,
988                stops,
989            } => {
990                paint_gradient(
991                    window,
992                    &GradientParams {
993                        x: off_x + *x,
994                        y: off_y + *y,
995                        w: *w,
996                        h: *h,
997                        kind: *kind,
998                        stops: stops.clone(),
999                        opacity,
1000                    },
1001                );
1002            }
1003        }
1004    }
1005}
1006
1007fn apply_opacity(a: u8, opacity: f32) -> u8 {
1008    (a as f32 * opacity).round().clamp(0.0, 255.0) as u8
1009}
1010
1011fn clipped_out(clip: Option<Bounds<Pixels>>, target: Bounds<Pixels>) -> bool {
1012    if let Some(c) = clip {
1013        let cl = f32::from(c.origin.x);
1014        let ct = f32::from(c.origin.y);
1015        let cr = cl + f32::from(c.size.width);
1016        let cb = ct + f32::from(c.size.height);
1017
1018        let tl = f32::from(target.origin.x);
1019        let tt = f32::from(target.origin.y);
1020        let tr = tl + f32::from(target.size.width);
1021        let tb = tt + f32::from(target.size.height);
1022
1023        tr <= cl || tl >= cr || tb <= ct || tt >= cb
1024    } else {
1025        false
1026    }
1027}
1028
1029fn intersect_bounds(a: Bounds<Pixels>, b: Bounds<Pixels>) -> Bounds<Pixels> {
1030    let al = f32::from(a.origin.x);
1031    let at = f32::from(a.origin.y);
1032    let ar = al + f32::from(a.size.width);
1033    let ab = at + f32::from(a.size.height);
1034
1035    let bl = f32::from(b.origin.x);
1036    let bt = f32::from(b.origin.y);
1037    let br = bl + f32::from(b.size.width);
1038    let bb = bt + f32::from(b.size.height);
1039
1040    let il = al.max(bl);
1041    let it = at.max(bt);
1042    let ir = ar.min(br);
1043    let ib = ab.min(bb);
1044
1045    Bounds::from_corners(point(px(il), px(it)), point(px(ir.max(il)), px(ib.max(it))))
1046}
1047
1048fn rounded_rect_polygon(x: f32, y: f32, w: f32, h: f32, radius: f32) -> Vec<Point<Pixels>> {
1049    let r = radius.min(w / 2.0).min(h / 2.0);
1050    let segs = 4;
1051    let mut pts = Vec::with_capacity(segs * 4 + 4);
1052    for corner in 0..4 {
1053        let (corner_x, corner_y, angle_start) = match corner {
1054            0 => (x + w - r, y + r, -std::f32::consts::FRAC_PI_2), // top-right
1055            1 => (x + w - r, y + h - r, 0.0),                      // bottom-right
1056            2 => (x + r, y + h - r, std::f32::consts::FRAC_PI_2),  // bottom-left
1057            _ => (x + r, y + r, std::f32::consts::PI),             // top-left
1058        };
1059        for i in 0..=segs {
1060            let t = angle_start + (i as f32 / segs as f32) * std::f32::consts::FRAC_PI_2;
1061            pts.push(point(
1062                px(corner_x + r * t.cos()),
1063                px(corner_y + r * t.sin()),
1064            ));
1065        }
1066    }
1067    pts
1068}
1069
1070fn arc_polyline(cx: f32, cy: f32, radius: f32, start: f32, end: f32) -> Vec<Point<Pixels>> {
1071    let sweep = end - start;
1072    let n = ((sweep.abs() / std::f32::consts::TAU) * 24.0)
1073        .ceil()
1074        .max(2.0) as usize;
1075    (0..=n)
1076        .map(|i| {
1077            let t = start + (i as f32 / n as f32) * sweep;
1078            point(px(cx + radius * t.cos()), px(cy + radius * t.sin()))
1079        })
1080        .collect()
1081}
1082
1083struct GradientParams {
1084    x: f32,
1085    y: f32,
1086    w: f32,
1087    h: f32,
1088    kind: u8,
1089    stops: Vec<GradientStop>,
1090    opacity: f32,
1091}
1092
1093fn paint_gradient(window: &mut Window, p: &GradientParams) {
1094    if p.stops.is_empty() {
1095        return;
1096    }
1097
1098    // Keep band count low — GPUI's Metal scene buffer has per-frame limits and each band
1099    // is a separate quad.  8 bands gives a smooth-enough look without overwhelming the
1100    // renderer (64 bands was causing "scene too large" at >800 quads per frame).
1101    let bands: usize = 8;
1102    for i in 0..bands {
1103        let t = i as f32 / (bands - 1).max(1) as f32;
1104        let (sr, sg, sb, sa) = sample_gradient(&p.stops, t);
1105        let ca = apply_opacity(sa, p.opacity);
1106
1107        if p.kind == 1 {
1108            // Radial: concentric rectangles from outside in.
1109            let frac = 1.0 - t;
1110            let bx = p.x + p.w * 0.5 * t;
1111            let by = p.y + p.h * 0.5 * t;
1112            let bw = p.w * frac;
1113            let bh = p.h * frac;
1114            if bw > 0.0 && bh > 0.0 {
1115                let min = point(px(bx), px(by));
1116                let band_bounds = Bounds::from_corners(min, min + point(px(bw), px(bh)));
1117                window.paint_quad(gpui::fill(band_bounds, rgba8(sr, sg, sb, ca)));
1118            }
1119        } else {
1120            // Linear: vertical bands along the gradient axis.
1121            let band_h = p.h / bands as f32;
1122            let by = p.y + i as f32 * band_h;
1123            let min = point(px(p.x), px(by));
1124            let band_bounds = Bounds::from_corners(min, min + point(px(p.w), px(band_h.ceil())));
1125            window.paint_quad(gpui::fill(band_bounds, rgba8(sr, sg, sb, ca)));
1126        }
1127    }
1128}
1129
1130fn sample_gradient(stops: &[GradientStop], t: f32) -> (u8, u8, u8, u8) {
1131    if stops.len() == 1 {
1132        let s = &stops[0];
1133        return (s.r, s.g, s.b, s.a);
1134    }
1135    let t = t.clamp(0.0, 1.0);
1136    let mut lo = &stops[0];
1137    let mut hi = &stops[stops.len() - 1];
1138    for pair in stops.windows(2) {
1139        if t >= pair[0].offset && t <= pair[1].offset {
1140            lo = &pair[0];
1141            hi = &pair[1];
1142            break;
1143        }
1144    }
1145    let range = hi.offset - lo.offset;
1146    let frac = if range > 0.0 {
1147        (t - lo.offset) / range
1148    } else {
1149        0.0
1150    };
1151    let lerp = |a: u8, b: u8| -> u8 { (a as f32 + (b as f32 - a as f32) * frac).round() as u8 };
1152    (
1153        lerp(lo.r, hi.r),
1154        lerp(lo.g, hi.g),
1155        lerp(lo.b, hi.b),
1156        lerp(lo.a, hi.a),
1157    )
1158}
1159
1160/// Result of a background `rfd` file dialog (must not run inside GPUI `App::update` — modal + focus events re-enter and panic).
1161enum FilePickDone {
1162    Chosen { path: PathBuf, bytes: Vec<u8> },
1163    Directory(PathBuf),
1164    Cancelled,
1165}
1166
1167pub struct OxideBrowserView {
1168    tabs: Vec<TabState>,
1169    active_tab: usize,
1170    next_tab_id: u64,
1171    shared_kv_db: Option<Arc<sled::Db>>,
1172    shared_module_loader: Option<Arc<ModuleLoader>>,
1173    bookmark_store: Option<BookmarkStore>,
1174    history_store: Option<HistoryStore>,
1175    show_bookmarks: bool,
1176    show_menu: bool,
1177    /// Focus for the page (canvas + guest widgets); required for keyboard to reach `on_key_down` on the root.
1178    canvas_focus: FocusHandle,
1179    url_focus: FocusHandle,
1180    /// Keyboard focus for the `oxide://forge` chat composer.
1181    forge_focus: FocusHandle,
1182    /// Keyboard focus for API key field in Forge settings.
1183    forge_settings_key_focus: FocusHandle,
1184    /// Keyboard focus for model field in Forge settings.
1185    forge_settings_model_focus: FocusHandle,
1186    /// Persisted multi-provider Forge configuration.
1187    forge_config: ForgeUserConfig,
1188    /// Provider tab selected in the settings panel.
1189    forge_settings_provider: ForgeProvider,
1190    /// Draft API key while editing settings (cleared after save).
1191    forge_settings_key_draft: String,
1192    /// Draft model id while editing settings.
1193    forge_settings_model_draft: String,
1194    /// Receiver for [`FilePickDone`]; dialog runs on a background thread so the main thread never holds `App` during `NSOpenPanel`.
1195    file_pick_rx: Option<mpsc::Receiver<FilePickDone>>,
1196    download_manager: DownloadManager,
1197    show_downloads: bool,
1198    /// Lazily-initialised Claude-backed guest app factory for `oxide://forge`.
1199    forge: Arc<Mutex<Option<ForgeState>>>,
1200    /// Whether the user is currently dragging the scrollbar thumb.
1201    scroll_dragging: bool,
1202    /// The screen Y position where the scrollbar drag started.
1203    scroll_drag_start_y: f32,
1204    /// The absolute scroll Y offset when the scrollbar drag started.
1205    scroll_drag_start_scroll_y: f32,
1206    /// Active slider drag: (widget id, slider x, slider width, min, max).
1207    slider_drag: Option<(u32, f32, f32, f32, f32)>,
1208}
1209
1210impl OxideBrowserView {
1211    fn new(cx: &mut Context<Self>, host_state: HostState, status: Arc<Mutex<PageStatus>>) -> Self {
1212        let shared_kv_db = host_state.kv_db.clone();
1213        let shared_module_loader = host_state.module_loader.clone();
1214        let bookmark_store = host_state.bookmark_store.lock().unwrap().clone();
1215        let history_store = host_state.history_store.lock().unwrap().clone();
1216        let first_tab = TabState::new(0, host_state, status);
1217        Self {
1218            tabs: vec![first_tab],
1219            active_tab: 0,
1220            next_tab_id: 1,
1221            shared_kv_db,
1222            shared_module_loader,
1223            bookmark_store,
1224            history_store,
1225            show_bookmarks: false,
1226            show_menu: false,
1227            canvas_focus: cx.focus_handle(),
1228            url_focus: cx.focus_handle(),
1229            forge_focus: cx.focus_handle(),
1230            forge_settings_key_focus: cx.focus_handle(),
1231            forge_settings_model_focus: cx.focus_handle(),
1232            forge_config: ForgeUserConfig::load(),
1233            forge_settings_provider: ForgeProvider::Anthropic,
1234            forge_settings_key_draft: String::new(),
1235            forge_settings_model_draft: String::new(),
1236            file_pick_rx: None,
1237            download_manager: DownloadManager::new(),
1238            show_downloads: false,
1239            forge: Arc::new(Mutex::new(None)),
1240            scroll_dragging: false,
1241            scroll_drag_start_y: 0.0,
1242            scroll_drag_start_scroll_y: 0.0,
1243            slider_drag: None,
1244        }
1245    }
1246
1247    /// Ensure the Forge subsystem is initialised; returns `true` if available.
1248    fn ensure_forge(&mut self) -> bool {
1249        let mut g = self.forge.lock().unwrap();
1250        if g.is_none() {
1251            *g = ForgeState::from_config(&self.forge_config);
1252        }
1253        if let Some(forge) = g.as_mut() {
1254            forge.apply_config(&self.forge_config);
1255        }
1256        g.is_some()
1257    }
1258
1259    fn forge_sync_settings_drafts(&mut self) {
1260        let p = self.forge_settings_provider;
1261        let settings = self.forge_config.provider(p);
1262        self.forge_settings_model_draft = settings.model_or_default(p);
1263        self.forge_settings_key_draft.clear();
1264    }
1265
1266    fn forge_toggle_settings(&mut self) {
1267        self.forge_config.settings_open = !self.forge_config.settings_open;
1268        if self.forge_config.settings_open {
1269            self.forge_settings_provider = self.forge_config.active_provider;
1270            self.forge_sync_settings_drafts();
1271        }
1272    }
1273
1274    fn forge_select_provider(&mut self, provider: ForgeProvider) {
1275        self.forge_config.active_provider = provider;
1276        self.forge_settings_provider = provider;
1277        self.forge_sync_settings_drafts();
1278        let mut g = self.forge.lock().unwrap();
1279        if let Some(forge) = g.as_mut() {
1280            forge.apply_config(&self.forge_config);
1281        }
1282        let _ = self.forge_config.save();
1283    }
1284
1285    fn forge_save_provider_settings(&mut self) {
1286        let provider = self.forge_settings_provider;
1287        if !self.forge_settings_key_draft.trim().is_empty() {
1288            self.forge_config
1289                .set_api_key(provider, self.forge_settings_key_draft.clone());
1290        }
1291        self.forge_config
1292            .set_model(provider, self.forge_settings_model_draft.clone());
1293        let _ = self.forge_config.save();
1294        self.forge_settings_key_draft.clear();
1295
1296        let mut g = self.forge.lock().unwrap();
1297        if g.is_none() {
1298            *g = ForgeState::from_config(&self.forge_config);
1299        } else if let Some(forge) = g.as_mut() {
1300            forge.apply_config(&self.forge_config);
1301        }
1302    }
1303
1304    /// Snapshot the session active in the current tab, if any.
1305    fn forge_current_snapshot(&self) -> Option<ForgeSnapshot> {
1306        let id = self.tabs[self.active_tab].forge_session_id?;
1307        let g = self.forge.lock().ok()?;
1308        g.as_ref()?.snapshot(id)
1309    }
1310
1311    fn forge_creations(&self) -> Vec<ForgeCreationSummary> {
1312        let g = self.forge.lock().ok();
1313        g.and_then(|g| g.as_ref().map(|forge| forge.list_creations()))
1314            .unwrap_or_default()
1315    }
1316
1317    fn forge_output_dir(&self) -> Option<PathBuf> {
1318        let g = self.forge.lock().ok()?;
1319        Some(g.as_ref()?.output_dir())
1320    }
1321
1322    /// Submit the current tab's prompt. On success, the tab's
1323    /// `forge_session_id` is set and the prompt is cleared.
1324    fn forge_submit(&mut self) {
1325        let idx = self.active_tab;
1326        let prompt = self.tabs[idx].forge_prompt.trim().to_string();
1327        if prompt.is_empty() {
1328            return;
1329        }
1330        if !self.ensure_forge() {
1331            return;
1332        }
1333        let session_id = self.tabs[idx].forge_session_id;
1334        let result = {
1335            let mut g = self.forge.lock().unwrap();
1336            g.as_mut().map(|forge| match session_id {
1337                Some(id) => forge.revise(id, prompt.clone()).map(|_| id),
1338                None => forge.start(prompt.clone()),
1339            })
1340        };
1341        match result {
1342            Some(Ok(id)) => {
1343                self.tabs[idx].forge_session_id = Some(id);
1344                self.tabs[idx].forge_prompt.clear();
1345            }
1346            Some(Err(e)) => {
1347                let console = self.tabs[idx].host_state.console.clone();
1348                crate::capabilities::console_log(
1349                    &console,
1350                    ConsoleLevel::Error,
1351                    format!("[FORGE] start failed: {e}"),
1352                );
1353            }
1354            None => {}
1355        }
1356    }
1357
1358    fn forge_pick_output_dir(&mut self) {
1359        if self.file_pick_rx.is_some() {
1360            return;
1361        }
1362        let (tx, rx) = mpsc::channel();
1363        self.file_pick_rx = Some(rx);
1364        std::thread::spawn(move || {
1365            let msg = rfd::FileDialog::new()
1366                .set_title("Choose Oxide Forge Output Folder")
1367                .pick_folder()
1368                .map(FilePickDone::Directory)
1369                .unwrap_or(FilePickDone::Cancelled);
1370            let _ = tx.send(msg);
1371        });
1372    }
1373
1374    /// Kick off a `cargo build` for the current tab's session.
1375    fn forge_build(&self) {
1376        let id = match self.tabs[self.active_tab].forge_session_id {
1377            Some(id) => id,
1378            None => return,
1379        };
1380        let mut g = self.forge.lock().unwrap();
1381        if let Some(forge) = g.as_mut() {
1382            if let Err(e) = forge.build(id) {
1383                crate::capabilities::console_log(
1384                    &self.tabs[self.active_tab].host_state.console,
1385                    ConsoleLevel::Error,
1386                    format!("[FORGE] build failed: {e}"),
1387                );
1388            }
1389        }
1390    }
1391
1392    fn forge_delete_current_creation(&mut self) {
1393        let id = match self.tabs[self.active_tab].forge_session_id {
1394            Some(id) => id,
1395            None => return,
1396        };
1397        let result = {
1398            let mut g = self.forge.lock().unwrap();
1399            g.as_mut().map(|forge| forge.delete_creation(id))
1400        };
1401        match result {
1402            Some(Ok(())) => {
1403                for tab in &mut self.tabs {
1404                    if tab.forge_session_id == Some(id) {
1405                        tab.forge_session_id = None;
1406                        tab.forge_prompt.clear();
1407                    }
1408                }
1409            }
1410            Some(Err(e)) => {
1411                crate::capabilities::console_log(
1412                    &self.tabs[self.active_tab].host_state.console,
1413                    ConsoleLevel::Error,
1414                    format!("[FORGE] delete failed: {e}"),
1415                );
1416            }
1417            None => {}
1418        }
1419    }
1420
1421    /// Load the built `.wasm` for the current session into a new tab.
1422    fn forge_run_in_new_tab(&mut self) {
1423        let id = match self.tabs[self.active_tab].forge_session_id {
1424            Some(id) => id,
1425            None => return,
1426        };
1427        let bytes = {
1428            let g = self.forge.lock().unwrap();
1429            g.as_ref().and_then(|f| f.artifact_bytes(id))
1430        };
1431        let Some(bytes) = bytes else {
1432            return;
1433        };
1434        let slug = {
1435            let g = self.forge.lock().unwrap();
1436            g.as_ref()
1437                .and_then(|f| f.snapshot(id))
1438                .map(|s| s.slug)
1439                .unwrap_or_else(|| format!("session-{id}"))
1440        };
1441        let run_url = format!("oxide://forge/run/{slug}");
1442        let new_idx = self.create_tab();
1443        self.active_tab = new_idx;
1444        let tab = &mut self.tabs[new_idx];
1445        tab.url_input = run_url.clone();
1446        tab.url_cursor = tab.url_input.len();
1447        tab.url_sel_start = tab.url_input.len();
1448        tab.internal_page = None;
1449        let _ = tab.run_tx.send(RunRequest::LoadLocal {
1450            bytes,
1451            url: run_url,
1452            manifest: None,
1453        });
1454    }
1455
1456    fn poll_file_pick(&mut self, cx: &mut Context<Self>) {
1457        let rx = match self.file_pick_rx.take() {
1458            Some(r) => r,
1459            None => return,
1460        };
1461        match rx.try_recv() {
1462            Ok(FilePickDone::Chosen { path, bytes }) => {
1463                let manifest = match crate::manifest::load_local_manifest(&path) {
1464                    Ok(m) => m,
1465                    Err(e) => {
1466                        crate::capabilities::console_log(
1467                            &self.tabs[self.active_tab].host_state.console,
1468                            ConsoleLevel::Warn,
1469                            format!("[MANIFEST] {e} — loading app without a manifest"),
1470                        );
1471                        None
1472                    }
1473                };
1474                let file_url = format!("file://{}", path.display());
1475                let tab = &mut self.tabs[self.active_tab];
1476                tab.url_input = file_url.clone();
1477                tab.pending_history_url = Some(file_url.clone());
1478                tab.internal_page = None;
1479                let _ = tab.run_tx.send(RunRequest::LoadLocal {
1480                    bytes,
1481                    url: file_url,
1482                    manifest,
1483                });
1484                cx.notify();
1485            }
1486            Ok(FilePickDone::Directory(path)) => {
1487                if self.ensure_forge() {
1488                    let result = {
1489                        let mut g = self.forge.lock().unwrap();
1490                        g.as_mut().map(|forge| forge.set_output_dir(path.clone()))
1491                    };
1492                    if let Some(Err(e)) = result {
1493                        crate::capabilities::console_log(
1494                            &self.tabs[self.active_tab].host_state.console,
1495                            ConsoleLevel::Error,
1496                            format!("[FORGE] output folder failed: {e}"),
1497                        );
1498                    } else {
1499                        self.tabs[self.active_tab].forge_session_id = None;
1500                    }
1501                }
1502                cx.notify();
1503            }
1504            Ok(FilePickDone::Cancelled) => {}
1505            Err(TryRecvError::Empty) => {
1506                self.file_pick_rx = Some(rx);
1507            }
1508            Err(TryRecvError::Disconnected) => {}
1509        }
1510    }
1511
1512    fn create_tab(&mut self) -> usize {
1513        let bm_shared: crate::bookmarks::SharedBookmarkStore =
1514            Arc::new(Mutex::new(self.bookmark_store.clone()));
1515        let hist_shared: crate::history::SharedHistoryStore =
1516            Arc::new(Mutex::new(self.history_store.clone()));
1517        let host_state = HostState {
1518            kv_db: self.shared_kv_db.clone(),
1519            module_loader: self.shared_module_loader.clone(),
1520            bookmark_store: bm_shared,
1521            history_store: hist_shared,
1522            ..Default::default()
1523        };
1524        let status = Arc::new(Mutex::new(PageStatus::Idle));
1525        let tab = TabState::new(self.next_tab_id, host_state, status);
1526        self.next_tab_id += 1;
1527        self.tabs.push(tab);
1528        self.tabs.len() - 1
1529    }
1530
1531    /// Keep `active_tab` in range. Stale close handlers can fire with an old tab index after the strip shrinks.
1532    fn clamp_active_tab(&mut self) {
1533        if self.tabs.is_empty() {
1534            self.active_tab = 0;
1535            return;
1536        }
1537        self.active_tab = self.active_tab.min(self.tabs.len() - 1);
1538    }
1539
1540    fn close_tab(&mut self, idx: usize) {
1541        if self.tabs.len() <= 1 {
1542            return;
1543        }
1544        if idx >= self.tabs.len() {
1545            self.clamp_active_tab();
1546            return;
1547        }
1548        self.tabs.remove(idx);
1549        if self.active_tab > idx {
1550            self.active_tab -= 1;
1551        } else if self.active_tab == idx && self.active_tab >= self.tabs.len() {
1552            self.active_tab = self.tabs.len().saturating_sub(1);
1553        }
1554        self.clamp_active_tab();
1555    }
1556
1557    fn toggle_active_bookmark(&self) {
1558        let url = self.tabs[self.active_tab].url_input.trim().to_string();
1559        if url.is_empty() || url == "https://" {
1560            return;
1561        }
1562        if let Some(store) = &self.bookmark_store {
1563            if store.contains(&url) {
1564                let _ = store.remove(&url);
1565            } else {
1566                let title = url_to_title(&url);
1567                let _ = store.add(&url, &title);
1568            }
1569        }
1570    }
1571}
1572
1573impl Render for OxideBrowserView {
1574    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1575        self.clamp_active_tab();
1576        self.poll_file_pick(cx);
1577        let dm = self.download_manager.clone();
1578        for tab in &mut self.tabs {
1579            tab.drain_results();
1580            tab.handle_pending_navigation(&dm);
1581            tab.sync_url_bar();
1582        }
1583
1584        let active = self.active_tab;
1585        let canvas_focused = self.canvas_focus.is_focused(window);
1586        {
1587            let tab = &mut self.tabs[active];
1588            tab.host_state
1589                .focused
1590                .store(canvas_focused, Ordering::Relaxed);
1591            tab.sync_keys_held_to_input();
1592            // Expose the window's text system to the guest only for the
1593            // duration of `on_frame`, so `canvas_measure_text` can shape
1594            // synchronously. Cleared after tick to avoid leaking the handle.
1595            *tab.host_state.text_system.lock().unwrap() = Some(window.text_system().clone());
1596            tab.tick_frame();
1597            *tab.host_state.text_system.lock().unwrap() = None;
1598            tab.update_texture_cache(window);
1599            tab.refresh_pip_texture(window);
1600        }
1601
1602        let canvas_offset = self.tabs[active].host_state.canvas_offset.clone();
1603        let cmds = self.tabs[active]
1604            .host_state
1605            .canvas
1606            .lock()
1607            .unwrap()
1608            .commands
1609            .clone();
1610        let hyperlinks = self.tabs[active]
1611            .host_state
1612            .hyperlinks
1613            .lock()
1614            .unwrap()
1615            .clone();
1616        let hyperlinks_hover = hyperlinks.clone();
1617        let widget_commands = self.tabs[active]
1618            .host_state
1619            .widget_commands
1620            .lock()
1621            .unwrap()
1622            .clone();
1623        let widget_cmds_overlay = widget_commands.clone();
1624        let textures = self.tabs[active].image_textures.clone();
1625        let show_console = self.tabs[active].show_console;
1626        let pip_tex = self.tabs[active].pip_texture.clone();
1627
1628        self.tabs[active].post_tick_clear_input();
1629
1630        cx.on_next_frame(window, |_this, _window, cx| {
1631            cx.notify();
1632        });
1633
1634        let tab_titles: Vec<String> = self.tabs.iter().map(|t| t.display_title()).collect();
1635        let num_tabs = self.tabs.len();
1636        let active_tab = self.active_tab;
1637        let bm = self.bookmark_store.clone();
1638        let current_url = self.tabs[active].url_input.clone();
1639        let is_bookmarked = bm
1640            .as_ref()
1641            .map(|s| s.contains(&current_url))
1642            .unwrap_or(false);
1643        let url_focused = self.url_focus.is_focused(window);
1644        let caret_blink_on = SystemTime::now()
1645            .duration_since(UNIX_EPOCH)
1646            .map(|d| (d.as_millis() / 530) % 2 == 0)
1647            .unwrap_or(true);
1648        let can_back = self.tabs[active]
1649            .host_state
1650            .navigation
1651            .lock()
1652            .unwrap()
1653            .can_go_back();
1654        let can_fwd = self.tabs[active]
1655            .host_state
1656            .navigation
1657            .lock()
1658            .unwrap()
1659            .can_go_forward();
1660
1661        let mut root = div()
1662            .id("oxide_root")
1663            .track_focus(&self.canvas_focus)
1664            .focusable()
1665            .size_full()
1666            .flex()
1667            .flex_col()
1668            .bg(gpui::rgb(theme::BG))
1669            .on_key_down(cx.listener(
1670                |this: &mut OxideBrowserView, event: &KeyDownEvent, window, cx| {
1671                    {
1672                        let tab = &this.tabs[this.active_tab];
1673                        let mut input = tab.host_state.input_state.lock().unwrap();
1674                        input.modifiers_shift = event.keystroke.modifiers.shift;
1675                        input.modifiers_ctrl =
1676                            event.keystroke.modifiers.control || event.keystroke.modifiers.platform;
1677                        input.modifiers_alt = event.keystroke.modifiers.alt;
1678                    }
1679                    // Keyboard path for the permission prompt (mouse-free resolution):
1680                    // Enter = Allow, Escape = Block. Swallowed so the guest never sees them.
1681                    if !this.url_focus.is_focused(window) {
1682                        let perms = this.tabs[this.active_tab].host_state.permissions.clone();
1683                        let pending = perms.lock().unwrap().pending.is_some();
1684                        if pending && !event.keystroke.modifiers.modified() {
1685                            match event.keystroke.key.as_str() {
1686                                "enter" => {
1687                                    crate::permissions::resolve_pending(&perms, true);
1688                                    cx.notify();
1689                                    return;
1690                                }
1691                                "escape" => {
1692                                    crate::permissions::resolve_pending(&perms, false);
1693                                    cx.notify();
1694                                    return;
1695                                }
1696                                _ => {}
1697                            }
1698                        }
1699                    }
1700                    if event.keystroke.modifiers.secondary() && event.keystroke.key == "r" {
1701                        this.tabs[this.active_tab].reload();
1702                        cx.notify();
1703                        return;
1704                    }
1705                    if event.keystroke.modifiers.secondary() && event.keystroke.key == "t" {
1706                        let i = this.create_tab();
1707                        this.active_tab = i;
1708                        cx.notify();
1709                        return;
1710                    }
1711                    if event.keystroke.modifiers.secondary() && event.keystroke.key == "w" {
1712                        if this.tabs.len() > 1 {
1713                            let a = this.active_tab;
1714                            this.close_tab(a);
1715                        }
1716                        cx.notify();
1717                        return;
1718                    }
1719                    if event.keystroke.modifiers.control
1720                        && !event.keystroke.modifiers.shift
1721                        && event.keystroke.key == "tab"
1722                    {
1723                        if !this.tabs.is_empty() {
1724                            this.active_tab = (this.active_tab + 1) % this.tabs.len();
1725                        }
1726                        cx.notify();
1727                        return;
1728                    }
1729                    if event.keystroke.modifiers.control
1730                        && event.keystroke.modifiers.shift
1731                        && event.keystroke.key == "tab"
1732                    {
1733                        if !this.tabs.is_empty() {
1734                            if this.active_tab == 0 {
1735                                this.active_tab = this.tabs.len() - 1;
1736                            } else {
1737                                this.active_tab -= 1;
1738                            }
1739                        }
1740                        cx.notify();
1741                        return;
1742                    }
1743                    if event.keystroke.modifiers.secondary() && event.keystroke.key == "d" {
1744                        this.toggle_active_bookmark();
1745                        cx.notify();
1746                        return;
1747                    }
1748                    if event.keystroke.modifiers.secondary() && event.keystroke.key == "b" {
1749                        this.show_bookmarks = !this.show_bookmarks;
1750                        cx.notify();
1751                        return;
1752                    }
1753                    if this.forge_settings_key_focus.is_focused(window)
1754                        || this.forge_settings_model_focus.is_focused(window)
1755                    {
1756                        let editing_key = this.forge_settings_key_focus.is_focused(window);
1757                        let draft = if editing_key {
1758                            &mut this.forge_settings_key_draft
1759                        } else {
1760                            &mut this.forge_settings_model_draft
1761                        };
1762                        match event.keystroke.key.as_str() {
1763                            "enter" => {
1764                                this.forge_save_provider_settings();
1765                                cx.notify();
1766                                return;
1767                            }
1768                            "escape" => {
1769                                if editing_key {
1770                                    this.forge_settings_key_draft.clear();
1771                                } else {
1772                                    this.forge_sync_settings_drafts();
1773                                }
1774                                cx.notify();
1775                                return;
1776                            }
1777                            "backspace" => {
1778                                draft.pop();
1779                                cx.notify();
1780                                return;
1781                            }
1782                            _ => {}
1783                        }
1784                        if event.keystroke.modifiers.secondary() && event.keystroke.key == "v" {
1785                            if let Ok(mut cb) = arboard::Clipboard::new() {
1786                                if let Ok(pasted) = cb.get_text() {
1787                                    draft.push_str(pasted.trim());
1788                                    cx.notify();
1789                                }
1790                            }
1791                            return;
1792                        }
1793                        if let Some(s) = text_insert_from_keystroke(&event.keystroke) {
1794                            draft.push_str(&s);
1795                            cx.notify();
1796                        }
1797                        return;
1798                    }
1799                    if this.forge_focus.is_focused(window) {
1800                        let active = this.active_tab;
1801                        match event.keystroke.key.as_str() {
1802                            "enter" => {
1803                                if !event.keystroke.modifiers.shift {
1804                                    this.forge_submit();
1805                                    cx.notify();
1806                                    return;
1807                                }
1808                                // Shift+Enter → insert newline
1809                                this.tabs[active].forge_prompt.push('\n');
1810                                cx.notify();
1811                                return;
1812                            }
1813                            "escape" => {
1814                                this.tabs[active].forge_prompt.clear();
1815                                cx.notify();
1816                                return;
1817                            }
1818                            "backspace" => {
1819                                this.tabs[active].forge_prompt.pop();
1820                                cx.notify();
1821                                return;
1822                            }
1823                            _ => {}
1824                        }
1825                        if event.keystroke.modifiers.secondary() && event.keystroke.key == "v" {
1826                            if let Ok(mut cb) = arboard::Clipboard::new() {
1827                                if let Ok(pasted) = cb.get_text() {
1828                                    this.tabs[active].forge_prompt.push_str(&pasted);
1829                                    cx.notify();
1830                                }
1831                            }
1832                            return;
1833                        }
1834                        if let Some(s) = text_insert_from_keystroke(&event.keystroke) {
1835                            this.tabs[active].forge_prompt.push_str(&s);
1836                            cx.notify();
1837                        }
1838                        return;
1839                    }
1840                    if this.url_focus.is_focused(window) {
1841                        return;
1842                    }
1843                    if let Some(id) = this.tabs[this.active_tab].text_input_focus {
1844                        handle_widget_key(this, id, event);
1845                        cx.notify();
1846                        return;
1847                    }
1848                    if let Some(code) = keystroke_to_oxide(&event.keystroke) {
1849                        let tab = &mut this.tabs[this.active_tab];
1850                        tab.keys_held.insert(code);
1851                        tab.host_state
1852                            .input_state
1853                            .lock()
1854                            .unwrap()
1855                            .keys_pressed
1856                            .push(code);
1857                        cx.notify();
1858                    }
1859                },
1860            ))
1861            .on_key_up(cx.listener(|this, event: &KeyUpEvent, _, _cx| {
1862                let tab = &mut this.tabs[this.active_tab];
1863                {
1864                    let mut input = tab.host_state.input_state.lock().unwrap();
1865                    input.modifiers_shift = event.keystroke.modifiers.shift;
1866                    input.modifiers_ctrl =
1867                        event.keystroke.modifiers.control || event.keystroke.modifiers.platform;
1868                    input.modifiers_alt = event.keystroke.modifiers.alt;
1869                }
1870                if let Some(code) = keystroke_to_oxide(&event.keystroke) {
1871                    tab.keys_held.remove(&code);
1872                }
1873            }));
1874
1875        // Tab strip
1876        root = root.child(
1877            div()
1878                .h(px(40.0))
1879                .flex()
1880                .flex_row()
1881                .items_center()
1882                .px_1()
1883                .border_b_1()
1884                .border_color(gpui::rgb(0x2a2a32))
1885                .children((0..num_tabs).map(|i| {
1886                    let title = tab_titles[i].clone();
1887                    let display = truncate_tab_title(&title);
1888                    let is_active = i == active_tab;
1889                    div()
1890                        .id(("oxide_tab", i))
1891                        .flex()
1892                        .flex_row()
1893                        .items_center()
1894                        .gap_1()
1895                        .min_w(px(140.0))
1896                        .px_3()
1897                        .py_2()
1898                        .rounded_md()
1899                        .cursor_pointer()
1900                        .when(is_active, |d| d.bg(gpui::rgb(0x373741)))
1901                        .text_sm()
1902                        .text_color(if is_active {
1903                            gpui::rgb(0xdcdce6)
1904                        } else {
1905                            gpui::rgb(0x9696a0)
1906                        })
1907                        .child(
1908                            div()
1909                                .flex_1()
1910                                .min_w(px(0.0))
1911                                .overflow_hidden()
1912                                .child(display),
1913                        )
1914                        .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
1915                            this.active_tab = i;
1916                            cx.notify();
1917                        }))
1918                        .when(num_tabs > 1, |d| {
1919                            d.child(
1920                                div()
1921                                    .id(("oxide_tab_close", i))
1922                                    .flex_shrink_0()
1923                                    .cursor_pointer()
1924                                    .text_color(gpui::rgb(0xa0a0aa))
1925                                    .child("×")
1926                                    .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
1927                                        this.close_tab(i);
1928                                        cx.notify();
1929                                    })),
1930                            )
1931                        })
1932                }))
1933                .child(
1934                    div()
1935                        .id("oxide_new_tab")
1936                        .ml_1()
1937                        .cursor_pointer()
1938                        .text_color(gpui::rgb(0xc0c0cc))
1939                        .child("+")
1940                        .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
1941                            let i = this.create_tab();
1942                            this.active_tab = i;
1943                            cx.notify();
1944                        })),
1945                ),
1946        );
1947
1948        // Toolbar
1949        let (status_icon, status_color) = {
1950            let status = self.tabs[active].status.lock().unwrap();
1951            let icon = match &*status {
1952                PageStatus::Idle => "○",
1953                PageStatus::Loading(_) => "↻",
1954                PageStatus::Running(_) => "●",
1955                PageStatus::Error(_) => "●",
1956            };
1957            let color = match &*status {
1958                PageStatus::Error(_) => gpui::rgb(0xf05050),
1959                PageStatus::Running(_) => gpui::rgb(0x50e070),
1960                _ => gpui::rgb(0xa0a0a8),
1961            };
1962            (icon, color)
1963        };
1964
1965        root = root.child(
1966            div()
1967                .h(px(44.0))
1968                .flex()
1969                .flex_row()
1970                .items_center()
1971                .gap_2()
1972                .px_2()
1973                .border_b_1()
1974                .border_color(gpui::rgb(0x2a2a32))
1975                .child(
1976                    div()
1977                        .id("oxide_back")
1978                        .when(can_back, |el| el.cursor_pointer())
1979                        .text_sm()
1980                        .text_color(if can_back {
1981                            gpui::rgb(0xb8b8c4)
1982                        } else {
1983                            gpui::rgb(0x50505a)
1984                        })
1985                        .child("◀")
1986                        .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
1987                            this.tabs[this.active_tab].go_back();
1988                            cx.notify();
1989                        })),
1990                )
1991                .child(
1992                    div()
1993                        .id("oxide_forward")
1994                        .when(can_fwd, |el| el.cursor_pointer())
1995                        .text_sm()
1996                        .text_color(if can_fwd {
1997                            gpui::rgb(0xb8b8c4)
1998                        } else {
1999                            gpui::rgb(0x50505a)
2000                        })
2001                        .child("▶")
2002                        .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
2003                            this.tabs[this.active_tab].go_forward();
2004                            cx.notify();
2005                        })),
2006                )
2007                .child(
2008                    div()
2009                        .id("oxide_reload")
2010                        .cursor_pointer()
2011                        .text_sm()
2012                        .text_color(gpui::rgb(0xb8b8c4))
2013                        .hover(|style| style.text_color(gpui::rgb(0xffffff)))
2014                        .child("↻")
2015                        .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
2016                            this.tabs[this.active_tab].reload();
2017                            cx.notify();
2018                        })),
2019                )
2020                .child(
2021                    div()
2022                        .text_sm()
2023                        .text_color(status_color)
2024                        .child(status_icon.to_string()),
2025                )
2026                .child({
2027                    let url_text_for_canvas =
2028                        SharedString::from(self.tabs[active].url_input.clone());
2029                    let url_cursor = self.tabs[active].url_cursor;
2030                    let url_sel_start = self.tabs[active].url_sel_start;
2031                    let url_bounds_ref = self.tabs[active].url_text_bounds.clone();
2032                    div()
2033                        .id("oxide_url_bar")
2034                        .flex_1()
2035                        .flex()
2036                        .flex_row()
2037                        .items_center()
2038                        .h(px(32.0))
2039                        .px_3()
2040                        .rounded_md()
2041                        .bg(gpui::rgb(theme::SURFACE))
2042                        .border_1()
2043                        .border_color(if url_focused {
2044                            gpui::rgb(theme::RING)
2045                        } else {
2046                            gpui::rgb(theme::BORDER)
2047                        })
2048                        .track_focus(&self.url_focus)
2049                        .overflow_hidden()
2050                        .on_key_down(cx.listener(
2051                            |this: &mut OxideBrowserView, event: &KeyDownEvent, window, cx| {
2052                                if !this.url_focus.is_focused(window) {
2053                                    return;
2054                                }
2055                                let shift = event.keystroke.modifiers.shift;
2056                                if event.keystroke.modifiers.secondary() {
2057                                    let tab = &mut this.tabs[this.active_tab];
2058                                    match event.keystroke.key.as_str() {
2059                                        "a" => {
2060                                            tab.url_select_all();
2061                                            cx.notify();
2062                                            return;
2063                                        }
2064                                        "c" => {
2065                                            let text = tab.url_selected_text();
2066                                            if !text.is_empty() {
2067                                                if let Ok(mut cb) = arboard::Clipboard::new() {
2068                                                    let _ = cb.set_text(text);
2069                                                }
2070                                            }
2071                                            return;
2072                                        }
2073                                        "x" => {
2074                                            let text = tab.url_selected_text();
2075                                            if !text.is_empty() {
2076                                                if let Ok(mut cb) = arboard::Clipboard::new() {
2077                                                    let _ = cb.set_text(text);
2078                                                }
2079                                                tab.url_delete_selection();
2080                                                cx.notify();
2081                                            }
2082                                            return;
2083                                        }
2084                                        "v" => {
2085                                            if let Ok(mut cb) = arboard::Clipboard::new() {
2086                                                if let Ok(text) = cb.get_text() {
2087                                                    tab.url_insert_at_cursor(&text);
2088                                                    cx.notify();
2089                                                }
2090                                            }
2091                                            return;
2092                                        }
2093                                        _ => {}
2094                                    }
2095                                }
2096                                let tab = &mut this.tabs[this.active_tab];
2097                                match event.keystroke.key.as_str() {
2098                                    "left" => {
2099                                        if shift {
2100                                            tab.url_select_to(tab.url_prev_boundary());
2101                                        } else if tab.url_has_selection() {
2102                                            let lo = tab.url_sel_range().start;
2103                                            tab.url_move_to(lo);
2104                                        } else {
2105                                            let prev = tab.url_prev_boundary();
2106                                            tab.url_move_to(prev);
2107                                        }
2108                                        cx.notify();
2109                                        return;
2110                                    }
2111                                    "right" => {
2112                                        if shift {
2113                                            tab.url_select_to(tab.url_next_boundary());
2114                                        } else if tab.url_has_selection() {
2115                                            let hi = tab.url_sel_range().end;
2116                                            tab.url_move_to(hi);
2117                                        } else {
2118                                            let next = tab.url_next_boundary();
2119                                            tab.url_move_to(next);
2120                                        }
2121                                        cx.notify();
2122                                        return;
2123                                    }
2124                                    "home" => {
2125                                        if shift {
2126                                            tab.url_select_to(0);
2127                                        } else {
2128                                            tab.url_move_to(0);
2129                                        }
2130                                        cx.notify();
2131                                        return;
2132                                    }
2133                                    "end" => {
2134                                        let len = tab.url_input.len();
2135                                        if shift {
2136                                            tab.url_select_to(len);
2137                                        } else {
2138                                            tab.url_move_to(len);
2139                                        }
2140                                        cx.notify();
2141                                        return;
2142                                    }
2143                                    "backspace" => {
2144                                        tab.url_backspace();
2145                                        cx.notify();
2146                                        return;
2147                                    }
2148                                    "delete" => {
2149                                        tab.url_delete_forward();
2150                                        cx.notify();
2151                                        return;
2152                                    }
2153                                    "enter" => {
2154                                        tab.navigate(&this.download_manager);
2155                                        this.show_downloads = this.download_manager.has_active()
2156                                            || this.show_downloads;
2157                                        cx.notify();
2158                                        return;
2159                                    }
2160                                    _ => {}
2161                                }
2162                                if let Some(s) = text_insert_from_keystroke(&event.keystroke) {
2163                                    tab.url_insert_at_cursor(&s);
2164                                    cx.notify();
2165                                }
2166                            },
2167                        ))
2168                        .on_mouse_down(
2169                            MouseButton::Left,
2170                            cx.listener(move |this, event: &MouseDownEvent, window, cx| {
2171                                if !this.url_focus.is_focused(window) {
2172                                    this.tabs[this.active_tab].url_select_all();
2173                                    cx.notify();
2174                                    return;
2175                                }
2176                                let tab = &mut this.tabs[this.active_tab];
2177                                let bounds = *tab.url_text_bounds.lock().unwrap();
2178                                let rel_x =
2179                                    f32::from(event.position.x) - f32::from(bounds.origin.x);
2180                                let text = SharedString::from(tab.url_input.clone());
2181                                if text.is_empty() {
2182                                    tab.url_move_to(0);
2183                                } else {
2184                                    let run = TextRun {
2185                                        len: text.len(),
2186                                        font: font(".SystemUIFont"),
2187                                        color: rgba8(0xdc, 0xdc, 0xe6, 0xff),
2188                                        background_color: None,
2189                                        underline: None,
2190                                        strikethrough: None,
2191                                    };
2192                                    let line = window.text_system().shape_line(
2193                                        text,
2194                                        px(14.0),
2195                                        &[run],
2196                                        None,
2197                                    );
2198                                    let idx = line.closest_index_for_x(px(rel_x));
2199                                    if event.modifiers.shift {
2200                                        tab.url_select_to(idx);
2201                                    } else {
2202                                        tab.url_move_to(idx);
2203                                        tab.url_selecting = true;
2204                                    }
2205                                }
2206                                cx.notify();
2207                            }),
2208                        )
2209                        .on_mouse_up(
2210                            MouseButton::Left,
2211                            cx.listener(|this, _: &MouseUpEvent, _, _cx| {
2212                                this.tabs[this.active_tab].url_selecting = false;
2213                            }),
2214                        )
2215                        .on_mouse_move(cx.listener(
2216                            move |this, event: &gpui::MouseMoveEvent, window, _cx| {
2217                                let tab = &mut this.tabs[this.active_tab];
2218                                if !tab.url_selecting {
2219                                    return;
2220                                }
2221                                let bounds = *tab.url_text_bounds.lock().unwrap();
2222                                let rel_x =
2223                                    f32::from(event.position.x) - f32::from(bounds.origin.x);
2224                                let text = SharedString::from(tab.url_input.clone());
2225                                if text.is_empty() {
2226                                    return;
2227                                }
2228                                let run = TextRun {
2229                                    len: text.len(),
2230                                    font: font(".SystemUIFont"),
2231                                    color: rgba8(0xdc, 0xdc, 0xe6, 0xff),
2232                                    background_color: None,
2233                                    underline: None,
2234                                    strikethrough: None,
2235                                };
2236                                let line =
2237                                    window
2238                                        .text_system()
2239                                        .shape_line(text, px(14.0), &[run], None);
2240                                let idx = line.closest_index_for_x(px(rel_x));
2241                                tab.url_select_to(idx);
2242                                _cx.notify();
2243                            },
2244                        ))
2245                        .child({
2246                            let url_bounds_store = url_bounds_ref.clone();
2247                            canvas(
2248                                {
2249                                    let text = url_text_for_canvas.clone();
2250                                    let bounds_store = url_bounds_store.clone();
2251                                    move |bounds, window, _cx| {
2252                                        *bounds_store.lock().unwrap() = bounds;
2253                                        if text.is_empty() {
2254                                            return None;
2255                                        }
2256                                        let run = TextRun {
2257                                            len: text.len(),
2258                                            font: font(".SystemUIFont"),
2259                                            color: rgba8(0xdc, 0xdc, 0xe6, 0xff),
2260                                            background_color: None,
2261                                            underline: None,
2262                                            strikethrough: None,
2263                                        };
2264                                        Some(window.text_system().shape_line(
2265                                            text.clone(),
2266                                            px(14.0),
2267                                            &[run],
2268                                            None,
2269                                        ))
2270                                    }
2271                                },
2272                                {
2273                                    let focused = url_focused;
2274                                    let blink = caret_blink_on;
2275                                    move |bounds, line_opt: Option<gpui::ShapedLine>, window, cx| {
2276                                        let has_sel = url_cursor != url_sel_start;
2277                                        let sel_lo = url_cursor.min(url_sel_start);
2278                                        let sel_hi = url_cursor.max(url_sel_start);
2279
2280                                        if let Some(ref line) = line_opt {
2281                                            if has_sel {
2282                                                let sx = line.x_for_index(sel_lo);
2283                                                let ex = line.x_for_index(sel_hi);
2284                                                let sel_bounds = Bounds::from_corners(
2285                                                    point(bounds.origin.x + sx, bounds.origin.y),
2286                                                    point(
2287                                                        bounds.origin.x + ex,
2288                                                        bounds.origin.y + bounds.size.height,
2289                                                    ),
2290                                                );
2291                                                window.paint_quad(gpui::fill(
2292                                                    sel_bounds,
2293                                                    theme::selection(),
2294                                                ));
2295                                            }
2296
2297                                            let _ = line.paint(
2298                                                bounds.origin,
2299                                                bounds.size.height,
2300                                                window,
2301                                                cx,
2302                                            );
2303
2304                                            if focused && !has_sel && blink {
2305                                                let cx_pos = line.x_for_index(url_cursor);
2306                                                let cursor_bounds = Bounds::from_corners(
2307                                                    point(
2308                                                        bounds.origin.x + cx_pos,
2309                                                        bounds.origin.y,
2310                                                    ),
2311                                                    point(
2312                                                        bounds.origin.x + cx_pos + px(2.0),
2313                                                        bounds.origin.y + bounds.size.height,
2314                                                    ),
2315                                                );
2316                                                window.paint_quad(gpui::fill(
2317                                                    cursor_bounds,
2318                                                    rgba8(0xe8, 0xe8, 0xf0, 0xff),
2319                                                ));
2320                                            }
2321                                        } else if focused && blink {
2322                                            let cursor_bounds = Bounds::from_corners(
2323                                                bounds.origin,
2324                                                point(
2325                                                    bounds.origin.x + px(2.0),
2326                                                    bounds.origin.y + bounds.size.height,
2327                                                ),
2328                                            );
2329                                            window.paint_quad(gpui::fill(
2330                                                cursor_bounds,
2331                                                rgba8(0xe8, 0xe8, 0xf0, 0xff),
2332                                            ));
2333                                        }
2334                                    }
2335                                },
2336                            )
2337                            .flex_1()
2338                            .h(px(16.0))
2339                        })
2340                })
2341                .child(
2342                    div()
2343                        .id("oxide_bookmark")
2344                        .cursor_pointer()
2345                        .text_lg()
2346                        .text_color(if is_bookmarked {
2347                            gpui::rgb(0xffc832)
2348                        } else {
2349                            gpui::rgb(0xa0a0a8)
2350                        })
2351                        .child(if is_bookmarked { "★" } else { "☆" })
2352                        .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
2353                            this.toggle_active_bookmark();
2354                            cx.notify();
2355                        })),
2356                )
2357                .child(
2358                    div()
2359                        .id("oxide_open_file")
2360                        .cursor_pointer()
2361                        .text_sm()
2362                        .text_color(gpui::rgb(0xc8c8d4))
2363                        .child("Open")
2364                        .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
2365                            if this.file_pick_rx.is_some() {
2366                                return;
2367                            }
2368                            let (tx, rx) = mpsc::channel();
2369                            this.file_pick_rx = Some(rx);
2370                            std::thread::spawn(move || {
2371                                let path = rfd::FileDialog::new()
2372                                    .add_filter("WebAssembly", &["wasm"])
2373                                    .set_title("Open .wasm Application")
2374                                    .pick_file();
2375                                let msg = match path {
2376                                    Some(p) => match std::fs::read(&p) {
2377                                        Ok(bytes) => FilePickDone::Chosen { path: p, bytes },
2378                                        Err(_) => FilePickDone::Cancelled,
2379                                    },
2380                                    None => FilePickDone::Cancelled,
2381                                };
2382                                let _ = tx.send(msg);
2383                            });
2384                            cx.notify();
2385                        })),
2386                )
2387                .child({
2388                    let has_active_dl = self.download_manager.has_active();
2389                    let dl_count = self.download_manager.downloads().lock().unwrap().len();
2390                    div()
2391                        .id("oxide_downloads_btn")
2392                        .cursor_pointer()
2393                        .w(px(28.0))
2394                        .h(px(28.0))
2395                        .flex()
2396                        .items_center()
2397                        .justify_center()
2398                        .rounded_md()
2399                        .hover(|s| s.bg(gpui::rgb(0x373741)))
2400                        .text_color(if has_active_dl {
2401                            gpui::rgb(0x50b0e0)
2402                        } else if dl_count > 0 {
2403                            gpui::rgb(0xc8c8d4)
2404                        } else {
2405                            gpui::rgb(0x60606a)
2406                        })
2407                        .child("⬇")
2408                        .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
2409                            this.show_downloads = !this.show_downloads;
2410                            cx.notify();
2411                        }))
2412                })
2413                .child(
2414                    div()
2415                        .id("oxide_menu_btn")
2416                        .relative()
2417                        .cursor_pointer()
2418                        .w(px(28.0))
2419                        .h(px(28.0))
2420                        .flex()
2421                        .items_center()
2422                        .justify_center()
2423                        .rounded_md()
2424                        .hover(|s| s.bg(gpui::rgb(0x373741)))
2425                        .text_color(gpui::rgb(0xc8c8d4))
2426                        .child("⋮")
2427                        .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
2428                            this.show_menu = !this.show_menu;
2429                            cx.notify();
2430                        })),
2431                ),
2432        );
2433
2434        // Main row: optional bookmarks + content
2435        let mut main_row = div().flex_1().flex().flex_row().min_h_0();
2436
2437        if self.show_bookmarks {
2438            if let Some(store) = &self.bookmark_store {
2439                let items = store.list_all();
2440                main_row = main_row.child(
2441                    div()
2442                        .id("oxide_bookmarks_panel")
2443                        .w(px(260.0))
2444                        .h_full()
2445                        .overflow_scroll()
2446                        .border_r_1()
2447                        .border_color(gpui::rgb(0x2a2a32))
2448                        .p_2()
2449                        .children(items.iter().enumerate().map(|(bi, bm)| {
2450                            let url = bm.url.clone();
2451                            let label = if bm.title.is_empty() {
2452                                url_to_title(&bm.url)
2453                            } else {
2454                                bm.title.clone()
2455                            };
2456                            div()
2457                                .id(("oxide_bm", bi))
2458                                .py_1()
2459                                .cursor_pointer()
2460                                .text_sm()
2461                                .text_color(gpui::rgb(0xaab4ff))
2462                                .child(truncate_tab_title(&label))
2463                                .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
2464                                    this.tabs[this.active_tab].navigate_to(
2465                                        url.clone(),
2466                                        true,
2467                                        &this.download_manager,
2468                                    );
2469                                    this.show_downloads =
2470                                        this.download_manager.has_active() || this.show_downloads;
2471                                    cx.notify();
2472                                }))
2473                        })),
2474                );
2475            }
2476        }
2477
2478        let mut content_col = div().flex_1().flex().flex_col().min_h_0();
2479
2480        if let Some(ref page) = self.tabs[active].internal_page {
2481            match page {
2482                InternalPage::Home => {
2483                    content_col = content_col.child(
2484                        div()
2485                            .id("oxide_home_page")
2486                            .flex_1()
2487                            .flex()
2488                            .items_center()
2489                            .justify_center()
2490                            .p_4()
2491                            .child(
2492                                div()
2493                                    .w(px(560.0))
2494                                    .p_5()
2495                                    .rounded_lg()
2496                                    .bg(gpui::rgb(0x222228))
2497                                    .border_1()
2498                                    .border_color(gpui::rgb(0x3a3a44))
2499                                    .child(
2500                                        div()
2501                                            .text_xl()
2502                                            .font_weight(gpui::FontWeight::BOLD)
2503                                            .text_color(gpui::rgb(0x80d8d0))
2504                                            .child("Oxide Browser"),
2505                                    )
2506                                    .child(
2507                                        div()
2508                                            .mt_2()
2509                                            .text_sm()
2510                                            .text_color(gpui::rgb(0xc8c8d4))
2511                                            .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."),
2512                                    )
2513                                    .child(
2514                                        div()
2515                                            .mt_4()
2516                                            .flex()
2517                                            .flex_col()
2518                                            .gap_2()
2519                                            .text_xs()
2520                                            .text_color(gpui::rgb(0xa6a6b8))
2521                                            .child("Open a local .wasm file, enter an HTTP(S) .wasm URL, or build a new app with Forge.")
2522                                            .child("Guest apps start with no filesystem, environment, or socket access. Every host interaction goes through explicit Oxide capabilities."),
2523                                    )
2524                                    .child(
2525                                        div()
2526                                            .mt_4()
2527                                            .h(px(1.0))
2528                                            .bg(gpui::rgb(0x3a3a44)),
2529                                    )
2530                                    .child(
2531                                        div()
2532                                            .mt_4()
2533                                            .flex()
2534                                            .flex_row()
2535                                            .items_center()
2536                                            .justify_between()
2537                                            .gap_3()
2538                                            .child(
2539                                                div()
2540                                                    .flex_1()
2541                                                    .min_w_0()
2542                                                    .child(
2543                                                        div()
2544                                                            .text_sm()
2545                                                            .font_weight(gpui::FontWeight::SEMIBOLD)
2546                                                            .text_color(gpui::rgb(0xe8e8f4))
2547                                                            .child("Create with Oxide Forge"),
2548                                                    )
2549                                                    .child(
2550                                                        div()
2551                                                            .mt_1()
2552                                                            .text_xs()
2553                                                            .text_color(gpui::rgb(0x8a8aa0))
2554                                                            .child("Describe an app and Forge will generate, build, and hot-load a sandboxed guest WASM module."),
2555                                                    ),
2556                                            )
2557                                            .child(
2558                                                div()
2559                                                    .id("oxide_home_forge")
2560                                                    .px_3()
2561                                                    .py_2()
2562                                                    .rounded_md()
2563                                                    .bg(gpui::rgb(0x2f6f68))
2564                                                    .text_sm()
2565                                                    .text_color(gpui::rgb(0xffffff))
2566                                                    .cursor_pointer()
2567                                                    .child("oxide://forge")
2568                                                    .on_click(cx.listener(
2569                                                        |this, _: &ClickEvent, _, cx| {
2570                                                            this.tabs[this.active_tab].navigate_to(
2571                                                                "oxide://forge".to_string(),
2572                                                                true,
2573                                                                &this.download_manager,
2574                                                            );
2575                                                            cx.notify();
2576                                                        },
2577                                                    )),
2578                                            ),
2579                                    ),
2580                            ),
2581                    );
2582                }
2583                InternalPage::History => {
2584                    let all_entries: Vec<(Vec<u8>, String, String, u64)> = self
2585                        .history_store
2586                        .as_ref()
2587                        .map(|store| {
2588                            store
2589                                .list_all()
2590                                .into_iter()
2591                                .map(|(key, item)| (key, item.url, item.title, item.visited_at_ms))
2592                                .collect()
2593                        })
2594                        .unwrap_or_default();
2595                    let has_entries = !all_entries.is_empty();
2596
2597                    content_col = content_col.child(
2598                        div()
2599                            .id("oxide_history_page")
2600                            .flex_1()
2601                            .overflow_scroll()
2602                            .p_4()
2603                            .child(
2604                                div()
2605                                    .flex()
2606                                    .flex_row()
2607                                    .items_center()
2608                                    .justify_between()
2609                                    .child(
2610                                        div()
2611                                            .child(
2612                                                div()
2613                                                    .text_lg()
2614                                                    .font_weight(gpui::FontWeight::BOLD)
2615                                                    .text_color(gpui::rgb(0xb478ff))
2616                                                    .child("History"),
2617                                            )
2618                                            .child(
2619                                                div()
2620                                                    .mt_1()
2621                                                    .text_xs()
2622                                                    .text_color(gpui::rgb(0x7a7a90))
2623                                                    .child(format!(
2624                                                        "{} visited page{}",
2625                                                        all_entries.len(),
2626                                                        if all_entries.len() == 1 {
2627                                                            ""
2628                                                        } else {
2629                                                            "s"
2630                                                        }
2631                                                    )),
2632                                            ),
2633                                    )
2634                                    .when(has_entries, |d| {
2635                                        d.child(
2636                                            div()
2637                                                .id("oxide_hist_clear_all")
2638                                                .flex()
2639                                                .flex_row()
2640                                                .items_center()
2641                                                .gap_1()
2642                                                .px_3()
2643                                                .py(px(6.0))
2644                                                .rounded_md()
2645                                                .cursor_pointer()
2646                                                .bg(gpui::rgb(0x2a2a34))
2647                                                .hover(|s| s.bg(gpui::rgb(0x3a2a2a)))
2648                                                .text_xs()
2649                                                .text_color(gpui::rgb(0xf05050))
2650                                                .child("🗑")
2651                                                .child("Clear All")
2652                                                .on_click(cx.listener(
2653                                                    |this, _: &ClickEvent, _, cx| {
2654                                                        if let Some(store) = &this.history_store {
2655                                                            let _ = store.clear();
2656                                                        }
2657                                                        cx.notify();
2658                                                    },
2659                                                )),
2660                                        )
2661                                    }),
2662                            )
2663                            .child(div().mt_3().h(px(1.0)).bg(gpui::rgb(0x2a2a32)))
2664                            .when(!has_entries, |d| {
2665                                d.child(
2666                                    div()
2667                                        .mt_4()
2668                                        .text_sm()
2669                                        .text_color(gpui::rgb(0x7a7a90))
2670                                        .child(
2671                                            "No history yet. Navigate to a page to see it here.",
2672                                        ),
2673                                )
2674                            })
2675                            .children(all_entries.into_iter().enumerate().map(
2676                                |(i, (key, url, title, ts))| {
2677                                    let url_nav = url.clone();
2678                                    let key_for_delete = key.clone();
2679                                    let display_title = if title.is_empty() {
2680                                        url_to_title(&url)
2681                                    } else {
2682                                        title
2683                                    };
2684                                    let friendly = format_friendly_timestamp(ts);
2685                                    div()
2686                                        .id(("oxide_hist", i))
2687                                        .flex()
2688                                        .flex_row()
2689                                        .items_center()
2690                                        .justify_between()
2691                                        .py_2()
2692                                        .px_2()
2693                                        .rounded_md()
2694                                        .hover(|s| s.bg(gpui::rgb(0x2a2a34)))
2695                                        .border_b_1()
2696                                        .border_color(gpui::rgb(0x222230))
2697                                        .child(
2698                                            div()
2699                                                .id(("oxide_hist_link", i))
2700                                                .flex_1()
2701                                                .min_w_0()
2702                                                .overflow_hidden()
2703                                                .cursor_pointer()
2704                                                .child(
2705                                                    div()
2706                                                        .text_sm()
2707                                                        .text_color(gpui::rgb(0xaab4ff))
2708                                                        .child(display_title),
2709                                                )
2710                                                .child(
2711                                                    div()
2712                                                        .text_xs()
2713                                                        .text_color(gpui::rgb(0x6a6a80))
2714                                                        .mt(px(2.0))
2715                                                        .child(url.clone()),
2716                                                )
2717                                                .on_click(cx.listener(
2718                                                    move |this, _: &ClickEvent, _, cx| {
2719                                                        this.tabs[this.active_tab].navigate_to(
2720                                                            url_nav.clone(),
2721                                                            true,
2722                                                            &this.download_manager,
2723                                                        );
2724                                                        this.show_downloads =
2725                                                            this.download_manager.has_active()
2726                                                                || this.show_downloads;
2727                                                        cx.notify();
2728                                                    },
2729                                                )),
2730                                        )
2731                                        .child(
2732                                            div()
2733                                                .flex_shrink_0()
2734                                                .ml_3()
2735                                                .text_xs()
2736                                                .text_color(gpui::rgb(0x7a7a90))
2737                                                .child(friendly),
2738                                        )
2739                                        .child(
2740                                            div()
2741                                                .id(("oxide_hist_del", i))
2742                                                .flex_shrink_0()
2743                                                .ml_2()
2744                                                .w(px(24.0))
2745                                                .h(px(24.0))
2746                                                .flex()
2747                                                .items_center()
2748                                                .justify_center()
2749                                                .rounded_sm()
2750                                                .cursor_pointer()
2751                                                .hover(|s| s.bg(gpui::rgb(0x3a2a2a)))
2752                                                .text_xs()
2753                                                .text_color(gpui::rgb(0x9696a0))
2754                                                .child("🗑")
2755                                                .on_click(cx.listener(
2756                                                    move |this, _: &ClickEvent, _, cx| {
2757                                                        if let Some(store) = &this.history_store {
2758                                                            let _ = store
2759                                                                .remove_by_key(&key_for_delete);
2760                                                        }
2761                                                        cx.notify();
2762                                                    },
2763                                                )),
2764                                        )
2765                                },
2766                            )),
2767                    );
2768                }
2769                InternalPage::Bookmarks => {
2770                    let items = self
2771                        .bookmark_store
2772                        .as_ref()
2773                        .map(|s| s.list_all())
2774                        .unwrap_or_default();
2775
2776                    content_col = content_col.child(
2777                        div()
2778                            .id("oxide_bookmarks_page")
2779                            .flex_1()
2780                            .overflow_scroll()
2781                            .p_4()
2782                            .child(
2783                                div()
2784                                    .text_lg()
2785                                    .font_weight(gpui::FontWeight::BOLD)
2786                                    .text_color(gpui::rgb(0xb478ff))
2787                                    .child("Bookmarks"),
2788                            )
2789                            .child(
2790                                div()
2791                                    .mt_1()
2792                                    .text_xs()
2793                                    .text_color(gpui::rgb(0x7a7a90))
2794                                    .child(format!(
2795                                        "{} bookmark{}",
2796                                        items.len(),
2797                                        if items.len() == 1 { "" } else { "s" }
2798                                    )),
2799                            )
2800                            .child(
2801                                div()
2802                                    .mt_3()
2803                                    .h(px(1.0))
2804                                    .bg(gpui::rgb(0x2a2a32)),
2805                            )
2806                            .when(items.is_empty(), |d| {
2807                                d.child(
2808                                    div()
2809                                        .mt_4()
2810                                        .text_sm()
2811                                        .text_color(gpui::rgb(0x7a7a90))
2812                                        .child("No bookmarks yet. Press ☆ in the toolbar to bookmark a page."),
2813                                )
2814                            })
2815                            .children(items.into_iter().enumerate().map(|(i, bm)| {
2816                                let url = bm.url.clone();
2817                                let url_nav = bm.url.clone();
2818                                let label = if bm.title.is_empty() {
2819                                    url_to_title(&bm.url)
2820                                } else {
2821                                    bm.title.clone()
2822                                };
2823                                div()
2824                                    .id(("oxide_bmp", i))
2825                                    .flex()
2826                                    .flex_row()
2827                                    .items_center()
2828                                    .py_2()
2829                                    .px_2()
2830                                    .rounded_md()
2831                                    .cursor_pointer()
2832                                    .hover(|s| s.bg(gpui::rgb(0x2a2a34)))
2833                                    .border_b_1()
2834                                    .border_color(gpui::rgb(0x222230))
2835                                    .child(
2836                                        div()
2837                                            .flex_1()
2838                                            .min_w_0()
2839                                            .overflow_hidden()
2840                                            .child(
2841                                                div()
2842                                                    .text_sm()
2843                                                    .text_color(gpui::rgb(0xaab4ff))
2844                                                    .child(label),
2845                                            )
2846                                            .child(
2847                                                div()
2848                                                    .text_xs()
2849                                                    .text_color(gpui::rgb(0x6a6a80))
2850                                                    .mt(px(2.0))
2851                                                    .child(url),
2852                                            ),
2853                                    )
2854                                    .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
2855                                        this.tabs[this.active_tab]
2856                                            .navigate_to(url_nav.clone(), true, &this.download_manager);
2857                                        this.show_downloads = this.download_manager.has_active() || this.show_downloads;
2858                                        cx.notify();
2859                                    }))
2860                            })),
2861                    );
2862                }
2863                InternalPage::About => {
2864                    content_col = content_col.child(
2865                        div()
2866                            .flex_1()
2867                            .flex()
2868                            .items_center()
2869                            .justify_center()
2870                            .p_4()
2871                            .child(
2872                                div()
2873                                    .w(px(480.0))
2874                                    .p_5()
2875                                    .rounded_lg()
2876                                    .bg(gpui::rgb(0x222228))
2877                                    .border_1()
2878                                    .border_color(gpui::rgb(0x3a3a44))
2879                                    .child(
2880                                        div()
2881                                            .text_xl()
2882                                            .font_weight(gpui::FontWeight::BOLD)
2883                                            .text_color(gpui::rgb(0xb478ff))
2884                                            .child("Oxide Browser"),
2885                                    )
2886                                    .child(
2887                                        div()
2888                                            .mt_1()
2889                                            .text_sm()
2890                                            .text_color(gpui::rgb(0x8888a0))
2891                                            .child(format!(
2892                                                "Version {}",
2893                                                env!("CARGO_PKG_VERSION")
2894                                            )),
2895                                    )
2896                                    .child(
2897                                        div()
2898                                            .mt_3()
2899                                            .h(px(1.0))
2900                                            .bg(gpui::rgb(0x3a3a44)),
2901                                    )
2902                                    .child(
2903                                        div()
2904                                            .mt_3()
2905                                            .text_sm()
2906                                            .text_color(gpui::rgb(0xc0c0cc))
2907                                            .child("A binary-first browser that fetches and runs .wasm modules in a secure sandbox, powered by a GPU-accelerated native UI."),
2908                                    )
2909                                    .child(
2910                                        div()
2911                                            .mt_3()
2912                                            .flex()
2913                                            .flex_col()
2914                                            .gap_1()
2915                                            .text_xs()
2916                                            .text_color(gpui::rgb(0x9696a0))
2917                                            .child(
2918                                                div().flex().flex_row().gap_2()
2919                                                    .child(div().w(px(70.0)).text_color(gpui::rgb(0x7a7a90)).child("Engine"))
2920                                                    .child(div().child("Wasmtime sandbox")),
2921                                            )
2922                                            .child(
2923                                                div().flex().flex_row().gap_2()
2924                                                    .child(div().w(px(70.0)).text_color(gpui::rgb(0x7a7a90)).child("UI"))
2925                                                    .child(div().child("GPUI (Zed's GPU-accelerated framework)")),
2926                                            )
2927                                            .child(
2928                                                div().flex().flex_row().gap_2()
2929                                                    .child(div().w(px(70.0)).text_color(gpui::rgb(0x7a7a90)).child("Graphics"))
2930                                                    .child(div().child("Metal / wgpu")),
2931                                            )
2932                                            .child(
2933                                                div().flex().flex_row().gap_2()
2934                                                    .child(div().w(px(70.0)).text_color(gpui::rgb(0x7a7a90)).child("License"))
2935                                                    .child(div().child("MIT")),
2936                                            ),
2937                                    )
2938                                    .child(
2939                                        div()
2940                                            .mt_3()
2941                                            .text_xs()
2942                                            .text_color(gpui::rgb(0x6a6a80))
2943                                            .child("github.com/niklabh/oxide"),
2944                                    ),
2945                            ),
2946                    );
2947                }
2948                InternalPage::Forge => {
2949                    let forge_ready = self.ensure_forge();
2950                    let snapshot = self.forge_current_snapshot();
2951                    let creations = self.forge_creations();
2952                    let output_dir = self
2953                        .forge_output_dir()
2954                        .map(|p| p.display().to_string())
2955                        .unwrap_or_else(|| "(not configured)".to_string());
2956                    let prompt_draft = self.tabs[active].forge_prompt.clone();
2957                    let prompt_focused = self.forge_focus.is_focused(window);
2958                    let settings_open = self.forge_config.settings_open;
2959                    let active_provider = self.forge_config.active_provider;
2960                    let active_model = self.forge_config.active_model();
2961                    let settings_provider = self.forge_settings_provider;
2962                    let settings_key_focused = self.forge_settings_key_focus.is_focused(window);
2963                    let settings_model_focused = self.forge_settings_model_focus.is_focused(window);
2964                    let settings_key_draft = self.forge_settings_key_draft.clone();
2965                    let settings_model_draft = self.forge_settings_model_draft.clone();
2966                    let saved_key = self.forge_config.provider(settings_provider).api_key;
2967                    let settings_key_hint = if settings_key_draft.is_empty() {
2968                        if saved_key.is_empty() {
2969                            format!("Paste {} API key", settings_provider.label())
2970                        } else {
2971                            mask_api_key(&saved_key)
2972                        }
2973                    } else {
2974                        "•".repeat(settings_key_draft.chars().count().min(28))
2975                    };
2976                    let settings_save_enabled = !settings_key_draft.trim().is_empty()
2977                        || !settings_model_draft.trim().is_empty()
2978                        || !saved_key.is_empty();
2979
2980                    let (status_word, status_color, status_hint) = if !forge_ready {
2981                        (
2982                            "Configure AI".to_string(),
2983                            gpui::rgb(0xf08050),
2984                            "Open Settings and add an API key for your preferred provider."
2985                                .to_string(),
2986                        )
2987                    } else {
2988                        (
2989                            "ready".to_string(),
2990                            gpui::rgb(0x80d090),
2991                            format!(
2992                                "Using {} · {}. Chat to generate sandboxed guest WASM apps. Output: {output_dir}",
2993                                active_provider.label(),
2994                                active_model
2995                            ),
2996                        )
2997                    };
2998
2999                    let phase = snapshot.as_ref().map(|s| s.phase);
3000                    let can_build = matches!(
3001                        phase,
3002                        Some(ForgePhase::StreamComplete)
3003                            | Some(ForgePhase::Error)
3004                            | Some(ForgePhase::BuildOk),
3005                    );
3006                    let can_run = matches!(phase, Some(ForgePhase::BuildOk));
3007                    let can_delete = snapshot
3008                        .as_ref()
3009                        .map(|s| !matches!(s.phase, ForgePhase::Streaming | ForgePhase::Building))
3010                        .unwrap_or(false);
3011
3012                    let caret = if prompt_focused && caret_blink_on {
3013                        "\u{2588}"
3014                    } else {
3015                        ""
3016                    };
3017                    let prompt_display = if prompt_draft.is_empty() {
3018                        "Describe an app…".to_string()
3019                    } else {
3020                        prompt_draft.clone()
3021                    };
3022                    let prompt_color = if prompt_draft.is_empty() {
3023                        gpui::rgb(0x5a5a6a)
3024                    } else {
3025                        gpui::rgb(0xe0e0ff)
3026                    };
3027
3028                    let submit_enabled = forge_ready && !prompt_draft.trim().is_empty();
3029                    let submit_label = if self.tabs[active].forge_session_id.is_some() {
3030                        "Apply changes"
3031                    } else {
3032                        "Create app"
3033                    };
3034
3035                    let selected_id = self.tabs[active].forge_session_id;
3036
3037                    let list_panel = div()
3038                        .id("oxide_forge_list")
3039                        .w(px(260.0))
3040                        .h_full()
3041                        .flex()
3042                        .flex_col()
3043                        .min_h_0()
3044                        .border_r_1()
3045                        .border_color(gpui::rgb(0x2a2a32))
3046                        .pr_3()
3047                        .child(
3048                            div()
3049                                .flex()
3050                                .flex_row()
3051                                .items_center()
3052                                .justify_between()
3053                                .child(
3054                                    div()
3055                                        .text_sm()
3056                                        .font_weight(gpui::FontWeight::BOLD)
3057                                        .text_color(gpui::rgb(0xe8e8f4))
3058                                        .child("Creations"),
3059                                )
3060                                .child(
3061                                    div()
3062                                        .id("oxide_forge_new_btn")
3063                                        .px_2()
3064                                        .py(px(5.0))
3065                                        .rounded_sm()
3066                                        .bg(gpui::rgb(0x2a2a36))
3067                                        .text_xs()
3068                                        .text_color(gpui::rgb(0xc8c8d4))
3069                                        .cursor_pointer()
3070                                        .child("New")
3071                                        .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
3072                                            let tab = &mut this.tabs[this.active_tab];
3073                                            tab.forge_session_id = None;
3074                                            tab.forge_prompt.clear();
3075                                            cx.notify();
3076                                        })),
3077                                ),
3078                        )
3079                        .child(
3080                            div()
3081                                .mt_2()
3082                                .text_xs()
3083                                .text_color(gpui::rgb(0x7a7a90))
3084                                .child(format!(
3085                                    "{} app{}",
3086                                    creations.len(),
3087                                    if creations.len() == 1 { "" } else { "s" }
3088                                )),
3089                        )
3090                        .child(
3091                            div()
3092                                .id("oxide_forge_creation_scroll")
3093                                .mt_3()
3094                                .flex_1()
3095                                .min_h_0()
3096                                .overflow_scroll()
3097                                .children(creations.iter().enumerate().map(|(i, item)| {
3098                                    let id = item.id;
3099                                    let selected = Some(id) == selected_id;
3100                                    let title = if item.prompt.trim().is_empty() {
3101                                        item.slug.clone()
3102                                    } else {
3103                                        truncate_tab_title(&item.prompt)
3104                                    };
3105                                    let path = item.project_dir.display().to_string();
3106                                    div()
3107                                        .id(("oxide_forge_creation", i))
3108                                        .mb_2()
3109                                        .p_2()
3110                                        .rounded_md()
3111                                        .cursor_pointer()
3112                                        .bg(if selected {
3113                                            gpui::rgb(0x2f2944)
3114                                        } else {
3115                                            gpui::rgb(0x202028)
3116                                        })
3117                                        .border_1()
3118                                        .border_color(if selected {
3119                                            gpui::rgb(0x7a5ae0)
3120                                        } else {
3121                                            gpui::rgb(0x2e2e38)
3122                                        })
3123                                        .child(
3124                                            div()
3125                                                .flex()
3126                                                .flex_row()
3127                                                .items_center()
3128                                                .justify_between()
3129                                                .gap_2()
3130                                                .child(
3131                                                    div()
3132                                                        .flex_1()
3133                                                        .min_w_0()
3134                                                        .text_sm()
3135                                                        .text_color(gpui::rgb(0xe4e4f0))
3136                                                        .child(title),
3137                                                )
3138                                                .child(
3139                                                    div()
3140                                                        .text_xs()
3141                                                        .text_color(phase_color_for(item.phase))
3142                                                        .child(phase_label(item.phase)),
3143                                                ),
3144                                        )
3145                                        .child(
3146                                            div()
3147                                                .mt_1()
3148                                                .text_xs()
3149                                                .text_color(gpui::rgb(0x747488))
3150                                                .child(item.slug.clone()),
3151                                        )
3152                                        .child(
3153                                            div()
3154                                                .mt_1()
3155                                                .text_xs()
3156                                                .text_color(gpui::rgb(0x5f5f72))
3157                                                .child(path),
3158                                        )
3159                                        .on_click(cx.listener(
3160                                            move |this, _: &ClickEvent, _, cx| {
3161                                                this.tabs[this.active_tab].forge_session_id =
3162                                                    Some(id);
3163                                                this.tabs[this.active_tab].forge_prompt.clear();
3164                                                cx.notify();
3165                                            },
3166                                        ))
3167                                })),
3168                        );
3169
3170                    let mut chat_panel = div()
3171                        .id("oxide_forge_chat")
3172                        .flex_1()
3173                        .min_h_0()
3174                        .flex()
3175                        .flex_col()
3176                        .rounded_md()
3177                        .bg(gpui::rgb(0x181820))
3178                        .border_1()
3179                        .border_color(gpui::rgb(0x2a2a34));
3180
3181                    let mut code_panel = div()
3182                        .id("oxide_forge_code_panel")
3183                        .w(px(340.0))
3184                        .min_h_0()
3185                        .flex()
3186                        .flex_col()
3187                        .rounded_md()
3188                        .bg(gpui::rgb(0x15151c))
3189                        .border_1()
3190                        .border_color(gpui::rgb(0x2a2a34));
3191
3192                    if let Some(snap) = snapshot.clone() {
3193                        let phase_text = if snap.retries_used > 0 {
3194                            format!(
3195                                "{} (auto-fix {}/{})",
3196                                phase_label(snap.phase),
3197                                snap.retries_used,
3198                                snap.max_retries
3199                            )
3200                        } else {
3201                            phase_label(snap.phase).to_string()
3202                        };
3203                        let phase_color = phase_color_for(snap.phase);
3204                        let prompt_preview = truncate_tab_title(&snap.prompt);
3205                        let code_text = snap.code.clone();
3206                        let build_log = snap.build_log.clone();
3207                        let err = snap.error.clone();
3208                        let artifact = snap
3209                            .artifact_path
3210                            .as_ref()
3211                            .map(|p| p.display().to_string())
3212                            .unwrap_or_else(|| "No wasm artifact yet".to_string());
3213
3214                        chat_panel = chat_panel
3215                            .child(
3216                                div()
3217                                    .px_3()
3218                                    .py_2()
3219                                    .border_b_1()
3220                                    .border_color(gpui::rgb(0x2a2a34))
3221                                    .flex()
3222                                    .flex_row()
3223                                    .items_center()
3224                                    .justify_between()
3225                                    .child(
3226                                        div()
3227                                            .child(
3228                                                div()
3229                                                    .text_sm()
3230                                                    .font_weight(gpui::FontWeight::BOLD)
3231                                                    .text_color(gpui::rgb(0xe8e8f4))
3232                                                    .child(prompt_preview),
3233                                            )
3234                                            .child(
3235                                                div()
3236                                                    .mt_1()
3237                                                    .text_xs()
3238                                                    .text_color(gpui::rgb(0x7a7a90))
3239                                                    .child(format!(
3240                                                        "{} · {} · {}",
3241                                                        snap.slug,
3242                                                        snap.provider.label(),
3243                                                        snap.model
3244                                                    )),
3245                                            ),
3246                                    )
3247                                    .child(
3248                                        div()
3249                                            .px_2()
3250                                            .py(px(2.0))
3251                                            .rounded_sm()
3252                                            .bg(gpui::rgb(0x22222c))
3253                                            .text_xs()
3254                                            .text_color(phase_color)
3255                                            .child(phase_text),
3256                                    ),
3257                            )
3258                            .child(
3259                                div()
3260                                    .id("oxide_forge_messages")
3261                                    .flex_1()
3262                                    .min_h_0()
3263                                    .overflow_scroll()
3264                                    .p_3()
3265                                    .flex()
3266                                    .flex_col()
3267                                    .gap_2()
3268                                    .children(
3269                                        snap.messages
3270                                            .iter()
3271                                            .enumerate()
3272                                            .map(|(i, msg)| forge_chat_bubble(i, msg, snap.phase)),
3273                                    ),
3274                            );
3275
3276                        code_panel = code_panel
3277                            .child(
3278                                div()
3279                                    .px_3()
3280                                    .py_2()
3281                                    .border_b_1()
3282                                    .border_color(gpui::rgb(0x2a2a34))
3283                                    .text_xs()
3284                                    .font_weight(gpui::FontWeight::BOLD)
3285                                    .text_color(gpui::rgb(0xc0c0d8))
3286                                    .child("Generated code"),
3287                            )
3288                            .child(
3289                                div()
3290                                    .id("oxide_forge_code")
3291                                    .flex_1()
3292                                    .min_h_0()
3293                                    .overflow_scroll()
3294                                    .px_3()
3295                                    .py_2()
3296                                    .text_xs()
3297                                    .text_color(gpui::rgb(0xc0c0d8))
3298                                    .font(font("Menlo"))
3299                                    .child(if code_text.is_empty() {
3300                                        "(awaiting stream…)".to_string()
3301                                    } else {
3302                                        code_text
3303                                    }),
3304                            )
3305                            .child(
3306                                div()
3307                                    .id("oxide_forge_log")
3308                                    .max_h(px(120.0))
3309                                    .overflow_scroll()
3310                                    .px_3()
3311                                    .py_2()
3312                                    .border_t_1()
3313                                    .border_color(gpui::rgb(0x2a2a34))
3314                                    .text_xs()
3315                                    .text_color(gpui::rgb(0xf0c0a0))
3316                                    .font(font("Menlo"))
3317                                    .child(if let Some(e) = err {
3318                                        format!("error: {e}\n\n{build_log}")
3319                                    } else if build_log.is_empty() {
3320                                        artifact
3321                                    } else {
3322                                        build_log
3323                                    }),
3324                            )
3325                            .child(
3326                                div()
3327                                    .p_2()
3328                                    .flex()
3329                                    .flex_row()
3330                                    .gap_2()
3331                                    .child(
3332                                        div()
3333                                            .id("oxide_forge_build_btn")
3334                                            .flex_1()
3335                                            .px_2()
3336                                            .py_2()
3337                                            .rounded_md()
3338                                            .bg(if can_build {
3339                                                gpui::rgb(0x3a6a8a)
3340                                            } else {
3341                                                gpui::rgb(0x3a3a44)
3342                                            })
3343                                            .text_xs()
3344                                            .text_color(gpui::rgb(0xffffff))
3345                                            .cursor_pointer()
3346                                            .child("Build")
3347                                            .on_click(cx.listener(
3348                                                |this, _: &ClickEvent, _, cx| {
3349                                                    this.forge_build();
3350                                                    cx.notify();
3351                                                },
3352                                            )),
3353                                    )
3354                                    .child(
3355                                        div()
3356                                            .id("oxide_forge_run_btn")
3357                                            .flex_1()
3358                                            .px_2()
3359                                            .py_2()
3360                                            .rounded_md()
3361                                            .bg(if can_run {
3362                                                gpui::rgb(0x4a9a6a)
3363                                            } else {
3364                                                gpui::rgb(0x3a3a44)
3365                                            })
3366                                            .text_xs()
3367                                            .text_color(gpui::rgb(0xffffff))
3368                                            .cursor_pointer()
3369                                            .child("Run")
3370                                            .on_click(cx.listener(
3371                                                |this, _: &ClickEvent, _, cx| {
3372                                                    this.forge_run_in_new_tab();
3373                                                    cx.notify();
3374                                                },
3375                                            )),
3376                                    )
3377                                    .child(
3378                                        div()
3379                                            .id("oxide_forge_delete_btn")
3380                                            .px_2()
3381                                            .py_2()
3382                                            .rounded_md()
3383                                            .bg(if can_delete {
3384                                                gpui::rgb(0x8a3a3a)
3385                                            } else {
3386                                                gpui::rgb(0x3a3a44)
3387                                            })
3388                                            .text_xs()
3389                                            .text_color(gpui::rgb(0xffffff))
3390                                            .cursor_pointer()
3391                                            .child("Del")
3392                                            .on_click(cx.listener(
3393                                                |this, _: &ClickEvent, _, cx| {
3394                                                    this.forge_delete_current_creation();
3395                                                    cx.notify();
3396                                                },
3397                                            )),
3398                                    ),
3399                            );
3400                    } else {
3401                        chat_panel = chat_panel.child(
3402                            div()
3403                                .flex_1()
3404                                .flex()
3405                                .flex_col()
3406                                .items_center()
3407                                .justify_center()
3408                                .p_4()
3409                                .child(
3410                                    div()
3411                                        .text_sm()
3412                                        .text_color(gpui::rgb(0x9a9ab0))
3413                                        .child("Welcome to Oxide Forge"),
3414                                )
3415                                .child(
3416                                    div()
3417                                        .mt_2()
3418                                        .text_xs()
3419                                        .text_color(gpui::rgb(0x6a6a80))
3420                                        .child(
3421                                            "Describe an app below — like Cursor chat, Forge writes Rust, builds WASM, and runs it in a tab.",
3422                                        ),
3423                                ),
3424                        );
3425                        code_panel = code_panel.child(
3426                            div()
3427                                .flex_1()
3428                                .flex()
3429                                .items_center()
3430                                .justify_center()
3431                                .text_xs()
3432                                .text_color(gpui::rgb(0x6a6a80))
3433                                .child("Code appears here after generation."),
3434                        );
3435                    }
3436
3437                    let workspace_panel = div()
3438                        .flex_1()
3439                        .min_h_0()
3440                        .flex()
3441                        .flex_row()
3442                        .gap_3()
3443                        .child(list_panel)
3444                        .child(chat_panel)
3445                        .child(code_panel);
3446
3447                    content_col = content_col.child(
3448                        div()
3449                            .id("oxide_forge_page")
3450                            .flex_1()
3451                            .flex()
3452                            .flex_col()
3453                            .min_h_0()
3454                            .p_4()
3455                            .child(
3456                                div()
3457                                    .flex()
3458                                    .flex_row()
3459                                    .items_center()
3460                                    .justify_between()
3461                                    .gap_3()
3462                                    .child(
3463                                        div()
3464                                            .flex_1()
3465                                            .min_w_0()
3466                                            .child(
3467                                                div()
3468                                                    .text_lg()
3469                                                    .font_weight(gpui::FontWeight::BOLD)
3470                                                    .text_color(gpui::rgb(0xb478ff))
3471                                                    .child("Oxide Forge"),
3472                                            )
3473                                            .child(
3474                                                div()
3475                                                    .mt_1()
3476                                                    .text_xs()
3477                                                    .text_color(gpui::rgb(0x8a8aa0))
3478                                                    .child(status_hint.clone()),
3479                                            ),
3480                                    )
3481                                    .child(
3482                                        div()
3483                                            .flex()
3484                                            .flex_row()
3485                                            .gap_1()
3486                                            .children(ForgeProvider::ALL.iter().enumerate().map(
3487                                                |(i, provider)| {
3488                                                    let selected = *provider == active_provider;
3489                                                    let configured =
3490                                                        self.forge_config.provider(*provider)
3491                                                            .has_key();
3492                                                    let dot = if configured { "● " } else { "" };
3493                                                    div()
3494                                                        .id(("oxide_forge_provider", i))
3495                                                        .px_2()
3496                                                        .py_1()
3497                                                        .rounded_sm()
3498                                                        .bg(if selected {
3499                                                            gpui::rgb(0x4a3a7a)
3500                                                        } else {
3501                                                            gpui::rgb(0x2a2a36)
3502                                                        })
3503                                                        .text_xs()
3504                                                        .text_color(if selected {
3505                                                            gpui::rgb(0xffffff)
3506                                                        } else {
3507                                                            gpui::rgb(0xb0b0c0)
3508                                                        })
3509                                                        .cursor_pointer()
3510                                                        .child(format!(
3511                                                            "{dot}{}",
3512                                                            provider.label()
3513                                                        ))
3514                                                        .on_click(cx.listener(
3515                                                            move |this, _: &ClickEvent, _, cx| {
3516                                                                this.forge_select_provider(
3517                                                                    *provider,
3518                                                                );
3519                                                                cx.notify();
3520                                                            },
3521                                                        ))
3522                                                },
3523                                            )),
3524                                    )
3525                                    .child(
3526                                        div()
3527                                            .id("oxide_forge_settings_btn")
3528                                            .px_2()
3529                                            .py_1()
3530                                            .rounded_sm()
3531                                            .bg(if settings_open {
3532                                                gpui::rgb(0x3a4a5a)
3533                                            } else {
3534                                                gpui::rgb(0x2a2a36)
3535                                            })
3536                                            .text_xs()
3537                                            .text_color(gpui::rgb(0xd0d0dc))
3538                                            .cursor_pointer()
3539                                            .child("Settings")
3540                                            .on_click(cx.listener(
3541                                                |this, _: &ClickEvent, _, cx| {
3542                                                    this.forge_toggle_settings();
3543                                                    cx.notify();
3544                                                },
3545                                            )),
3546                                    )
3547                                    .child(
3548                                        div()
3549                                            .id("oxide_forge_choose_folder")
3550                                            .px_2()
3551                                            .py_1()
3552                                            .rounded_sm()
3553                                            .bg(gpui::rgb(0x2a2a36))
3554                                            .text_xs()
3555                                            .text_color(gpui::rgb(0xd0d0dc))
3556                                            .cursor_pointer()
3557                                            .child("Folder")
3558                                            .on_click(cx.listener(
3559                                                |this, _: &ClickEvent, _, cx| {
3560                                                    this.forge_pick_output_dir();
3561                                                    cx.notify();
3562                                                },
3563                                            )),
3564                                    )
3565                                    .child(
3566                                        div()
3567                                            .px_2()
3568                                            .py_1()
3569                                            .rounded_sm()
3570                                            .bg(gpui::rgb(0x22222c))
3571                                            .text_xs()
3572                                            .text_color(status_color)
3573                                            .child(status_word),
3574                                    ),
3575                            )
3576                            .child(div().mt_3().h(px(1.0)).bg(gpui::rgb(0x2a2a32)))
3577                            .when(settings_open, |panel| {
3578                                panel.child(
3579                                    div()
3580                                        .id("oxide_forge_settings")
3581                                        .mt_3()
3582                                        .p_3()
3583                                        .rounded_md()
3584                                        .bg(gpui::rgb(0x1a1a22))
3585                                        .border_1()
3586                                        .border_color(gpui::rgb(0x33333f))
3587                                        .child(
3588                                            div()
3589                                                .text_sm()
3590                                                .font_weight(gpui::FontWeight::BOLD)
3591                                                .text_color(gpui::rgb(0xe0e0f0))
3592                                                .child("AI provider settings"),
3593                                        )
3594                                        .child(
3595                                            div()
3596                                                .mt_2()
3597                                                .flex()
3598                                                .flex_row()
3599                                                .gap_1()
3600                                                .children(
3601                                                    ForgeProvider::ALL.iter().enumerate().map(
3602                                                        |(i, provider)| {
3603                                                            let selected =
3604                                                                *provider == settings_provider;
3605                                                            let configured = self
3606                                                                .forge_config
3607                                                                .provider(*provider)
3608                                                                .has_key();
3609                                                            div()
3610                                                                .id(("oxide_forge_settings_tab", i))
3611                                                                .px_2()
3612                                                                .py_1()
3613                                                                .rounded_sm()
3614                                                                .bg(if selected {
3615                                                                    gpui::rgb(0x3a3a50)
3616                                                                } else {
3617                                                                    gpui::rgb(0x252530)
3618                                                                })
3619                                                                .text_xs()
3620                                                                .text_color(if configured {
3621                                                                    gpui::rgb(0x90d0a0)
3622                                                                } else {
3623                                                                    gpui::rgb(0x9090a8)
3624                                                                })
3625                                                                .cursor_pointer()
3626                                                                .child(provider.label())
3627                                                                .on_click(cx.listener(
3628                                                                    move |this,
3629                                                                          _: &ClickEvent,
3630                                                                          _,
3631                                                                          cx| {
3632                                                                        this.forge_settings_provider =
3633                                                                            *provider;
3634                                                                        this.forge_sync_settings_drafts();
3635                                                                        cx.notify();
3636                                                                    },
3637                                                                ))
3638                                                        },
3639                                                    ),
3640                                                ),
3641                                        )
3642                                        .child(
3643                                            div()
3644                                                .mt_2()
3645                                                .text_xs()
3646                                                .text_color(gpui::rgb(0x7a7a90))
3647                                                .child(format!(
3648                                                    "Keys are stored locally at {}",
3649                                                    ForgeUserConfig::config_path().display()
3650                                                )),
3651                                        )
3652                                        .child(
3653                                            div()
3654                                                .mt_2()
3655                                                .text_xs()
3656                                                .text_color(gpui::rgb(0x8a8aa0))
3657                                                .child("Model"),
3658                                        )
3659                                        .child(
3660                                            div()
3661                                                .id("oxide_forge_settings_model")
3662                                                .mt_1()
3663                                                .track_focus(&self.forge_settings_model_focus)
3664                                                .focusable()
3665                                                .px_3()
3666                                                .py_2()
3667                                                .rounded_md()
3668                                                .bg(gpui::rgb(0x121218))
3669                                                .border_1()
3670                                                .border_color(if settings_model_focused {
3671                                                    gpui::rgb(0x7a5ae0)
3672                                                } else {
3673                                                    gpui::rgb(0x33333f)
3674                                                })
3675                                                .text_sm()
3676                                                .text_color(gpui::rgb(0xe0e0ff))
3677                                                .child(if settings_model_focused && caret_blink_on
3678                                                {
3679                                                    format!("{settings_model_draft}\u{2588}")
3680                                                } else {
3681                                                    settings_model_draft.clone()
3682                                                })
3683                                                .on_click(cx.listener(
3684                                                    |this, _: &ClickEvent, window, cx| {
3685                                                        window.focus(
3686                                                            &this.forge_settings_model_focus,
3687                                                        );
3688                                                        cx.notify();
3689                                                    },
3690                                                )),
3691                                        )
3692                                        .child(
3693                                            div()
3694                                                .mt_2()
3695                                                .text_xs()
3696                                                .text_color(gpui::rgb(0x8a8aa0))
3697                                                .child("API key"),
3698                                        )
3699                                        .child(
3700                                            div()
3701                                                .mt_1()
3702                                                .flex()
3703                                                .flex_row()
3704                                                .gap_2()
3705                                                .child(
3706                                                    div()
3707                                                        .id("oxide_forge_settings_key")
3708                                                        .track_focus(&self.forge_settings_key_focus)
3709                                                        .focusable()
3710                                                        .flex_1()
3711                                                        .px_3()
3712                                                        .py_2()
3713                                                        .rounded_md()
3714                                                        .bg(gpui::rgb(0x121218))
3715                                                        .border_1()
3716                                                        .border_color(if settings_key_focused {
3717                                                            gpui::rgb(0x4ea39a)
3718                                                        } else {
3719                                                            gpui::rgb(0x33333f)
3720                                                        })
3721                                                        .text_sm()
3722                                                        .text_color(gpui::rgb(0xe0e0ff))
3723                                                        .child(
3724                                                            if settings_key_focused && caret_blink_on
3725                                                            {
3726                                                                format!("{settings_key_hint}\u{2588}")
3727                                                            } else {
3728                                                                settings_key_hint.clone()
3729                                                            },
3730                                                        )
3731                                                        .on_click(cx.listener(
3732                                                            |this, _: &ClickEvent, window, cx| {
3733                                                                window.focus(
3734                                                                    &this.forge_settings_key_focus,
3735                                                                );
3736                                                                cx.notify();
3737                                                            },
3738                                                        )),
3739                                                )
3740                                                .child(
3741                                                    div()
3742                                                        .id("oxide_forge_settings_save")
3743                                                        .px_3()
3744                                                        .py_2()
3745                                                        .rounded_md()
3746                                                        .bg(if settings_save_enabled {
3747                                                            gpui::rgb(0x2f6f68)
3748                                                        } else {
3749                                                            gpui::rgb(0x3a3a44)
3750                                                        })
3751                                                        .text_sm()
3752                                                        .text_color(gpui::rgb(0xffffff))
3753                                                        .cursor_pointer()
3754                                                        .child("Save")
3755                                                        .on_click(cx.listener(
3756                                                            |this, _: &ClickEvent, _, cx| {
3757                                                                this.forge_save_provider_settings();
3758                                                                cx.notify();
3759                                                            },
3760                                                        )),
3761                                                ),
3762                                        ),
3763                                )
3764                            })
3765                            .child(
3766                                div()
3767                                    .mt_3()
3768                                    .flex_1()
3769                                    .min_h_0()
3770                                    .flex()
3771                                    .flex_col()
3772                                    .child(workspace_panel)
3773                                    .child(
3774                                        div()
3775                                            .id("oxide_forge_prompt_row")
3776                                            .flex()
3777                                            .flex_row()
3778                                            .gap_2()
3779                                            .items_center()
3780                                            .child(
3781                                        div()
3782                                            .id("oxide_forge_prompt_input")
3783                                            .track_focus(&self.forge_focus)
3784                                            .focusable()
3785                                            .flex_1()
3786                                            .px_3()
3787                                            .py_2()
3788                                            .rounded_md()
3789                                            .bg(gpui::rgb(0x22222c))
3790                                            .border_1()
3791                                            .border_color(if prompt_focused {
3792                                                gpui::rgb(0x7a5ae0)
3793                                            } else {
3794                                                gpui::rgb(0x33333f)
3795                                            })
3796                                            .text_sm()
3797                                            .text_color(prompt_color)
3798                                            .child(format!("{prompt_display}{caret}"))
3799                                            .on_click(cx.listener(
3800                                                |this, _: &ClickEvent, window, cx| {
3801                                                    window.focus(&this.forge_focus);
3802                                                    cx.notify();
3803                                                },
3804                                            )),
3805                                    )
3806                                    .child(
3807                                        div()
3808                                            .id("oxide_forge_submit")
3809                                            .px_3()
3810                                            .py_2()
3811                                            .rounded_md()
3812                                            .bg(if submit_enabled {
3813                                                gpui::rgb(0x7a5ae0)
3814                                            } else {
3815                                                gpui::rgb(0x3a3a44)
3816                                            })
3817                                            .text_sm()
3818                                            .text_color(gpui::rgb(0xffffff))
3819                                            .cursor_pointer()
3820                                            .child(submit_label)
3821                                            .on_click(cx.listener(
3822                                                |this, _: &ClickEvent, _, cx| {
3823                                                    this.forge_submit();
3824                                                    cx.notify();
3825                                                },
3826                                            )),
3827                                    ),
3828                            )
3829                            .child(
3830                                div()
3831                                    .mt_1()
3832                                    .text_xs()
3833                                    .text_color(gpui::rgb(0x6a6a80))
3834                                    .child(
3835                                        "Enter to send · Shift+Enter for newline · Pick a provider above · Settings for API keys",
3836                                    ),
3837                            ),
3838                    ),
3839                    );
3840                }
3841            }
3842        } else {
3843            let text_input_focus_id = self.tabs[active].text_input_focus;
3844            let caret_blink_on = SystemTime::now()
3845                .duration_since(UNIX_EPOCH)
3846                .map(|d| (d.as_millis() / 530) % 2 == 0)
3847                .unwrap_or(true);
3848
3849            let canvas_area = div()
3850                .id("oxide_canvas_area")
3851                .flex_1()
3852                .flex()
3853                .flex_col()
3854                .min_h_0()
3855                .relative()
3856                .on_mouse_move(cx.listener({
3857                    let hyperlinks_hover = hyperlinks_hover.clone();
3858                    move |this, event: &gpui::MouseMoveEvent, _, cx| {
3859                        let tab = &mut this.tabs[this.active_tab];
3860                        let mut input = tab.host_state.input_state.lock().unwrap();
3861                        input.mouse_x = f32::from(event.position.x);
3862                        input.mouse_y = f32::from(event.position.y);
3863                        drop(input);
3864
3865                        if this.scroll_dragging {
3866                            let viewport_h = tab.host_state.canvas.lock().unwrap().height as f32;
3867                            let content_h = *tab.host_state.content_height.lock().unwrap() as f32;
3868                            if content_h > viewport_h && viewport_h > 0.0 {
3869                                let max_scroll_y = content_h - viewport_h;
3870                                let thumb_height =
3871                                    ((viewport_h / content_h) * viewport_h).max(20.0);
3872                                let max_thumb_top = viewport_h - thumb_height;
3873                                if max_thumb_top > 0.0 {
3874                                    let dy = f32::from(event.position.y) - this.scroll_drag_start_y;
3875                                    let d_scroll = dy * (max_scroll_y / max_thumb_top);
3876                                    let new_scroll_y = (this.scroll_drag_start_scroll_y + d_scroll)
3877                                        .clamp(0.0, max_scroll_y);
3878                                    *tab.host_state.scroll_y.lock().unwrap() = new_scroll_y;
3879                                }
3880                            }
3881                            cx.notify();
3882                        }
3883
3884                        if let Some((id, sx, sw, min, max)) = this.slider_drag {
3885                            let tab = &mut this.tabs[this.active_tab];
3886                            let (ox, _) = *tab.host_state.canvas_offset.lock().unwrap();
3887                            let lx = f32::from(event.position.x) - ox;
3888                            let frac = ((lx - sx) / sw).clamp(0.0, 1.0);
3889                            let v = min + frac * (max - min);
3890                            tab.host_state
3891                                .widget_states
3892                                .lock()
3893                                .unwrap()
3894                                .insert(id, WidgetValue::Float(v));
3895                            cx.notify();
3896                        }
3897
3898                        let tab = &mut this.tabs[this.active_tab];
3899                        let (ox, oy) = *tab.host_state.canvas_offset.lock().unwrap();
3900                        let lx = f32::from(event.position.x) - ox;
3901                        let ly = f32::from(event.position.y) - oy;
3902                        let mut hovered = None;
3903                        for link in &hyperlinks_hover {
3904                            if lx >= link.x
3905                                && ly >= link.y
3906                                && lx <= link.x + link.w
3907                                && ly <= link.y + link.h
3908                            {
3909                                hovered = Some(link.url.clone());
3910                                break;
3911                            }
3912                        }
3913                        tab.hovered_link_url = hovered;
3914                    }
3915                }))
3916                .on_any_mouse_down(cx.listener(|this, event: &MouseDownEvent, _, _cx| {
3917                    let tab = &mut this.tabs[this.active_tab];
3918                    let mut input = tab.host_state.input_state.lock().unwrap();
3919                    let b = match event.button {
3920                        MouseButton::Left => 0,
3921                        MouseButton::Right => 1,
3922                        MouseButton::Middle => 2,
3923                        _ => return,
3924                    };
3925                    input.mouse_buttons_down[b] = true;
3926                }))
3927                .on_mouse_up(
3928                    MouseButton::Left,
3929                    cx.listener(|this, _: &MouseUpEvent, _, _cx| {
3930                        this.scroll_dragging = false;
3931                        this.slider_drag = None;
3932                        let tab = &mut this.tabs[this.active_tab];
3933                        let mut input = tab.host_state.input_state.lock().unwrap();
3934                        input.mouse_buttons_down[0] = false;
3935                        input.mouse_buttons_clicked[0] = true;
3936                    }),
3937                )
3938                .on_mouse_up(
3939                    MouseButton::Right,
3940                    cx.listener(|this, _: &MouseUpEvent, _, _cx| {
3941                        let tab = &mut this.tabs[this.active_tab];
3942                        let mut input = tab.host_state.input_state.lock().unwrap();
3943                        input.mouse_buttons_down[1] = false;
3944                        input.mouse_buttons_clicked[1] = true;
3945                    }),
3946                )
3947                .on_mouse_up(
3948                    MouseButton::Middle,
3949                    cx.listener(|this, _: &MouseUpEvent, _, _cx| {
3950                        let tab = &mut this.tabs[this.active_tab];
3951                        let mut input = tab.host_state.input_state.lock().unwrap();
3952                        input.mouse_buttons_down[2] = false;
3953                        input.mouse_buttons_clicked[2] = true;
3954                    }),
3955                )
3956                .on_click(cx.listener(move |this, event: &ClickEvent, window, cx| {
3957                    if let Some(pos) = event.mouse_position() {
3958                        let tab = &mut this.tabs[this.active_tab];
3959                        let (ox, oy) = *tab.host_state.canvas_offset.lock().unwrap();
3960                        let lx = f32::from(pos.x) - ox;
3961                        let ly = f32::from(pos.y) - oy;
3962                        if canvas_point_hits_widget(lx, ly, &widget_cmds_overlay) {
3963                            return;
3964                        }
3965                        let links = tab.host_state.hyperlinks.lock().unwrap().clone();
3966                        for link in links.iter().rev() {
3967                            if lx >= link.x
3968                                && ly >= link.y
3969                                && lx <= link.x + link.w
3970                                && ly <= link.y + link.h
3971                            {
3972                                tab.navigate_to(link.url.clone(), true, &this.download_manager);
3973                                this.show_downloads =
3974                                    this.download_manager.has_active() || this.show_downloads;
3975                                cx.notify();
3976                                return;
3977                            }
3978                        }
3979                        tab.text_input_focus = None;
3980                        this.canvas_focus.focus(window);
3981                    }
3982                }))
3983                .on_scroll_wheel(cx.listener(|this, event: &ScrollWheelEvent, _, cx| {
3984                    let tab = &mut this.tabs[this.active_tab];
3985                    let mut input = tab.host_state.input_state.lock().unwrap();
3986                    let (dx, dy);
3987                    match event.delta {
3988                        ScrollDelta::Pixels(p) => {
3989                            input.scroll_x += f32::from(p.x);
3990                            input.scroll_y += f32::from(p.y);
3991                            dx = f32::from(p.x);
3992                            dy = f32::from(p.y);
3993                        }
3994                        ScrollDelta::Lines(l) => {
3995                            input.scroll_x += l.x * 20.0;
3996                            input.scroll_y += l.y * 20.0;
3997                            dx = l.x * 20.0;
3998                            dy = l.y * 20.0;
3999                        }
4000                    }
4001                    drop(input);
4002
4003                    let viewport_w = tab.host_state.canvas.lock().unwrap().width as f32;
4004                    let viewport_h = tab.host_state.canvas.lock().unwrap().height as f32;
4005                    let content_w = *tab.host_state.content_width.lock().unwrap() as f32;
4006                    let content_h = *tab.host_state.content_height.lock().unwrap() as f32;
4007
4008                    let max_x = (content_w - viewport_w).max(0.0);
4009                    let max_y = (content_h - viewport_h).max(0.0);
4010
4011                    let mut sx = tab.host_state.scroll_x.lock().unwrap();
4012                    let mut sy = tab.host_state.scroll_y.lock().unwrap();
4013                    *sx = (*sx - dx).clamp(0.0, max_x);
4014                    *sy = (*sy - dy).clamp(0.0, max_y);
4015                    cx.notify();
4016                }))
4017                .on_drop(cx.listener(|this, paths: &gpui::ExternalPaths, _, _cx| {
4018                    let tab = &mut this.tabs[this.active_tab];
4019                    crate::events::enqueue_drop_files(&tab.host_state.events, paths.paths());
4020                }))
4021                .child({
4022                    let cmds = cmds.clone();
4023                    let textures = textures.clone();
4024                    let canvas_offset = canvas_offset.clone();
4025                    let canvas_state_for_dims = self.tabs[active].host_state.canvas.clone();
4026                    canvas(
4027                        move |bounds, _window, _cx| {
4028                            *canvas_offset.lock().unwrap() =
4029                                (f32::from(bounds.origin.x), f32::from(bounds.origin.y));
4030                            let mut cs = canvas_state_for_dims.lock().unwrap();
4031                            cs.width = f32::from(bounds.size.width) as u32;
4032                            cs.height = f32::from(bounds.size.height) as u32;
4033                        },
4034                        move |bounds, (), window, cx| {
4035                            if cmds.is_empty() {
4036                                let _ = window
4037                                    .text_system()
4038                                    .shape_line(
4039                                        "Oxide Browser".into(),
4040                                        px(28.0),
4041                                        &[TextRun {
4042                                            len: 13,
4043                                            font: font(".SystemUIFont"),
4044                                            color: gpui::hsla(0.75, 0.5, 0.7, 1.0),
4045                                            background_color: None,
4046                                            underline: None,
4047                                            strikethrough: None,
4048                                        }],
4049                                        None,
4050                                    )
4051                                    .paint(
4052                                        bounds.origin + point(px(24.0), px(24.0)),
4053                                        px(32.0),
4054                                        window,
4055                                        cx,
4056                                    );
4057                            } else {
4058                                paint_draw_commands(window, cx, bounds, &cmds, &textures);
4059                            }
4060                        },
4061                    )
4062                    .flex_1()
4063                });
4064
4065            let widget_states_snapshot = self.tabs[active]
4066                .host_state
4067                .widget_states
4068                .lock()
4069                .unwrap()
4070                .clone();
4071            let widget_edits_snapshot = self.tabs[active].widget_edits.clone();
4072
4073            // Ensure each editable widget has a stable bounds cache so mouse hit-tests
4074            // can locate the text canvas after layout.
4075            for cmd in &widget_commands {
4076                match cmd {
4077                    WidgetCommand::TextInput { id, .. } | WidgetCommand::Textarea { id, .. } => {
4078                        self.tabs[active]
4079                            .widget_bounds_cache
4080                            .entry(*id)
4081                            .or_insert_with(|| Arc::new(Mutex::new(Bounds::default())));
4082                    }
4083                    _ => {}
4084                }
4085            }
4086            let widget_bounds_cache_snapshot = self.tabs[active].widget_bounds_cache.clone();
4087
4088            let canvas_with_widgets =
4089                widget_commands
4090                    .into_iter()
4091                    .fold(canvas_area, |el, cmd| match cmd {
4092                        WidgetCommand::Button {
4093                            id,
4094                            x,
4095                            y,
4096                            w,
4097                            h,
4098                            label,
4099                            variant,
4100                        } => el.child(render_button(cx, id, x, y, w, h, label, variant)),
4101                        WidgetCommand::Checkbox { id, x, y, label } => {
4102                            let checked = widget_states_snapshot
4103                                .get(&id)
4104                                .and_then(|v| match v {
4105                                    WidgetValue::Bool(b) => Some(*b),
4106                                    _ => None,
4107                                })
4108                                .unwrap_or(false);
4109                            el.child(render_checkbox(cx, id, x, y, &label, checked))
4110                        }
4111                        WidgetCommand::Switch { id, x, y, label } => {
4112                            let checked = widget_states_snapshot
4113                                .get(&id)
4114                                .and_then(|v| match v {
4115                                    WidgetValue::Bool(b) => Some(*b),
4116                                    _ => None,
4117                                })
4118                                .unwrap_or(false);
4119                            el.child(render_switch(cx, id, x, y, &label, checked))
4120                        }
4121                        WidgetCommand::Slider {
4122                            id,
4123                            x,
4124                            y,
4125                            w,
4126                            min,
4127                            max,
4128                        } => {
4129                            let cur = widget_states_snapshot
4130                                .get(&id)
4131                                .and_then(|v| match v {
4132                                    WidgetValue::Float(f) => Some(*f),
4133                                    _ => None,
4134                                })
4135                                .unwrap_or(min);
4136                            el.child(render_slider(cx, id, x, y, w, min, max, cur))
4137                        }
4138                        WidgetCommand::TextInput {
4139                            id,
4140                            x,
4141                            y,
4142                            w,
4143                            placeholder,
4144                        } => {
4145                            let value = widget_states_snapshot
4146                                .get(&id)
4147                                .and_then(|v| match v {
4148                                    WidgetValue::Text(t) => Some(t.clone()),
4149                                    _ => None,
4150                                })
4151                                .unwrap_or_default();
4152                            let edit = widget_edits_snapshot.get(&id).cloned().unwrap_or_default();
4153                            let bounds_ref = widget_bounds_cache_snapshot
4154                                .get(&id)
4155                                .cloned()
4156                                .unwrap_or_else(|| Arc::new(Mutex::new(Bounds::default())));
4157                            el.child(render_text_input(
4158                                cx,
4159                                id,
4160                                x,
4161                                y,
4162                                w,
4163                                placeholder,
4164                                value,
4165                                edit,
4166                                text_input_focus_id == Some(id),
4167                                caret_blink_on,
4168                                bounds_ref,
4169                            ))
4170                        }
4171                        WidgetCommand::Textarea {
4172                            id,
4173                            x,
4174                            y,
4175                            w,
4176                            h,
4177                            placeholder,
4178                        } => {
4179                            let value = widget_states_snapshot
4180                                .get(&id)
4181                                .and_then(|v| match v {
4182                                    WidgetValue::Text(t) => Some(t.clone()),
4183                                    _ => None,
4184                                })
4185                                .unwrap_or_default();
4186                            let edit = widget_edits_snapshot.get(&id).cloned().unwrap_or_default();
4187                            let bounds_ref = widget_bounds_cache_snapshot
4188                                .get(&id)
4189                                .cloned()
4190                                .unwrap_or_else(|| Arc::new(Mutex::new(Bounds::default())));
4191                            el.child(render_textarea(
4192                                cx,
4193                                id,
4194                                x,
4195                                y,
4196                                w,
4197                                h,
4198                                placeholder,
4199                                value,
4200                                edit,
4201                                text_input_focus_id == Some(id),
4202                                caret_blink_on,
4203                                bounds_ref,
4204                            ))
4205                        }
4206                        WidgetCommand::Card {
4207                            x,
4208                            y,
4209                            w,
4210                            h,
4211                            title,
4212                            description,
4213                        } => el.child(render_card(x, y, w, h, &title, &description)),
4214                        WidgetCommand::Badge {
4215                            x,
4216                            y,
4217                            label,
4218                            variant,
4219                        } => el.child(render_badge(x, y, &label, variant)),
4220                        WidgetCommand::Separator {
4221                            x,
4222                            y,
4223                            length,
4224                            vertical,
4225                        } => el.child(render_separator(x, y, length, vertical)),
4226                        WidgetCommand::Progress { x, y, w, value } => {
4227                            el.child(render_progress(x, y, w, value))
4228                        }
4229                        WidgetCommand::Label {
4230                            x,
4231                            y,
4232                            text,
4233                            muted,
4234                            size,
4235                        } => el.child(render_label(x, y, &text, muted, size)),
4236                    });
4237
4238            let viewport_h = {
4239                let canvas_state = self.tabs[active].host_state.canvas.lock().unwrap();
4240                canvas_state.height as f32
4241            };
4242            let content_h = *self.tabs[active].host_state.content_height.lock().unwrap() as f32;
4243            let scroll_y = *self.tabs[active].host_state.scroll_y.lock().unwrap();
4244
4245            let canvas_with_widgets = if content_h > viewport_h && viewport_h > 0.0 {
4246                let max_scroll_y = content_h - viewport_h;
4247                let thumb_height = ((viewport_h / content_h) * viewport_h).max(20.0);
4248                let max_thumb_top = viewport_h - thumb_height;
4249                let thumb_top = (scroll_y / max_scroll_y) * max_thumb_top;
4250
4251                let thumb = div()
4252                    .id("oxide_scrollbar_thumb")
4253                    .absolute()
4254                    .top(px(thumb_top))
4255                    .left(px(0.0))
4256                    .w(px(8.0))
4257                    .h(px(thumb_height))
4258                    .rounded_full()
4259                    .bg(rgba8(0xff, 0xff, 0xff, 0x44))
4260                    .hover(|style| style.bg(rgba8(0xff, 0xff, 0xff, 0x66)))
4261                    .active(|style| style.bg(rgba8(0xff, 0xff, 0xff, 0x88)))
4262                    .on_mouse_down(
4263                        MouseButton::Left,
4264                        cx.listener(move |this, event: &MouseDownEvent, _, cx| {
4265                            this.scroll_dragging = true;
4266                            this.scroll_drag_start_y = f32::from(event.position.y);
4267                            this.scroll_drag_start_scroll_y = *this.tabs[this.active_tab]
4268                                .host_state
4269                                .scroll_y
4270                                .lock()
4271                                .unwrap();
4272                            cx.notify();
4273                        }),
4274                    );
4275
4276                let track = div()
4277                    .id("oxide_scrollbar_track")
4278                    .absolute()
4279                    .right(px(2.0))
4280                    .top(px(0.0))
4281                    .bottom(px(0.0))
4282                    .w(px(8.0))
4283                    .rounded_full()
4284                    .bg(rgba8(0x00, 0x00, 0x00, 0x11))
4285                    .child(thumb);
4286
4287                canvas_with_widgets.child(track)
4288            } else {
4289                canvas_with_widgets
4290            };
4291
4292            content_col = content_col.child(canvas_with_widgets);
4293        }
4294
4295        if let Some(tex) = pip_tex {
4296            content_col = content_col.child(
4297                div()
4298                    .id("oxide_pip")
4299                    .absolute()
4300                    .bottom(px(16.0))
4301                    .right(px(16.0))
4302                    .w(px(320.0))
4303                    .h(px(200.0))
4304                    .rounded_md()
4305                    .overflow_hidden()
4306                    .border_1()
4307                    .border_color(gpui::rgb(0x2a2a32))
4308                    .child(img(ImageSource::from(tex)).object_fit(gpui::ObjectFit::Contain)),
4309            );
4310        }
4311
4312        if show_console {
4313            let entries = self.tabs[active].host_state.console.lock().unwrap().clone();
4314            content_col = content_col.child(
4315                div()
4316                    .id("oxide_console")
4317                    .h(px(160.0))
4318                    .border_t_1()
4319                    .border_color(gpui::rgb(0x2a2a32))
4320                    .flex()
4321                    .flex_col()
4322                    .child(
4323                        div()
4324                            .flex()
4325                            .flex_row()
4326                            .items_center()
4327                            .justify_between()
4328                            .h(px(28.0))
4329                            .px_2()
4330                            .border_b_1()
4331                            .border_color(gpui::rgb(0x2a2a32))
4332                            .child(
4333                                div()
4334                                    .text_xs()
4335                                    .font_weight(gpui::FontWeight::SEMIBOLD)
4336                                    .text_color(gpui::rgb(0x9696a0))
4337                                    .child("Console"),
4338                            )
4339                            .child(
4340                                div()
4341                                    .id("oxide_console_close")
4342                                    .cursor_pointer()
4343                                    .w(px(20.0))
4344                                    .h(px(20.0))
4345                                    .flex()
4346                                    .items_center()
4347                                    .justify_center()
4348                                    .rounded_sm()
4349                                    .hover(|s| s.bg(gpui::rgb(0x3a3a48)))
4350                                    .text_xs()
4351                                    .text_color(gpui::rgb(0x9696a0))
4352                                    .child("✕")
4353                                    .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
4354                                        this.tabs[this.active_tab].show_console = false;
4355                                        cx.notify();
4356                                    })),
4357                            ),
4358                    )
4359                    .child(
4360                        div()
4361                            .id("oxide_console_entries")
4362                            .flex_1()
4363                            .overflow_scroll()
4364                            .p_2()
4365                            .font_family("Monaco")
4366                            .text_xs()
4367                            .children(entries.into_iter().map(|e| {
4368                                let color = match e.level {
4369                                    ConsoleLevel::Log => gpui::rgb(0xc8c8c8),
4370                                    ConsoleLevel::Warn => gpui::rgb(0xf0c83c),
4371                                    ConsoleLevel::Error => gpui::rgb(0xf05050),
4372                                };
4373                                div()
4374                                    .flex()
4375                                    .flex_row()
4376                                    .gap_2()
4377                                    .child(
4378                                        div()
4379                                            .text_color(gpui::rgb(0x646464))
4380                                            .child(e.timestamp.clone()),
4381                                    )
4382                                    .child(div().text_color(color).child(e.message.clone()))
4383                            })),
4384                    ),
4385            );
4386        }
4387
4388        main_row = main_row.child(content_col);
4389
4390        root = root.child(main_row);
4391
4392        // Downloads panel
4393        {
4394            let downloads = self.download_manager.downloads();
4395            let list = downloads.lock().unwrap().clone();
4396            if self.show_downloads && !list.is_empty() {
4397                let panel_height = (list.len() as f32 * 56.0 + 32.0).min(240.0);
4398                root = root.child(
4399                    div()
4400                        .id("oxide_downloads_panel")
4401                        .h(px(panel_height))
4402                        .border_t_1()
4403                        .border_color(gpui::rgb(0x2a2a32))
4404                        .flex()
4405                        .flex_col()
4406                        .child(
4407                            div()
4408                                .flex()
4409                                .flex_row()
4410                                .items_center()
4411                                .justify_between()
4412                                .h(px(28.0))
4413                                .px_2()
4414                                .border_b_1()
4415                                .border_color(gpui::rgb(0x2a2a32))
4416                                .child(
4417                                    div()
4418                                        .text_xs()
4419                                        .font_weight(gpui::FontWeight::SEMIBOLD)
4420                                        .text_color(gpui::rgb(0x9696a0))
4421                                        .child("Downloads"),
4422                                )
4423                                .child(
4424                                    div()
4425                                        .id("oxide_downloads_close")
4426                                        .cursor_pointer()
4427                                        .w(px(20.0))
4428                                        .h(px(20.0))
4429                                        .flex()
4430                                        .items_center()
4431                                        .justify_center()
4432                                        .rounded_sm()
4433                                        .hover(|s| s.bg(gpui::rgb(0x3a3a48)))
4434                                        .text_xs()
4435                                        .text_color(gpui::rgb(0x9696a0))
4436                                        .child("✕")
4437                                        .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
4438                                            this.show_downloads = false;
4439                                            cx.notify();
4440                                        })),
4441                                ),
4442                        )
4443                        .child(
4444                            div()
4445                                .id("oxide_downloads_list")
4446                                .flex_1()
4447                                .overflow_y_scroll()
4448                                .children(list.iter().enumerate().map(|(idx, dl)| {
4449                                    let dl_id = dl.id;
4450                                    let filename = SharedString::from(dl.filename.clone());
4451                                    let (status_text, status_color) = match &dl.state {
4452                                        DownloadState::InProgress => {
4453                                            let downloaded = format_bytes(dl.bytes_downloaded);
4454                                            let total = dl
4455                                                .total_bytes
4456                                                .map(format_bytes)
4457                                                .unwrap_or_else(|| "?".to_string());
4458                                            let speed = format_bytes(dl.speed_bytes_per_sec as u64);
4459                                            let pct = dl
4460                                                .percent()
4461                                                .map(|p| format!("{p:.0}%"))
4462                                                .unwrap_or_default();
4463                                            (
4464                                                format!("{downloaded} / {total}  {speed}/s  {pct}"),
4465                                                gpui::rgb(0x50b0e0),
4466                                            )
4467                                        }
4468                                        DownloadState::Completed => {
4469                                            let total = format_bytes(dl.bytes_downloaded);
4470                                            (format!("Complete — {total}"), gpui::rgb(0x50e070))
4471                                        }
4472                                        DownloadState::Failed(msg) => {
4473                                            (format!("Failed: {msg}"), gpui::rgb(0xf05050))
4474                                        }
4475                                        DownloadState::Cancelled => {
4476                                            ("Cancelled".to_string(), gpui::rgb(0x9696a0))
4477                                        }
4478                                    };
4479
4480                                    let progress_fraction = match &dl.state {
4481                                        DownloadState::InProgress => {
4482                                            dl.percent().map(|p| (p / 100.0) as f32).unwrap_or(0.0)
4483                                        }
4484                                        DownloadState::Completed => 1.0,
4485                                        _ => 0.0,
4486                                    };
4487
4488                                    let is_active = dl.state == DownloadState::InProgress;
4489
4490                                    div()
4491                                        .id(("oxide_dl", idx))
4492                                        .flex()
4493                                        .flex_row()
4494                                        .items_center()
4495                                        .gap_2()
4496                                        .px_2()
4497                                        .py_1()
4498                                        .border_b_1()
4499                                        .border_color(gpui::rgb(0x24242c))
4500                                        .child(
4501                                            div()
4502                                                .flex_1()
4503                                                .min_w_0()
4504                                                .flex()
4505                                                .flex_col()
4506                                                .gap(px(2.0))
4507                                                .child(
4508                                                    div()
4509                                                        .text_sm()
4510                                                        .text_color(gpui::rgb(0xe4e4ec))
4511                                                        .overflow_hidden()
4512                                                        .child(filename),
4513                                                )
4514                                                .child(
4515                                                    div()
4516                                                        .text_xs()
4517                                                        .text_color(status_color)
4518                                                        .child(SharedString::from(status_text)),
4519                                                )
4520                                                .when(is_active, |d| {
4521                                                    d.child(
4522                                                        div()
4523                                                            .h(px(4.0))
4524                                                            .w_full()
4525                                                            .rounded_sm()
4526                                                            .bg(gpui::rgb(0x2a2a32))
4527                                                            .child(
4528                                                                div()
4529                                                                    .h_full()
4530                                                                    .rounded_sm()
4531                                                                    .bg(gpui::rgb(0x50b0e0))
4532                                                                    .w(gpui::relative(
4533                                                                        progress_fraction,
4534                                                                    )),
4535                                                            ),
4536                                                    )
4537                                                }),
4538                                        )
4539                                        .child(if is_active {
4540                                            div()
4541                                                .id(("oxide_dl_cancel", idx))
4542                                                .cursor_pointer()
4543                                                .flex_shrink_0()
4544                                                .px_2()
4545                                                .py(px(4.0))
4546                                                .rounded_sm()
4547                                                .text_xs()
4548                                                .text_color(gpui::rgb(0xf05050))
4549                                                .hover(|s| s.bg(gpui::rgb(0x3a3a48)))
4550                                                .child("Cancel")
4551                                                .on_click(cx.listener(
4552                                                    move |this, _: &ClickEvent, _, cx| {
4553                                                        this.download_manager.cancel(dl_id);
4554                                                        cx.notify();
4555                                                    },
4556                                                ))
4557                                        } else {
4558                                            div()
4559                                                .id(("oxide_dl_dismiss", idx))
4560                                                .cursor_pointer()
4561                                                .flex_shrink_0()
4562                                                .px_2()
4563                                                .py(px(4.0))
4564                                                .rounded_sm()
4565                                                .text_xs()
4566                                                .text_color(gpui::rgb(0x9696a0))
4567                                                .hover(|s| s.bg(gpui::rgb(0x3a3a48)))
4568                                                .child("Dismiss")
4569                                                .on_click(cx.listener(
4570                                                    move |this, _: &ClickEvent, _, cx| {
4571                                                        this.download_manager.dismiss(dl_id);
4572                                                        cx.notify();
4573                                                    },
4574                                                ))
4575                                        })
4576                                })),
4577                        ),
4578                );
4579            }
4580        }
4581
4582        if let Some(url) = self.tabs[active].hovered_link_url.clone() {
4583            root = root.child(
4584                div()
4585                    .id("oxide_link_status")
4586                    .h(px(18.0))
4587                    .border_t_1()
4588                    .border_color(gpui::rgb(0x2a2a32))
4589                    .px_2()
4590                    .font_family("Monaco")
4591                    .text_xs()
4592                    .text_color(gpui::rgb(0x8c8cb4))
4593                    .child(url),
4594            );
4595        }
4596
4597        // Permission prompt (Chrome-style: top-left, under the toolbar). Shown while a guest
4598        // request for a sensitive API (camera, microphone, location, screen) awaits a decision.
4599        {
4600            let pending = self.tabs[active]
4601                .host_state
4602                .permissions
4603                .lock()
4604                .unwrap()
4605                .pending
4606                .clone();
4607            if let Some(req) = pending {
4608                let origin = SharedString::from(req.origin.clone());
4609                let request_line = SharedString::from(req.kind.description());
4610                root = root.child(
4611                    div()
4612                        .id("oxide_permission_prompt")
4613                        .absolute()
4614                        .top(px(92.0))
4615                        .left(px(8.0))
4616                        .w(px(320.0))
4617                        .rounded_md()
4618                        .bg(gpui::rgb(0x2c2c36))
4619                        .border_1()
4620                        .border_color(gpui::rgb(0x3a3a44))
4621                        .shadow_lg()
4622                        .p_3()
4623                        .flex()
4624                        .flex_col()
4625                        .gap_2()
4626                        .child(
4627                            div()
4628                                .text_xs()
4629                                .text_color(gpui::rgb(0x9696a0))
4630                                .overflow_hidden()
4631                                .child(origin),
4632                        )
4633                        .child(
4634                            div()
4635                                .text_sm()
4636                                .text_color(gpui::rgb(0xe4e4ec))
4637                                .child(SharedString::from(format!("wants to: {request_line}"))),
4638                        )
4639                        .child(
4640                            div()
4641                                .flex()
4642                                .flex_row()
4643                                .justify_end()
4644                                .gap_2()
4645                                .child(
4646                                    div()
4647                                        .id("oxide_permission_block")
4648                                        .cursor_pointer()
4649                                        .px_3()
4650                                        .py(px(6.0))
4651                                        .rounded_sm()
4652                                        .text_sm()
4653                                        .text_color(gpui::rgb(0xdcdce6))
4654                                        .bg(gpui::rgb(0x3a3a44))
4655                                        .hover(|s| s.bg(gpui::rgb(0x4a4a56)))
4656                                        .child("Block")
4657                                        .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
4658                                            let perms = this.tabs[this.active_tab]
4659                                                .host_state
4660                                                .permissions
4661                                                .clone();
4662                                            crate::permissions::resolve_pending(&perms, false);
4663                                            cx.notify();
4664                                        })),
4665                                )
4666                                .child(
4667                                    div()
4668                                        .id("oxide_permission_allow")
4669                                        .cursor_pointer()
4670                                        .px_3()
4671                                        .py(px(6.0))
4672                                        .rounded_sm()
4673                                        .text_sm()
4674                                        .font_weight(gpui::FontWeight::SEMIBOLD)
4675                                        .text_color(gpui::rgb(theme::PRIMARY_FG))
4676                                        .bg(gpui::rgb(theme::PRIMARY))
4677                                        .hover(|s| s.bg(gpui::rgb(0xd4d4d8)))
4678                                        .child("Allow")
4679                                        .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
4680                                            let perms = this.tabs[this.active_tab]
4681                                                .host_state
4682                                                .permissions
4683                                                .clone();
4684                                            crate::permissions::resolve_pending(&perms, true);
4685                                            cx.notify();
4686                                        })),
4687                                ),
4688                        )
4689                        .child(
4690                            div()
4691                                .text_xs()
4692                                .text_color(gpui::rgb(0x70707a))
4693                                .child("Enter to allow \u{00b7} Esc to block"),
4694                        ),
4695                );
4696            }
4697        }
4698
4699        if self.show_menu {
4700            root = root.child(
4701                div()
4702                    .id("oxide_menu_scrim")
4703                    .absolute()
4704                    .size_full()
4705                    .top_0()
4706                    .left_0()
4707                    .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
4708                        this.show_menu = false;
4709                        cx.notify();
4710                    })),
4711            );
4712            root = root.child(
4713                div()
4714                    .id("oxide_menu_dropdown")
4715                    .absolute()
4716                    .top(px(88.0))
4717                    .right(px(8.0))
4718                    .w(px(180.0))
4719                    .rounded_md()
4720                    .bg(gpui::rgb(0x2c2c36))
4721                    .border_1()
4722                    .border_color(gpui::rgb(0x3a3a44))
4723                    .py_1()
4724                    .shadow_lg()
4725                    .child(
4726                        div()
4727                            .id("oxide_menu_new_tab")
4728                            .px_3()
4729                            .py(px(8.0))
4730                            .cursor_pointer()
4731                            .text_sm()
4732                            .text_color(gpui::rgb(0xdcdce6))
4733                            .hover(|s| s.bg(gpui::rgb(0x3a3a48)))
4734                            .rounded_sm()
4735                            .child("  New Tab")
4736                            .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
4737                                let i = this.create_tab();
4738                                this.active_tab = i;
4739                                this.show_menu = false;
4740                                cx.notify();
4741                            })),
4742                    )
4743                    .child(div().h(px(1.0)).mx_2().my_1().bg(gpui::rgb(0x3a3a44)))
4744                    .child(
4745                        div()
4746                            .id("oxide_menu_bookmarks")
4747                            .px_3()
4748                            .py(px(8.0))
4749                            .cursor_pointer()
4750                            .text_sm()
4751                            .text_color(gpui::rgb(0xdcdce6))
4752                            .hover(|s| s.bg(gpui::rgb(0x3a3a48)))
4753                            .rounded_sm()
4754                            .child(if self.show_bookmarks {
4755                                "✓ Bookmarks"
4756                            } else {
4757                                "  Bookmarks"
4758                            })
4759                            .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
4760                                this.show_bookmarks = !this.show_bookmarks;
4761                                this.show_menu = false;
4762                                cx.notify();
4763                            })),
4764                    )
4765                    .child(
4766                        div()
4767                            .id("oxide_menu_console")
4768                            .px_3()
4769                            .py(px(8.0))
4770                            .cursor_pointer()
4771                            .text_sm()
4772                            .text_color(gpui::rgb(0xdcdce6))
4773                            .hover(|s| s.bg(gpui::rgb(0x3a3a48)))
4774                            .rounded_sm()
4775                            .child(if show_console {
4776                                "✓ Console"
4777                            } else {
4778                                "  Console"
4779                            })
4780                            .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
4781                                this.tabs[this.active_tab].show_console =
4782                                    !this.tabs[this.active_tab].show_console;
4783                                this.show_menu = false;
4784                                cx.notify();
4785                            })),
4786                    )
4787                    .child(
4788                        div()
4789                            .id("oxide_menu_downloads")
4790                            .px_3()
4791                            .py(px(8.0))
4792                            .cursor_pointer()
4793                            .text_sm()
4794                            .text_color(gpui::rgb(0xdcdce6))
4795                            .hover(|s| s.bg(gpui::rgb(0x3a3a48)))
4796                            .rounded_sm()
4797                            .child(if self.show_downloads {
4798                                "✓ Downloads"
4799                            } else {
4800                                "  Downloads"
4801                            })
4802                            .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
4803                                this.show_downloads = !this.show_downloads;
4804                                this.show_menu = false;
4805                                cx.notify();
4806                            })),
4807                    )
4808                    .child(
4809                        div()
4810                            .id("oxide_menu_history")
4811                            .px_3()
4812                            .py(px(8.0))
4813                            .cursor_pointer()
4814                            .text_sm()
4815                            .text_color(gpui::rgb(0xdcdce6))
4816                            .hover(|s| s.bg(gpui::rgb(0x3a3a48)))
4817                            .rounded_sm()
4818                            .child("  History")
4819                            .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
4820                                let i = this.create_tab();
4821                                this.active_tab = i;
4822                                this.tabs[i].navigate_to(
4823                                    "oxide://history".to_string(),
4824                                    true,
4825                                    &this.download_manager,
4826                                );
4827                                this.show_menu = false;
4828                                cx.notify();
4829                            })),
4830                    )
4831                    .child(div().h(px(1.0)).mx_2().my_1().bg(gpui::rgb(0x3a3a44)))
4832                    .child(
4833                        div()
4834                            .id("oxide_menu_about")
4835                            .px_3()
4836                            .py(px(8.0))
4837                            .cursor_pointer()
4838                            .text_sm()
4839                            .text_color(gpui::rgb(0xdcdce6))
4840                            .hover(|s| s.bg(gpui::rgb(0x3a3a48)))
4841                            .rounded_sm()
4842                            .child("  About Oxide")
4843                            .on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
4844                                let i = this.create_tab();
4845                                this.active_tab = i;
4846                                this.tabs[i].navigate_to(
4847                                    "oxide://about".to_string(),
4848                                    true,
4849                                    &this.download_manager,
4850                                );
4851                                this.show_menu = false;
4852                                cx.notify();
4853                            })),
4854                    ),
4855            );
4856        }
4857
4858        root
4859    }
4860}
4861
4862/// Render one chat bubble in the Forge conversation panel.
4863fn forge_chat_bubble(index: usize, msg: &ForgeChatMessage, phase: ForgePhase) -> impl IntoElement {
4864    let is_user = msg.role == ForgeMessageRole::User;
4865    let preview = if msg.content.len() > 4000 {
4866        format!("{}…", &msg.content[..4000])
4867    } else {
4868        msg.content.clone()
4869    };
4870    let streaming_tail = if !is_user && phase == ForgePhase::Streaming && msg.content.is_empty() {
4871        "▍"
4872    } else {
4873        ""
4874    };
4875    let (bg, fg, label) = if is_user {
4876        (gpui::rgb(0x3a2f5a), gpui::rgb(0xf0ecff), "You")
4877    } else {
4878        (gpui::rgb(0x242430), gpui::rgb(0xd8d8e8), "Forge")
4879    };
4880    div()
4881        .id(("oxide_forge_msg", index))
4882        .w_full()
4883        .flex()
4884        .flex_col()
4885        .items_start()
4886        .child(div().text_xs().text_color(gpui::rgb(0x7a7a90)).child(label))
4887        .child(
4888            div()
4889                .mt_1()
4890                .max_w(px(520.0))
4891                .px_3()
4892                .py_2()
4893                .rounded_md()
4894                .bg(bg)
4895                .text_sm()
4896                .text_color(fg)
4897                .font(if is_user {
4898                    font("Inter")
4899                } else {
4900                    font("Menlo")
4901                })
4902                .child(format!("{preview}{streaming_tail}")),
4903        )
4904}
4905
4906/// Human-readable label for a [`ForgePhase`].
4907fn phase_label(p: ForgePhase) -> &'static str {
4908    match p {
4909        ForgePhase::Idle => "idle",
4910        ForgePhase::Streaming => "streaming",
4911        ForgePhase::StreamComplete => "ready to build",
4912        ForgePhase::Building => "building",
4913        ForgePhase::BuildOk => "build ok",
4914        ForgePhase::Error => "error",
4915    }
4916}
4917
4918/// Themed color for a [`ForgePhase`] badge.
4919fn phase_color_for(p: ForgePhase) -> Rgba {
4920    match p {
4921        ForgePhase::Idle => gpui::rgb(0x8888a0),
4922        ForgePhase::Streaming => gpui::rgb(0xffc060),
4923        ForgePhase::StreamComplete => gpui::rgb(0x70b0ff),
4924        ForgePhase::Building => gpui::rgb(0xffa040),
4925        ForgePhase::BuildOk => gpui::rgb(0x80d090),
4926        ForgePhase::Error => gpui::rgb(0xf06070),
4927    }
4928}
4929
4930/// Byte offset of the start of the previous UTF-8 char from `cursor`.
4931fn prev_char_boundary(text: &str, cursor: usize) -> usize {
4932    if cursor == 0 {
4933        return 0;
4934    }
4935    let mut i = cursor - 1;
4936    while i > 0 && !text.is_char_boundary(i) {
4937        i -= 1;
4938    }
4939    i
4940}
4941
4942/// Byte offset of the start of the next UTF-8 char from `cursor`.
4943fn next_char_boundary(text: &str, cursor: usize) -> usize {
4944    if cursor >= text.len() {
4945        return text.len();
4946    }
4947    let mut i = cursor + 1;
4948    while i < text.len() && !text.is_char_boundary(i) {
4949        i += 1;
4950    }
4951    i
4952}
4953
4954/// Byte offset of the start of the current line.
4955fn line_start(text: &str, cursor: usize) -> usize {
4956    text[..cursor].rfind('\n').map(|i| i + 1).unwrap_or(0)
4957}
4958
4959/// Byte offset of the end (just before '\n' or text end) of the current line.
4960fn line_end(text: &str, cursor: usize) -> usize {
4961    let from = cursor.min(text.len());
4962    text[from..]
4963        .find('\n')
4964        .map(|i| from + i)
4965        .unwrap_or(text.len())
4966}
4967
4968/// True if any `WidgetCommand::Textarea` in the current frame has the given id.
4969fn is_textarea(commands: &[WidgetCommand], id: u32) -> bool {
4970    commands
4971        .iter()
4972        .any(|c| matches!(c, WidgetCommand::Textarea { id: cid, .. } if *cid == id))
4973}
4974
4975/// Apply a keystroke to the focused widget's text + edit state.
4976///
4977/// Mirrors the URL bar editing surface: arrow keys, home/end, selection with shift,
4978/// backspace/delete, copy/paste/cut/select-all, and (for textareas) up/down + enter.
4979fn handle_widget_key(view: &mut OxideBrowserView, id: u32, event: &KeyDownEvent) {
4980    let tab = &mut view.tabs[view.active_tab];
4981    let multi_line = {
4982        let cmds = tab.host_state.widget_commands.lock().unwrap();
4983        is_textarea(&cmds, id)
4984    };
4985
4986    let mut text = tab
4987        .host_state
4988        .widget_states
4989        .lock()
4990        .unwrap()
4991        .get(&id)
4992        .and_then(|v| match v {
4993            WidgetValue::Text(t) => Some(t.clone()),
4994            _ => None,
4995        })
4996        .unwrap_or_default();
4997    let mut edit = tab.widget_edits.get(&id).cloned().unwrap_or_default();
4998
4999    let shift = event.keystroke.modifiers.shift;
5000    let secondary = event.keystroke.modifiers.secondary();
5001
5002    let mut changed_text = false;
5003    let mut changed_edit = false;
5004
5005    let has_selection = |edit: &WidgetEditState| edit.cursor != edit.sel_start;
5006    let sel_range = |edit: &WidgetEditState| {
5007        let lo = edit.cursor.min(edit.sel_start);
5008        let hi = edit.cursor.max(edit.sel_start);
5009        lo..hi
5010    };
5011    let delete_selection = |text: &mut String, edit: &mut WidgetEditState| {
5012        if !has_selection(edit) {
5013            return false;
5014        }
5015        let r = sel_range(edit);
5016        text.replace_range(r.clone(), "");
5017        edit.cursor = r.start;
5018        edit.sel_start = r.start;
5019        true
5020    };
5021    let insert_at = |text: &mut String, edit: &mut WidgetEditState, ins: &str| {
5022        let _ = delete_selection(text, edit);
5023        text.insert_str(edit.cursor, ins);
5024        edit.cursor += ins.len();
5025        edit.sel_start = edit.cursor;
5026    };
5027
5028    if secondary {
5029        match event.keystroke.key.as_str() {
5030            "a" => {
5031                edit.sel_start = 0;
5032                edit.cursor = text.len();
5033                changed_edit = true;
5034            }
5035            "c" if has_selection(&edit) => {
5036                if let Ok(mut cb) = arboard::Clipboard::new() {
5037                    let _ = cb.set_text(&text[sel_range(&edit)]);
5038                }
5039            }
5040            "x" if has_selection(&edit) => {
5041                if let Ok(mut cb) = arboard::Clipboard::new() {
5042                    let _ = cb.set_text(&text[sel_range(&edit)]);
5043                }
5044                let _ = delete_selection(&mut text, &mut edit);
5045                changed_text = true;
5046                changed_edit = true;
5047            }
5048            "v" => {
5049                if let Ok(Ok(pasted)) = arboard::Clipboard::new().map(|mut cb| cb.get_text()) {
5050                    let to_insert = if multi_line {
5051                        pasted
5052                    } else {
5053                        pasted.replace('\n', " ")
5054                    };
5055                    insert_at(&mut text, &mut edit, &to_insert);
5056                    changed_text = true;
5057                    changed_edit = true;
5058                }
5059            }
5060            _ => {}
5061        }
5062    } else {
5063        match event.keystroke.key.as_str() {
5064            "left" => {
5065                if shift {
5066                    let p = prev_char_boundary(&text, edit.cursor);
5067                    edit.select_to(p, text.len());
5068                } else if has_selection(&edit) {
5069                    let lo = sel_range(&edit).start;
5070                    edit.move_to(lo, text.len());
5071                } else {
5072                    let p = prev_char_boundary(&text, edit.cursor);
5073                    edit.move_to(p, text.len());
5074                }
5075                changed_edit = true;
5076            }
5077            "right" => {
5078                if shift {
5079                    let n = next_char_boundary(&text, edit.cursor);
5080                    edit.select_to(n, text.len());
5081                } else if has_selection(&edit) {
5082                    let hi = sel_range(&edit).end;
5083                    edit.move_to(hi, text.len());
5084                } else {
5085                    let n = next_char_boundary(&text, edit.cursor);
5086                    edit.move_to(n, text.len());
5087                }
5088                changed_edit = true;
5089            }
5090            "up" if multi_line => {
5091                let ls = line_start(&text, edit.cursor);
5092                let col = edit.cursor - ls;
5093                if ls == 0 {
5094                    if shift {
5095                        edit.select_to(0, text.len());
5096                    } else {
5097                        edit.move_to(0, text.len());
5098                    }
5099                } else {
5100                    let prev_end = ls - 1;
5101                    let prev_start = line_start(&text, prev_end);
5102                    let new_pos = prev_start + col.min(prev_end - prev_start);
5103                    if shift {
5104                        edit.select_to(new_pos, text.len());
5105                    } else {
5106                        edit.move_to(new_pos, text.len());
5107                    }
5108                }
5109                changed_edit = true;
5110            }
5111            "down" if multi_line => {
5112                let ls = line_start(&text, edit.cursor);
5113                let le = line_end(&text, edit.cursor);
5114                let col = edit.cursor - ls;
5115                if le >= text.len() {
5116                    let l = text.len();
5117                    if shift {
5118                        edit.select_to(l, text.len());
5119                    } else {
5120                        edit.move_to(l, text.len());
5121                    }
5122                } else {
5123                    let next_start = le + 1;
5124                    let next_end = line_end(&text, next_start);
5125                    let new_pos = next_start + col.min(next_end - next_start);
5126                    if shift {
5127                        edit.select_to(new_pos, text.len());
5128                    } else {
5129                        edit.move_to(new_pos, text.len());
5130                    }
5131                }
5132                changed_edit = true;
5133            }
5134            "home" => {
5135                let target = if multi_line {
5136                    line_start(&text, edit.cursor)
5137                } else {
5138                    0
5139                };
5140                if shift {
5141                    edit.select_to(target, text.len());
5142                } else {
5143                    edit.move_to(target, text.len());
5144                }
5145                changed_edit = true;
5146            }
5147            "end" => {
5148                let target = if multi_line {
5149                    line_end(&text, edit.cursor)
5150                } else {
5151                    text.len()
5152                };
5153                if shift {
5154                    edit.select_to(target, text.len());
5155                } else {
5156                    edit.move_to(target, text.len());
5157                }
5158                changed_edit = true;
5159            }
5160            "backspace" => {
5161                if has_selection(&edit) {
5162                    let _ = delete_selection(&mut text, &mut edit);
5163                    changed_text = true;
5164                    changed_edit = true;
5165                } else if edit.cursor > 0 {
5166                    let p = prev_char_boundary(&text, edit.cursor);
5167                    text.replace_range(p..edit.cursor, "");
5168                    edit.cursor = p;
5169                    edit.sel_start = p;
5170                    changed_text = true;
5171                    changed_edit = true;
5172                }
5173            }
5174            "delete" => {
5175                if has_selection(&edit) {
5176                    let _ = delete_selection(&mut text, &mut edit);
5177                    changed_text = true;
5178                    changed_edit = true;
5179                } else if edit.cursor < text.len() {
5180                    let n = next_char_boundary(&text, edit.cursor);
5181                    text.replace_range(edit.cursor..n, "");
5182                    changed_text = true;
5183                }
5184            }
5185            "enter" if multi_line => {
5186                insert_at(&mut text, &mut edit, "\n");
5187                changed_text = true;
5188                changed_edit = true;
5189            }
5190            _ => {
5191                if let Some(s) = text_insert_from_keystroke(&event.keystroke) {
5192                    insert_at(&mut text, &mut edit, &s);
5193                    changed_text = true;
5194                    changed_edit = true;
5195                }
5196            }
5197        }
5198    }
5199
5200    if changed_text {
5201        tab.host_state
5202            .widget_states
5203            .lock()
5204            .unwrap()
5205            .insert(id, WidgetValue::Text(text));
5206    }
5207    if changed_edit {
5208        tab.widget_edits.insert(id, edit);
5209    }
5210}
5211
5212/// Colour scheme for one variant (bg, fg, border).
5213fn variant_colors(variant: WidgetVariant) -> (u32, u32, u32) {
5214    match variant {
5215        WidgetVariant::Default => (theme::PRIMARY, theme::PRIMARY_FG, theme::PRIMARY),
5216        WidgetVariant::Secondary => (theme::SURFACE_HOVER, theme::FG, theme::SURFACE_HOVER),
5217        WidgetVariant::Outline => (theme::BG, theme::FG, theme::BORDER_STRONG),
5218        WidgetVariant::Ghost => (theme::BG, theme::FG, theme::BG),
5219        WidgetVariant::Destructive => (theme::DESTRUCTIVE, theme::FG, theme::DESTRUCTIVE),
5220    }
5221}
5222
5223/// Hover-state background tint for a variant.
5224fn variant_hover_bg(variant: WidgetVariant) -> u32 {
5225    match variant {
5226        WidgetVariant::Default => 0xe4e4e7,
5227        WidgetVariant::Secondary => theme::BORDER_STRONG,
5228        WidgetVariant::Outline | WidgetVariant::Ghost => theme::SURFACE_HOVER,
5229        WidgetVariant::Destructive => 0x991b1b,
5230    }
5231}
5232
5233#[allow(clippy::too_many_arguments)]
5234fn render_button(
5235    cx: &mut gpui::Context<OxideBrowserView>,
5236    id: u32,
5237    x: f32,
5238    y: f32,
5239    w: f32,
5240    h: f32,
5241    label: String,
5242    variant: WidgetVariant,
5243) -> impl IntoElement {
5244    let (bg, fg, border) = variant_colors(variant);
5245    let hover_bg = variant_hover_bg(variant);
5246    div()
5247        .id(("oxide_btn", id as usize))
5248        .absolute()
5249        .left(px(x))
5250        .top(px(y))
5251        .w(px(w))
5252        .h(px(h))
5253        .flex()
5254        .items_center()
5255        .justify_center()
5256        .rounded_md()
5257        .bg(gpui::rgb(bg))
5258        .border_1()
5259        .border_color(gpui::rgb(border))
5260        .hover(|s| s.bg(gpui::rgb(hover_bg)))
5261        .cursor_pointer()
5262        .text_sm()
5263        .font_weight(gpui::FontWeight::MEDIUM)
5264        .text_color(gpui::rgb(fg))
5265        .child(label)
5266        .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
5267            this.tabs[this.active_tab]
5268                .host_state
5269                .widget_clicked
5270                .lock()
5271                .unwrap()
5272                .insert(id);
5273            cx.notify();
5274        }))
5275}
5276
5277fn render_checkbox(
5278    cx: &mut gpui::Context<OxideBrowserView>,
5279    id: u32,
5280    x: f32,
5281    y: f32,
5282    label: &str,
5283    checked: bool,
5284) -> impl IntoElement {
5285    let label = label.to_string();
5286    div()
5287        .id(("oxide_cb", id as usize))
5288        .absolute()
5289        .left(px(x))
5290        .top(px(y))
5291        .h(px(26.0))
5292        .flex()
5293        .flex_row()
5294        .items_center()
5295        .gap_2()
5296        .cursor_pointer()
5297        .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
5298            let mut states = this.tabs[this.active_tab]
5299                .host_state
5300                .widget_states
5301                .lock()
5302                .unwrap();
5303            let cur = states
5304                .get(&id)
5305                .and_then(|v| match v {
5306                    WidgetValue::Bool(b) => Some(*b),
5307                    _ => None,
5308                })
5309                .unwrap_or(false);
5310            states.insert(id, WidgetValue::Bool(!cur));
5311            cx.notify();
5312        }))
5313        .child(
5314            div()
5315                .w(px(16.0))
5316                .h(px(16.0))
5317                .rounded_sm()
5318                .border_1()
5319                .border_color(gpui::rgb(if checked {
5320                    theme::PRIMARY
5321                } else {
5322                    theme::BORDER_STRONG
5323                }))
5324                .bg(gpui::rgb(if checked { theme::PRIMARY } else { theme::BG }))
5325                .flex()
5326                .items_center()
5327                .justify_center()
5328                .text_color(gpui::rgb(theme::PRIMARY_FG))
5329                .text_xs()
5330                .child(if checked { "✓" } else { "" }),
5331        )
5332        .child(
5333            div()
5334                .text_sm()
5335                .text_color(gpui::rgb(theme::FG))
5336                .child(label),
5337        )
5338}
5339
5340fn render_switch(
5341    cx: &mut gpui::Context<OxideBrowserView>,
5342    id: u32,
5343    x: f32,
5344    y: f32,
5345    label: &str,
5346    checked: bool,
5347) -> impl IntoElement {
5348    let label = label.to_string();
5349    let track_w = 32.0;
5350    let track_h = 18.0;
5351    let knob = 14.0;
5352    let knob_x = if checked { track_w - knob - 2.0 } else { 2.0 };
5353    div()
5354        .id(("oxide_sw", id as usize))
5355        .absolute()
5356        .left(px(x))
5357        .top(px(y))
5358        .h(px(24.0))
5359        .flex()
5360        .flex_row()
5361        .items_center()
5362        .gap_2()
5363        .cursor_pointer()
5364        .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
5365            let mut states = this.tabs[this.active_tab]
5366                .host_state
5367                .widget_states
5368                .lock()
5369                .unwrap();
5370            let cur = states
5371                .get(&id)
5372                .and_then(|v| match v {
5373                    WidgetValue::Bool(b) => Some(*b),
5374                    _ => None,
5375                })
5376                .unwrap_or(false);
5377            states.insert(id, WidgetValue::Bool(!cur));
5378            cx.notify();
5379        }))
5380        .child(
5381            div()
5382                .relative()
5383                .w(px(track_w))
5384                .h(px(track_h))
5385                .rounded_full()
5386                .bg(gpui::rgb(if checked {
5387                    theme::PRIMARY
5388                } else {
5389                    theme::MUTED
5390                }))
5391                .child(
5392                    div()
5393                        .absolute()
5394                        .top(px((track_h - knob) / 2.0))
5395                        .left(px(knob_x))
5396                        .w(px(knob))
5397                        .h(px(knob))
5398                        .rounded_full()
5399                        .bg(gpui::rgb(if checked {
5400                            theme::PRIMARY_FG
5401                        } else {
5402                            theme::FG
5403                        })),
5404                ),
5405        )
5406        .child(
5407            div()
5408                .text_sm()
5409                .text_color(gpui::rgb(theme::FG))
5410                .child(label),
5411        )
5412}
5413
5414#[allow(clippy::too_many_arguments)]
5415fn render_slider(
5416    cx: &mut gpui::Context<OxideBrowserView>,
5417    id: u32,
5418    x: f32,
5419    y: f32,
5420    w: f32,
5421    min: f32,
5422    max: f32,
5423    cur: f32,
5424) -> impl IntoElement {
5425    let frac = if max > min {
5426        ((cur - min) / (max - min)).clamp(0.0, 1.0)
5427    } else {
5428        0.0
5429    };
5430    let handle_size: f32 = 16.0;
5431    let handle_left = (frac * w - handle_size / 2.0).clamp(0.0, w - handle_size);
5432    div()
5433        .id(("oxide_sl", id as usize))
5434        .absolute()
5435        .left(px(x))
5436        .top(px(y))
5437        .w(px(w))
5438        .h(px(28.0))
5439        .flex()
5440        .items_center()
5441        .on_mouse_down(
5442            MouseButton::Left,
5443            cx.listener(move |this, event: &MouseDownEvent, _, cx| {
5444                this.slider_drag = Some((id, x, w, min, max));
5445                let tab = &mut this.tabs[this.active_tab];
5446                let (ox, _) = *tab.host_state.canvas_offset.lock().unwrap();
5447                let lx = f32::from(event.position.x) - ox;
5448                let frac = ((lx - x) / w).clamp(0.0, 1.0);
5449                let v = min + frac * (max - min);
5450                tab.host_state
5451                    .widget_states
5452                    .lock()
5453                    .unwrap()
5454                    .insert(id, WidgetValue::Float(v));
5455                cx.notify();
5456            }),
5457        )
5458        .child(
5459            div()
5460                .absolute()
5461                .left(px(0.0))
5462                .top(px(12.0))
5463                .w(px(w))
5464                .h(px(4.0))
5465                .rounded_full()
5466                .bg(gpui::rgb(theme::MUTED)),
5467        )
5468        .child(
5469            div()
5470                .absolute()
5471                .left(px(0.0))
5472                .top(px(12.0))
5473                .w(px(frac * w))
5474                .h(px(4.0))
5475                .rounded_full()
5476                .bg(gpui::rgb(theme::PRIMARY)),
5477        )
5478        .child(
5479            div()
5480                .absolute()
5481                .left(px(handle_left))
5482                .top(px((28.0 - handle_size) / 2.0))
5483                .w(px(handle_size))
5484                .h(px(handle_size))
5485                .rounded_full()
5486                .bg(gpui::rgb(theme::PRIMARY))
5487                .border_2()
5488                .border_color(gpui::rgb(theme::BG)),
5489        )
5490}
5491
5492#[allow(clippy::too_many_arguments)]
5493fn render_text_input(
5494    cx: &mut gpui::Context<OxideBrowserView>,
5495    id: u32,
5496    x: f32,
5497    y: f32,
5498    w: f32,
5499    placeholder: String,
5500    value: String,
5501    edit: WidgetEditState,
5502    focused: bool,
5503    caret_blink_on: bool,
5504    bounds_ref: Arc<Mutex<Bounds<Pixels>>>,
5505) -> impl IntoElement {
5506    let value_for_canvas = SharedString::from(value.clone());
5507    let placeholder_for_canvas = SharedString::from(placeholder.clone());
5508    let bounds_for_measure = bounds_ref.clone();
5509    let cursor = edit.cursor;
5510    let sel_start = edit.sel_start;
5511
5512    div()
5513        .id(("oxide_ti", id as usize))
5514        .absolute()
5515        .left(px(x))
5516        .top(px(y))
5517        .w(px(w))
5518        .h(px(36.0))
5519        .px_3()
5520        .py(px(8.0))
5521        .rounded_md()
5522        .bg(gpui::rgb(theme::BG))
5523        .border_1()
5524        .border_color(gpui::rgb(if focused { theme::RING } else { theme::BORDER }))
5525        .cursor_text()
5526        .flex()
5527        .flex_row()
5528        .items_center()
5529        .overflow_hidden()
5530        .on_mouse_down(
5531            MouseButton::Left,
5532            cx.listener(move |this, event: &MouseDownEvent, window, cx| {
5533                let tab = &mut this.tabs[this.active_tab];
5534                tab.text_input_focus = Some(id);
5535                this.canvas_focus.focus(window);
5536                let bounds = *bounds_ref.lock().unwrap();
5537                let text = SharedString::from(
5538                    tab.host_state
5539                        .widget_states
5540                        .lock()
5541                        .unwrap()
5542                        .get(&id)
5543                        .and_then(|v| match v {
5544                            WidgetValue::Text(t) => Some(t.clone()),
5545                            _ => None,
5546                        })
5547                        .unwrap_or_default(),
5548                );
5549                let max = text.len();
5550                if text.is_empty() {
5551                    let edit = tab.widget_edits.entry(id).or_default();
5552                    edit.move_to(0, 0);
5553                    edit.selecting = true;
5554                } else {
5555                    let rel_x = f32::from(event.position.x) - f32::from(bounds.origin.x);
5556                    let run = TextRun {
5557                        len: text.len(),
5558                        font: font(".SystemUIFont"),
5559                        color: rgba8(0xfa, 0xfa, 0xfa, 0xff),
5560                        background_color: None,
5561                        underline: None,
5562                        strikethrough: None,
5563                    };
5564                    let line =
5565                        window
5566                            .text_system()
5567                            .shape_line(text.clone(), px(14.0), &[run], None);
5568                    let idx = line.closest_index_for_x(px(rel_x));
5569                    let edit = tab.widget_edits.entry(id).or_default();
5570                    if event.modifiers.shift {
5571                        edit.select_to(idx, max);
5572                    } else {
5573                        edit.move_to(idx, max);
5574                    }
5575                    edit.selecting = true;
5576                }
5577                cx.notify();
5578            }),
5579        )
5580        .on_mouse_up(
5581            MouseButton::Left,
5582            cx.listener(move |this, _: &MouseUpEvent, _, _cx| {
5583                if let Some(edit) = this.tabs[this.active_tab].widget_edits.get_mut(&id) {
5584                    edit.selecting = false;
5585                }
5586            }),
5587        )
5588        .on_mouse_move(
5589            cx.listener(move |this, event: &gpui::MouseMoveEvent, window, cx| {
5590                let tab = &mut this.tabs[this.active_tab];
5591                let selecting = tab
5592                    .widget_edits
5593                    .get(&id)
5594                    .map(|e| e.selecting)
5595                    .unwrap_or(false);
5596                if !selecting {
5597                    return;
5598                }
5599                let text = SharedString::from(
5600                    tab.host_state
5601                        .widget_states
5602                        .lock()
5603                        .unwrap()
5604                        .get(&id)
5605                        .and_then(|v| match v {
5606                            WidgetValue::Text(t) => Some(t.clone()),
5607                            _ => None,
5608                        })
5609                        .unwrap_or_default(),
5610                );
5611                if text.is_empty() {
5612                    return;
5613                }
5614                let max = text.len();
5615                let bounds = tab
5616                    .widget_bounds_cache
5617                    .get(&id)
5618                    .map(|b| *b.lock().unwrap())
5619                    .unwrap_or_default();
5620                let rel_x = f32::from(event.position.x) - f32::from(bounds.origin.x);
5621                let run = TextRun {
5622                    len: text.len(),
5623                    font: font(".SystemUIFont"),
5624                    color: rgba8(0xfa, 0xfa, 0xfa, 0xff),
5625                    background_color: None,
5626                    underline: None,
5627                    strikethrough: None,
5628                };
5629                let line = window
5630                    .text_system()
5631                    .shape_line(text.clone(), px(14.0), &[run], None);
5632                let idx = line.closest_index_for_x(px(rel_x));
5633                if let Some(edit) = tab.widget_edits.get_mut(&id) {
5634                    edit.select_to(idx, max);
5635                }
5636                cx.notify();
5637            }),
5638        )
5639        .child(
5640            canvas(
5641                move |bounds, window, _cx| {
5642                    *bounds_for_measure.lock().unwrap() = bounds;
5643                    if value_for_canvas.is_empty() {
5644                        if placeholder_for_canvas.is_empty() {
5645                            return (None, None);
5646                        }
5647                        let placeholder_run = TextRun {
5648                            len: placeholder_for_canvas.len(),
5649                            font: font(".SystemUIFont"),
5650                            color: rgba8(0x71, 0x71, 0x7a, 0xff),
5651                            background_color: None,
5652                            underline: None,
5653                            strikethrough: None,
5654                        };
5655                        let placeholder_line = window.text_system().shape_line(
5656                            placeholder_for_canvas.clone(),
5657                            px(14.0),
5658                            &[placeholder_run],
5659                            None,
5660                        );
5661                        return (None, Some(placeholder_line));
5662                    }
5663                    let run = TextRun {
5664                        len: value_for_canvas.len(),
5665                        font: font(".SystemUIFont"),
5666                        color: rgba8(0xfa, 0xfa, 0xfa, 0xff),
5667                        background_color: None,
5668                        underline: None,
5669                        strikethrough: None,
5670                    };
5671                    let line = window.text_system().shape_line(
5672                        value_for_canvas.clone(),
5673                        px(14.0),
5674                        &[run],
5675                        None,
5676                    );
5677                    (Some(line), None)
5678                },
5679                {
5680                    move |bounds,
5681                          state: (Option<gpui::ShapedLine>, Option<gpui::ShapedLine>),
5682                          window,
5683                          cx| {
5684                        let (line_opt, placeholder_opt) = state;
5685                        let has_sel = cursor != sel_start;
5686                        let sel_lo = cursor.min(sel_start);
5687                        let sel_hi = cursor.max(sel_start);
5688
5689                        if let Some(ref line) = line_opt {
5690                            if has_sel {
5691                                let sx = line.x_for_index(sel_lo);
5692                                let ex = line.x_for_index(sel_hi);
5693                                window.paint_quad(gpui::fill(
5694                                    Bounds::from_corners(
5695                                        point(bounds.origin.x + sx, bounds.origin.y),
5696                                        point(
5697                                            bounds.origin.x + ex,
5698                                            bounds.origin.y + bounds.size.height,
5699                                        ),
5700                                    ),
5701                                    theme::selection(),
5702                                ));
5703                            }
5704                            let _ = line.paint(bounds.origin, bounds.size.height, window, cx);
5705                            if focused && !has_sel && caret_blink_on {
5706                                let cx_pos = line.x_for_index(cursor);
5707                                window.paint_quad(gpui::fill(
5708                                    Bounds::from_corners(
5709                                        point(bounds.origin.x + cx_pos, bounds.origin.y),
5710                                        point(
5711                                            bounds.origin.x + cx_pos + px(1.5),
5712                                            bounds.origin.y + bounds.size.height,
5713                                        ),
5714                                    ),
5715                                    rgba8(0xfa, 0xfa, 0xfa, 0xff),
5716                                ));
5717                            }
5718                        } else if let Some(ref placeholder) = placeholder_opt {
5719                            let _ =
5720                                placeholder.paint(bounds.origin, bounds.size.height, window, cx);
5721                            if focused && caret_blink_on {
5722                                window.paint_quad(gpui::fill(
5723                                    Bounds::from_corners(
5724                                        bounds.origin,
5725                                        point(
5726                                            bounds.origin.x + px(1.5),
5727                                            bounds.origin.y + bounds.size.height,
5728                                        ),
5729                                    ),
5730                                    rgba8(0xfa, 0xfa, 0xfa, 0xff),
5731                                ));
5732                            }
5733                        } else if focused && caret_blink_on {
5734                            window.paint_quad(gpui::fill(
5735                                Bounds::from_corners(
5736                                    bounds.origin,
5737                                    point(
5738                                        bounds.origin.x + px(1.5),
5739                                        bounds.origin.y + bounds.size.height,
5740                                    ),
5741                                ),
5742                                rgba8(0xfa, 0xfa, 0xfa, 0xff),
5743                            ));
5744                        }
5745                    }
5746                },
5747            )
5748            .flex_1()
5749            .h(px(18.0)),
5750        )
5751}
5752
5753#[allow(clippy::too_many_arguments)]
5754fn render_textarea(
5755    cx: &mut gpui::Context<OxideBrowserView>,
5756    id: u32,
5757    x: f32,
5758    y: f32,
5759    w: f32,
5760    h: f32,
5761    placeholder: String,
5762    value: String,
5763    edit: WidgetEditState,
5764    focused: bool,
5765    caret_blink_on: bool,
5766    bounds_ref: Arc<Mutex<Bounds<Pixels>>>,
5767) -> impl IntoElement {
5768    let value_for_canvas = value.clone();
5769    let placeholder_for_canvas = SharedString::from(placeholder);
5770    let bounds_for_measure = bounds_ref.clone();
5771    let cursor = edit.cursor;
5772    let sel_start = edit.sel_start;
5773    let scroll_y = edit.scroll_y;
5774
5775    div()
5776        .id(("oxide_ta", id as usize))
5777        .absolute()
5778        .left(px(x))
5779        .top(px(y))
5780        .w(px(w))
5781        .h(px(h))
5782        .px_3()
5783        .py_2()
5784        .rounded_md()
5785        .bg(gpui::rgb(theme::BG))
5786        .border_1()
5787        .border_color(gpui::rgb(if focused { theme::RING } else { theme::BORDER }))
5788        .cursor_text()
5789        .overflow_hidden()
5790        .on_mouse_down(
5791            MouseButton::Left,
5792            cx.listener(move |this, event: &MouseDownEvent, window, cx| {
5793                let tab = &mut this.tabs[this.active_tab];
5794                tab.text_input_focus = Some(id);
5795                this.canvas_focus.focus(window);
5796                let text = tab
5797                    .host_state
5798                    .widget_states
5799                    .lock()
5800                    .unwrap()
5801                    .get(&id)
5802                    .and_then(|v| match v {
5803                        WidgetValue::Text(t) => Some(t.clone()),
5804                        _ => None,
5805                    })
5806                    .unwrap_or_default();
5807                let bounds = tab
5808                    .widget_bounds_cache
5809                    .get(&id)
5810                    .map(|b| *b.lock().unwrap())
5811                    .unwrap_or_default();
5812                let scroll_y = tab.widget_edits.get(&id).map(|e| e.scroll_y).unwrap_or(0.0);
5813                let idx = textarea_hit_index(
5814                    &text,
5815                    f32::from(event.position.x) - f32::from(bounds.origin.x),
5816                    f32::from(event.position.y) - f32::from(bounds.origin.y) + scroll_y,
5817                    window,
5818                );
5819                let max = text.len();
5820                let edit = tab.widget_edits.entry(id).or_default();
5821                if event.modifiers.shift {
5822                    edit.select_to(idx, max);
5823                } else {
5824                    edit.move_to(idx, max);
5825                }
5826                edit.selecting = true;
5827                cx.notify();
5828            }),
5829        )
5830        .on_mouse_up(
5831            MouseButton::Left,
5832            cx.listener(move |this, _: &MouseUpEvent, _, _cx| {
5833                if let Some(edit) = this.tabs[this.active_tab].widget_edits.get_mut(&id) {
5834                    edit.selecting = false;
5835                }
5836            }),
5837        )
5838        .on_mouse_move(
5839            cx.listener(move |this, event: &gpui::MouseMoveEvent, window, cx| {
5840                let tab = &mut this.tabs[this.active_tab];
5841                let selecting = tab
5842                    .widget_edits
5843                    .get(&id)
5844                    .map(|e| e.selecting)
5845                    .unwrap_or(false);
5846                if !selecting {
5847                    return;
5848                }
5849                let text = tab
5850                    .host_state
5851                    .widget_states
5852                    .lock()
5853                    .unwrap()
5854                    .get(&id)
5855                    .and_then(|v| match v {
5856                        WidgetValue::Text(t) => Some(t.clone()),
5857                        _ => None,
5858                    })
5859                    .unwrap_or_default();
5860                let bounds = tab
5861                    .widget_bounds_cache
5862                    .get(&id)
5863                    .map(|b| *b.lock().unwrap())
5864                    .unwrap_or_default();
5865                let scroll_y = tab.widget_edits.get(&id).map(|e| e.scroll_y).unwrap_or(0.0);
5866                let idx = textarea_hit_index(
5867                    &text,
5868                    f32::from(event.position.x) - f32::from(bounds.origin.x),
5869                    f32::from(event.position.y) - f32::from(bounds.origin.y) + scroll_y,
5870                    window,
5871                );
5872                let max = text.len();
5873                if let Some(edit) = tab.widget_edits.get_mut(&id) {
5874                    edit.select_to(idx, max);
5875                }
5876                cx.notify();
5877            }),
5878        )
5879        .on_scroll_wheel(cx.listener(move |this, event: &ScrollWheelEvent, _, cx| {
5880            let tab = &mut this.tabs[this.active_tab];
5881            let text = tab
5882                .host_state
5883                .widget_states
5884                .lock()
5885                .unwrap()
5886                .get(&id)
5887                .and_then(|v| match v {
5888                    WidgetValue::Text(t) => Some(t.clone()),
5889                    _ => None,
5890                })
5891                .unwrap_or_default();
5892            let line_count = text.split('\n').count() as f32;
5893            let line_h: f32 = 20.0;
5894            let content_h = (line_count * line_h).max(line_h);
5895            let max_scroll = (content_h - (h - 16.0)).max(0.0);
5896            let dy = match event.delta {
5897                ScrollDelta::Pixels(p) => f32::from(p.y),
5898                ScrollDelta::Lines(l) => l.y * 20.0,
5899            };
5900            let edit = tab.widget_edits.entry(id).or_default();
5901            edit.scroll_y = (edit.scroll_y - dy).clamp(0.0, max_scroll);
5902            cx.notify();
5903        }))
5904        .child(
5905            canvas(
5906                move |bounds, _window, _cx| {
5907                    *bounds_for_measure.lock().unwrap() = bounds;
5908                },
5909                {
5910                    move |bounds, _state: (), window, cx| {
5911                        let line_h: f32 = 20.0;
5912                        let inner_origin = bounds.origin + point(px(0.0), px(-scroll_y));
5913                        let lines: Vec<&str> = if value_for_canvas.is_empty() {
5914                            Vec::new()
5915                        } else {
5916                            value_for_canvas.split('\n').collect()
5917                        };
5918
5919                        if lines.is_empty() && !placeholder_for_canvas.is_empty() {
5920                            let run = TextRun {
5921                                len: placeholder_for_canvas.len(),
5922                                font: font(".SystemUIFont"),
5923                                color: rgba8(0x71, 0x71, 0x7a, 0xff),
5924                                background_color: None,
5925                                underline: None,
5926                                strikethrough: None,
5927                            };
5928                            let line = window.text_system().shape_line(
5929                                placeholder_for_canvas.clone(),
5930                                px(14.0),
5931                                &[run],
5932                                None,
5933                            );
5934                            let _ = line.paint(inner_origin, px(line_h), window, cx);
5935                        }
5936
5937                        let has_sel = cursor != sel_start;
5938                        let sel_lo = cursor.min(sel_start);
5939                        let sel_hi = cursor.max(sel_start);
5940
5941                        let mut byte_off = 0usize;
5942                        for (i, line_str) in lines.iter().enumerate() {
5943                            let line_start = byte_off;
5944                            let line_end = byte_off + line_str.len();
5945                            let y_top = inner_origin.y + px(i as f32 * line_h);
5946
5947                            let shaped = if line_str.is_empty() {
5948                                None
5949                            } else {
5950                                let run = TextRun {
5951                                    len: line_str.len(),
5952                                    font: font(".SystemUIFont"),
5953                                    color: rgba8(0xfa, 0xfa, 0xfa, 0xff),
5954                                    background_color: None,
5955                                    underline: None,
5956                                    strikethrough: None,
5957                                };
5958                                Some(window.text_system().shape_line(
5959                                    SharedString::from(line_str.to_string()),
5960                                    px(14.0),
5961                                    &[run],
5962                                    None,
5963                                ))
5964                            };
5965
5966                            if has_sel && sel_lo <= line_end && sel_hi >= line_start {
5967                                let lo_in_line =
5968                                    sel_lo.saturating_sub(line_start).min(line_str.len());
5969                                let hi_in_line =
5970                                    sel_hi.saturating_sub(line_start).min(line_str.len());
5971                                let sx = shaped
5972                                    .as_ref()
5973                                    .map(|l| l.x_for_index(lo_in_line))
5974                                    .unwrap_or(px(0.0));
5975                                let ex = if sel_hi > line_end {
5976                                    shaped
5977                                        .as_ref()
5978                                        .map(|l| l.x_for_index(line_str.len()))
5979                                        .unwrap_or(px(0.0))
5980                                        + px(6.0)
5981                                } else {
5982                                    shaped
5983                                        .as_ref()
5984                                        .map(|l| l.x_for_index(hi_in_line))
5985                                        .unwrap_or(px(0.0))
5986                                };
5987                                window.paint_quad(gpui::fill(
5988                                    Bounds::from_corners(
5989                                        point(inner_origin.x + sx, y_top),
5990                                        point(inner_origin.x + ex, y_top + px(line_h)),
5991                                    ),
5992                                    theme::selection(),
5993                                ));
5994                            }
5995
5996                            if let Some(line) = shaped {
5997                                let _ = line.paint(
5998                                    point(inner_origin.x, y_top),
5999                                    px(line_h),
6000                                    window,
6001                                    cx,
6002                                );
6003                            }
6004
6005                            if focused
6006                                && !has_sel
6007                                && caret_blink_on
6008                                && cursor >= line_start
6009                                && cursor <= line_end
6010                            {
6011                                let cur_in_line = cursor - line_start;
6012                                let cx_pos = match (cur_in_line, line_str.is_empty()) {
6013                                    (0, true) => px(0.0),
6014                                    _ => {
6015                                        if let Some(ref line) = lines.get(i) {
6016                                            let run = TextRun {
6017                                                len: line.len(),
6018                                                font: font(".SystemUIFont"),
6019                                                color: rgba8(0xfa, 0xfa, 0xfa, 0xff),
6020                                                background_color: None,
6021                                                underline: None,
6022                                                strikethrough: None,
6023                                            };
6024                                            let shape = window.text_system().shape_line(
6025                                                SharedString::from(line.to_string()),
6026                                                px(14.0),
6027                                                &[run],
6028                                                None,
6029                                            );
6030                                            shape.x_for_index(cur_in_line)
6031                                        } else {
6032                                            px(0.0)
6033                                        }
6034                                    }
6035                                };
6036                                window.paint_quad(gpui::fill(
6037                                    Bounds::from_corners(
6038                                        point(inner_origin.x + cx_pos, y_top),
6039                                        point(
6040                                            inner_origin.x + cx_pos + px(1.5),
6041                                            y_top + px(line_h),
6042                                        ),
6043                                    ),
6044                                    rgba8(0xfa, 0xfa, 0xfa, 0xff),
6045                                ));
6046                            }
6047
6048                            byte_off = line_end + 1; // account for '\n'
6049                        }
6050
6051                        if focused && caret_blink_on && lines.is_empty() {
6052                            window.paint_quad(gpui::fill(
6053                                Bounds::from_corners(
6054                                    inner_origin,
6055                                    point(inner_origin.x + px(1.5), inner_origin.y + px(line_h)),
6056                                ),
6057                                rgba8(0xfa, 0xfa, 0xfa, 0xff),
6058                            ));
6059                        }
6060                    }
6061                },
6062            )
6063            .size_full(),
6064        )
6065}
6066
6067/// Map an (x, y) point inside a textarea to a byte index in `text`.
6068fn textarea_hit_index(text: &str, x: f32, y: f32, window: &Window) -> usize {
6069    let line_h: f32 = 20.0;
6070    let line_idx = (y / line_h).floor().max(0.0) as usize;
6071    let mut byte_off = 0usize;
6072    for (i, line_str) in text.split('\n').enumerate() {
6073        if i == line_idx {
6074            if line_str.is_empty() {
6075                return byte_off;
6076            }
6077            let run = TextRun {
6078                len: line_str.len(),
6079                font: font(".SystemUIFont"),
6080                color: rgba8(0xfa, 0xfa, 0xfa, 0xff),
6081                background_color: None,
6082                underline: None,
6083                strikethrough: None,
6084            };
6085            let shape = window.text_system().shape_line(
6086                SharedString::from(line_str.to_string()),
6087                px(14.0),
6088                &[run],
6089                None,
6090            );
6091            return byte_off + shape.closest_index_for_x(px(x));
6092        }
6093        byte_off += line_str.len() + 1;
6094    }
6095    // Click past last line: end of text.
6096    text.len()
6097}
6098
6099fn render_card(x: f32, y: f32, w: f32, h: f32, title: &str, description: &str) -> impl IntoElement {
6100    let mut card = div()
6101        .absolute()
6102        .left(px(x))
6103        .top(px(y))
6104        .w(px(w))
6105        .h(px(h))
6106        .rounded_lg()
6107        .bg(gpui::rgb(theme::SURFACE))
6108        .border_1()
6109        .border_color(gpui::rgb(theme::BORDER))
6110        .p_4()
6111        .flex()
6112        .flex_col()
6113        .gap_1();
6114    if !title.is_empty() {
6115        card = card.child(
6116            div()
6117                .text_color(gpui::rgb(theme::FG))
6118                .font_weight(gpui::FontWeight::SEMIBOLD)
6119                .text_size(px(16.0))
6120                .child(title.to_string()),
6121        );
6122    }
6123    if !description.is_empty() {
6124        card = card.child(
6125            div()
6126                .text_sm()
6127                .text_color(gpui::rgb(theme::FG_MUTED))
6128                .child(description.to_string()),
6129        );
6130    }
6131    card
6132}
6133
6134fn render_badge(x: f32, y: f32, label: &str, variant: WidgetVariant) -> impl IntoElement {
6135    let (bg, fg, border) = match variant {
6136        WidgetVariant::Default => (theme::PRIMARY, theme::PRIMARY_FG, theme::PRIMARY),
6137        WidgetVariant::Secondary => (theme::SURFACE_HOVER, theme::FG, theme::SURFACE_HOVER),
6138        WidgetVariant::Outline => (theme::BG, theme::FG, theme::BORDER_STRONG),
6139        WidgetVariant::Ghost => (theme::BG, theme::FG_MUTED, theme::BG),
6140        WidgetVariant::Destructive => (theme::DESTRUCTIVE, theme::FG, theme::DESTRUCTIVE),
6141    };
6142    div()
6143        .absolute()
6144        .left(px(x))
6145        .top(px(y))
6146        .h(px(22.0))
6147        .px_2()
6148        .rounded_full()
6149        .bg(gpui::rgb(bg))
6150        .border_1()
6151        .border_color(gpui::rgb(border))
6152        .flex()
6153        .items_center()
6154        .justify_center()
6155        .text_xs()
6156        .font_weight(gpui::FontWeight::MEDIUM)
6157        .text_color(gpui::rgb(fg))
6158        .child(label.to_string())
6159}
6160
6161fn render_separator(x: f32, y: f32, length: f32, vertical: bool) -> impl IntoElement {
6162    let (w, h) = if vertical {
6163        (1.0, length)
6164    } else {
6165        (length, 1.0)
6166    };
6167    div()
6168        .absolute()
6169        .left(px(x))
6170        .top(px(y))
6171        .w(px(w))
6172        .h(px(h))
6173        .bg(gpui::rgb(theme::BORDER))
6174}
6175
6176fn render_progress(x: f32, y: f32, w: f32, value: f32) -> impl IntoElement {
6177    let fill = value.clamp(0.0, 1.0) * w;
6178    div()
6179        .absolute()
6180        .left(px(x))
6181        .top(px(y))
6182        .w(px(w))
6183        .h(px(8.0))
6184        .rounded_full()
6185        .bg(gpui::rgb(theme::MUTED))
6186        .child(
6187            div()
6188                .h(px(8.0))
6189                .w(px(fill))
6190                .rounded_full()
6191                .bg(gpui::rgb(theme::PRIMARY)),
6192        )
6193}
6194
6195fn render_label(x: f32, y: f32, text: &str, muted: bool, size: f32) -> impl IntoElement {
6196    div()
6197        .absolute()
6198        .left(px(x))
6199        .top(px(y))
6200        .text_size(px(size))
6201        .text_color(gpui::rgb(if muted { theme::FG_MUTED } else { theme::FG }))
6202        .font_weight(if muted {
6203            gpui::FontWeight::NORMAL
6204        } else {
6205            gpui::FontWeight::MEDIUM
6206        })
6207        .child(text.to_string())
6208}
6209
6210/// Guest widget bounds in canvas-local coordinates (must match overlay hit-test skip logic).
6211/// Returns `None` for purely decorative widgets that should not block click-through.
6212fn widget_bounds(cmd: &WidgetCommand) -> Option<(f32, f32, f32, f32)> {
6213    match cmd {
6214        WidgetCommand::Button { x, y, w, h, .. } => Some((*x, *y, *w, *h)),
6215        WidgetCommand::Checkbox { x, y, .. } => Some((*x, *y, 220.0, 26.0)),
6216        WidgetCommand::Slider { x, y, w, .. } => Some((*x, *y, *w, 28.0)),
6217        WidgetCommand::TextInput { x, y, w, .. } => Some((*x, *y, *w, 36.0)),
6218        WidgetCommand::Textarea { x, y, w, h, .. } => Some((*x, *y, *w, *h)),
6219        WidgetCommand::Card { x, y, w, h, .. } => Some((*x, *y, *w, *h)),
6220        WidgetCommand::Switch { x, y, .. } => Some((*x, *y, 220.0, 24.0)),
6221        WidgetCommand::Badge { .. }
6222        | WidgetCommand::Separator { .. }
6223        | WidgetCommand::Progress { .. }
6224        | WidgetCommand::Label { .. } => None,
6225    }
6226}
6227
6228/// True if `(lx, ly)` lies inside any guest widget rect (canvas space).
6229fn canvas_point_hits_widget(lx: f32, ly: f32, cmds: &[WidgetCommand]) -> bool {
6230    for cmd in cmds {
6231        if let Some((x, y, w, h)) = widget_bounds(cmd) {
6232            if lx >= x && ly >= y && lx <= x + w && ly <= y + h {
6233                return true;
6234            }
6235        }
6236    }
6237    false
6238}
6239
6240fn truncate_tab_title(title: &str) -> String {
6241    let max_len = 30;
6242    if title.chars().count() > max_len {
6243        let t: String = title.chars().take(max_len).collect();
6244        format!("{t}\u{2026}")
6245    } else {
6246        title.to_string()
6247    }
6248}
6249
6250/// Typed character for URL bar and guest [`WidgetCommand::TextInput`] fields.
6251/// Uses `key_char` when set; otherwise mirrors [`Keystroke::with_simulated_ime`] for plain typing.
6252fn text_insert_from_keystroke(ks: &Keystroke) -> Option<String> {
6253    if ks.modifiers.control || ks.modifiers.platform || ks.modifiers.function || ks.modifiers.alt {
6254        return None;
6255    }
6256    if let Some(ref c) = ks.key_char {
6257        return Some(c.clone());
6258    }
6259    ks.clone().with_simulated_ime().key_char
6260}
6261
6262fn keystroke_to_oxide(k: &Keystroke) -> Option<u32> {
6263    let key = k.key.as_str();
6264    match key {
6265        "a" => Some(0),
6266        "b" => Some(1),
6267        "c" => Some(2),
6268        "d" => Some(3),
6269        "e" => Some(4),
6270        "f" => Some(5),
6271        "g" => Some(6),
6272        "h" => Some(7),
6273        "i" => Some(8),
6274        "j" => Some(9),
6275        "k" => Some(10),
6276        "l" => Some(11),
6277        "m" => Some(12),
6278        "n" => Some(13),
6279        "o" => Some(14),
6280        "p" => Some(15),
6281        "q" => Some(16),
6282        "r" => Some(17),
6283        "s" => Some(18),
6284        "t" => Some(19),
6285        "u" => Some(20),
6286        "v" => Some(21),
6287        "w" => Some(22),
6288        "x" => Some(23),
6289        "y" => Some(24),
6290        "z" => Some(25),
6291        "0" => Some(26),
6292        "1" => Some(27),
6293        "2" => Some(28),
6294        "3" => Some(29),
6295        "4" => Some(30),
6296        "5" => Some(31),
6297        "6" => Some(32),
6298        "7" => Some(33),
6299        "8" => Some(34),
6300        "9" => Some(35),
6301        "enter" => Some(36),
6302        "escape" => Some(37),
6303        "tab" => Some(38),
6304        "backspace" => Some(39),
6305        "delete" => Some(40),
6306        "space" => Some(41),
6307        "up" => Some(42),
6308        "down" => Some(43),
6309        "left" => Some(44),
6310        "right" => Some(45),
6311        "home" => Some(46),
6312        "end" => Some(47),
6313        "pageup" => Some(48),
6314        "pagedown" => Some(49),
6315        _ => None,
6316    }
6317}
6318
6319/// Returns `true` when `url` clearly points to a downloadable file rather
6320/// than a WASM module.  Heuristic: the URL path has a file extension and that
6321/// extension is *not* `.wasm`.  Bare directories and extensionless paths are
6322/// assumed to be WASM endpoints (they get `/index.wasm` appended by the
6323/// runtime).
6324fn is_downloadable_url(url: &str) -> bool {
6325    let trimmed = url.trim();
6326    if trimmed.is_empty() {
6327        return false;
6328    }
6329    if let Ok(parsed) = url::Url::parse(trimmed) {
6330        if !matches!(parsed.scheme(), "http" | "https") {
6331            return false;
6332        }
6333        let path = parsed.path();
6334        if path.ends_with('/') || path == "/" || path.is_empty() {
6335            return false;
6336        }
6337        if let Some(last_segment) = path.rsplit('/').next() {
6338            if let Some(dot) = last_segment.rfind('.') {
6339                let ext = &last_segment[dot + 1..];
6340                return !ext.eq_ignore_ascii_case("wasm");
6341            }
6342        }
6343    }
6344    false
6345}
6346
6347fn url_to_title(url: &str) -> String {
6348    if url == "(local)" {
6349        return "Local Module".to_string();
6350    }
6351    match url {
6352        "oxide://home" => return "Home".to_string(),
6353        "oxide://history" => return "History".to_string(),
6354        "oxide://bookmarks" => return "Bookmarks".to_string(),
6355        "oxide://about" => return "About Oxide".to_string(),
6356        "oxide://forge" => return "Forge".to_string(),
6357        _ => {}
6358    }
6359    if let Some(stripped) = url
6360        .strip_prefix("https://")
6361        .or_else(|| url.strip_prefix("http://"))
6362    {
6363        stripped.split('/').next().unwrap_or(stripped).to_string()
6364    } else if let Some(stripped) = url.strip_prefix("file://") {
6365        stripped
6366            .rsplit('/')
6367            .next()
6368            .unwrap_or("Local File")
6369            .to_string()
6370    } else {
6371        let max = 20;
6372        if url.chars().count() > max {
6373            let truncated: String = url.chars().take(max).collect();
6374            format!("{truncated}\u{2026}")
6375        } else {
6376            url.to_string()
6377        }
6378    }
6379}
6380
6381/// Start the Oxide desktop shell: GPUI event loop and one main window.
6382///
6383/// Call this after constructing a [`crate::runtime::BrowserHost`] and cloning its [`HostState`]
6384/// and status mutex,
6385/// as the `oxide` binary does.
6386/// This function does not return until the application exits.
6387pub fn run_browser(host_state: HostState, status: Arc<Mutex<PageStatus>>) -> anyhow::Result<()> {
6388    Application::new().run(move |cx: &mut gpui::App| {
6389        cx.on_window_closed(|cx| {
6390            if cx.windows().is_empty() {
6391                cx.quit();
6392            }
6393        })
6394        .detach();
6395
6396        let opts = WindowOptions {
6397            window_bounds: Some(WindowBounds::centered(size(px(1024.0), px(720.0)), cx)),
6398            titlebar: Some(TitlebarOptions {
6399                title: Some("Oxide Browser".into()),
6400                ..Default::default()
6401            }),
6402            window_min_size: Some(size(px(600.0), px(400.0))),
6403            kind: WindowKind::Normal,
6404            ..Default::default()
6405        };
6406        cx.open_window(opts, move |_, cx| {
6407            cx.new(|cx| OxideBrowserView::new(cx, host_state.clone(), status.clone()))
6408        })
6409        .expect("open window");
6410    });
6411    Ok(())
6412}