1use 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#[allow(dead_code)]
41mod theme {
42 use gpui::Rgba;
43
44 pub const BG: u32 = 0x0a0a0b; pub const SURFACE: u32 = 0x18181b; pub const SURFACE_HOVER: u32 = 0x27272a; pub const MUTED: u32 = 0x27272a; pub const BORDER: u32 = 0x27272a; pub const BORDER_STRONG: u32 = 0x3f3f46; pub const RING: u32 = 0xd4d4d8; pub const PRIMARY: u32 = 0xfafafa; pub const PRIMARY_FG: u32 = 0x18181b; pub const FG: u32 = 0xfafafa; pub const FG_MUTED: u32 = 0xa1a1aa; pub const FG_DIM: u32 = 0x71717a; pub const ACCENT: u32 = 0x60a5fa; pub const DESTRUCTIVE: u32 = 0x7f1d1d; pub const SUCCESS: u32 = 0x16a34a; 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 url: String,
83 manifest: Option<crate::manifest::AppManifest>,
84 },
85}
86
87struct RunResult {
88 error: Option<String>,
89 live_module: Option<LiveModule>,
90}
91
92unsafe 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#[derive(Clone, Debug, Default)]
154struct WidgetEditState {
155 cursor: usize,
157 sel_start: usize,
159 selecting: bool,
161 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 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 text_input_focus: Option<u32>,
197 widget_edits: HashMap<u32, WidgetEditState>,
199 widget_bounds_cache: HashMap<u32, Arc<Mutex<Bounds<Pixels>>>>,
201 url_cursor: usize,
203 url_sel_start: usize,
205 url_selecting: bool,
207 url_text_bounds: Arc<Mutex<Bounds<Pixels>>>,
209 internal_page: Option<InternalPage>,
210 forge_prompt: String,
212 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 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
662fn 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#[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), 1 => (x + w - r, y + h - r, 0.0), 2 => (x + r, y + h - r, std::f32::consts::FRAC_PI_2), _ => (x + r, y + r, std::f32::consts::PI), };
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 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 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 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
1160enum 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 canvas_focus: FocusHandle,
1179 url_focus: FocusHandle,
1180 forge_focus: FocusHandle,
1182 forge_settings_key_focus: FocusHandle,
1184 forge_settings_model_focus: FocusHandle,
1186 forge_config: ForgeUserConfig,
1188 forge_settings_provider: ForgeProvider,
1190 forge_settings_key_draft: String,
1192 forge_settings_model_draft: String,
1194 file_pick_rx: Option<mpsc::Receiver<FilePickDone>>,
1196 download_manager: DownloadManager,
1197 show_downloads: bool,
1198 forge: Arc<Mutex<Option<ForgeState>>>,
1200 scroll_dragging: bool,
1202 scroll_drag_start_y: f32,
1204 scroll_drag_start_scroll_y: f32,
1206 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 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 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 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 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 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 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 *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(¤t_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 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 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 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 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 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 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 {
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 {
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
4862fn 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
4906fn 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
4918fn 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
4930fn 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
4942fn 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
4954fn line_start(text: &str, cursor: usize) -> usize {
4956 text[..cursor].rfind('\n').map(|i| i + 1).unwrap_or(0)
4957}
4958
4959fn 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
4968fn 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
4975fn 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
5212fn 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
5223fn 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; }
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
6067fn 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 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
6210fn 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
6228fn 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
6250fn 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
6319fn 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
6381pub 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}