//! One open device: its live screen, which takes taps, drags and typing, //! the rail of quick controls beside it and the Tools drawer, as T3 Code's //! `DeviceWorkspace` lays them out. The same view shows in the right panel //! or floats over the chat. use std::time::Duration; use gpui_kit::component::{ Icon, IconName, h_flex, input::{Input, InputState}, menu::{DropdownMenu as _, PopupMenuItem}, v_flex, Selectable as _, Sizable as _, }; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; use super::DeviceWatch; use crate::device::{ self, Action, AxElement, ColorFilter, Detail, DeviceSummary, Orientation, Platform, TextSize, Toggle, stream::{Button, DeviceStream, Frame, Key, Screen, Status, StreamEvent, TouchPhase}, }; use crate::ui::icons::Lucide; use crate::ui::metal::{self, Led, MetalButton, MetalStyled as _, Palette}; /// The rail's width; the screen keeps clear of it. const RAIL_WIDTH: f32 = 56.; const TOOLS_WIDTH: f32 = 288.; /// The panel is this wide before the drawer sits beside the screen rather than over it. const TOOLS_BESIDE: f32 = 700.; const AX_POLL: Duration = Duration::from_millis(1500); const LOCATION_PRESETS: [(&str, f64, f64); 5] = [ ("San Francisco", 37.7749, -122.4194), ("New York", 40.7128, -74.006), ("London", 51.5074, -0.1278), ("Stockholm", 59.3293, 18.0686), ("Tokyo", 35.6762, 139.6503), ]; const IOS_PERMISSIONS: [(&str, &str); 11] = [ ("camera", "Camera"), ("microphone", "Microphone"), ("photos", "Photos"), ("contacts", "Contacts"), ("calendar", "Calendar"), ("reminders", "Reminders"), ("location", "Location"), ("notifications", "Notifications"), ("motion", "Motion"), ("media-library", "Media library"), ("faceid", "Face ID"), ]; const ANDROID_PERMISSIONS: [(&str, &str); 8] = [ ("camera", "Camera"), ("microphone", "Microphone"), ("photos", "Photos"), ("contacts", "Contacts"), ("calendar", "Calendar"), ("location", "Location"), ("notifications", "Notifications"), ("motion", "Physical activity"), ]; pub enum DeviceViewEvent { /// Float over the chat. Float, /// Close the tab; the device keeps running. Close, /// Close the tab and power the device off. PowerOff, } /// The Tools drawer's text fields. struct Fields { url: Entity, launch: Entity, latitude: Entity, longitude: Entity, permission_app: Entity, push: Entity, } pub struct DeviceView { pub device: DeviceSummary, host_label: String, stream: Option, frame: Option, status: Status, status_detail: Option, screen: Option, input_connected: bool, input_detail: Option, visible: bool, /// Drawn without the rail and drawer, as the floating preview. pub compact: bool, focus_handle: FocusHandle, /// The whole viewer and the screen within it, in window coordinates. area: Bounds, screen_area: Bounds, touching: bool, tools_open: bool, ax_overlay: bool, ax: Vec, detail: Option, pending: bool, error: Option, screenshot_pending: bool, fields: Fields, permission: &'static str, _events: Task<()>, _ax: Task<()>, _detail: Task<()>, } impl EventEmitter for DeviceView {} impl DeviceView { pub fn new(device: DeviceSummary, host_label: String, window: &mut Window, cx: &mut Context) -> Self { let field = |placeholder: &str, window: &mut Window, cx: &mut Context| { let placeholder = placeholder.to_string(); cx.new(|cx| InputState::new(window, cx).placeholder(placeholder)) }; let fields = Fields { url: field("https://… or myapp://", window, cx), launch: field(if device.platform == Platform::Ios { "Bundle ID to launch" } else { "Package name to launch" }, window, cx), latitude: field("Latitude", window, cx), longitude: field("Longitude", window, cx), permission_app: field("App ID", window, cx), push: field("Alert text", window, cx), }; cx.on_release(|this, cx| { if let Some(frame) = this.frame.take() { cx.drop_image(frame.image, None); } }) .detach(); Self { device, host_label, stream: None, frame: None, status: Status::Connecting, status_detail: None, screen: None, input_connected: false, input_detail: None, visible: false, compact: false, focus_handle: cx.focus_handle(), area: Bounds::default(), screen_area: Bounds::default(), touching: false, tools_open: false, ax_overlay: false, ax: Vec::new(), detail: None, pending: false, error: None, screenshot_pending: false, fields, permission: "camera", _events: Task::ready(()), _ax: Task::ready(()), _detail: Task::ready(()), } } /// Streams while shown; a hidden view lets go of its stream. pub fn set_visible(&mut self, visible: bool, window: &mut Window, cx: &mut Context) { if self.visible == visible { return; } self.visible = visible; if visible { self.ensure_stream(window, cx); self.read_detail(cx); self.poll_ax(cx); } else { self._detail = Task::ready(()); self._ax = Task::ready(()); } if let Some(stream) = &self.stream { stream.set_watching(visible); } cx.notify(); } /// The device as the service now knows it (an AVD becomes a serial when it boots). pub fn update_device(&mut self, device: DeviceSummary, cx: &mut Context) { if self.device != device { self.device = device; cx.notify(); } } fn ensure_stream(&mut self, window: &mut Window, cx: &mut Context) { if self.stream.is_some() { return; } let Some(origin) = device::service().hub_origin() else { self.status = Status::Error; self.status_detail = Some("The device hub is not running. Refresh devices to start it.".into()); return; }; let (tx, rx) = smol::channel::unbounded(); self.stream = Some(DeviceStream::new(origin, self.device.platform, self.device.id.clone(), tx)); self._events = cx.spawn_in(window, async move |this, cx| { while let Ok(event) = rx.recv().await { let mut batch = vec![event]; while let Ok(event) = rx.try_recv() { batch.push(event); } let updated = this.update_in(cx, |this, window, cx| { for event in batch { this.handle_event(event, window, cx); } cx.notify(); }); if updated.is_err() { break; } } }); } fn handle_event(&mut self, event: StreamEvent, window: &mut Window, cx: &mut Context) { match event { StreamEvent::Status(status, detail) => { self.status = status; self.status_detail = detail.map(Into::into); } StreamEvent::Frame => { if let Some(frame) = self.stream.as_ref().and_then(DeviceStream::take_frame) && let Some(old) = self.frame.replace(frame) { cx.drop_image(old.image, Some(window)); } } StreamEvent::Screen(screen) => self.screen = Some(screen), StreamEvent::Input(connected, detail) => { self.input_connected = connected; self.input_detail = detail.map(Into::into); } } } fn reconnect(&mut self, cx: &mut Context) { if let Some(stream) = &self.stream { stream.restart(); } self.status = Status::Connecting; self.status_detail = None; cx.notify(); } /// Reads the device's settings; every action reads them again after. fn read_detail(&mut self, cx: &mut Context) { let id = self.device.id.clone(); self._detail = cx.spawn(async move |this, cx| { let result = cx.background_executor().spawn(async move { device::service().detail(&id) }).await; let _ = this.update(cx, |this, cx| { match result { Ok(detail) => { this.detail = Some(detail); this.error = None; } Err(err) => { this.detail = None; this.error = Some(format!("{err:#}").into()); } } cx.notify(); }); }); } /// Runs one action. One at a time; the result replaces the settings shown, /// so a control never shows a value the device didn't confirm. fn act(&mut self, action: Action, cx: &mut Context) { if self.pending || self.detail.is_none() { return; } self.pending = true; self.error = None; let id = self.device.id.clone(); cx.spawn(async move |this, cx| { let result = cx.background_executor().spawn(async move { device::service().action(&id, &action) }).await; let _ = this.update(cx, |this, cx| { this.pending = false; match result { Ok(detail) => this.detail = Some(detail), Err(err) => this.error = Some(format!("{err:#}").into()), } cx.notify(); }); }) .detach(); cx.notify(); } fn controls_disabled(&self) -> bool { self.pending || self.detail.is_none() || !self.visible } /// Polls the accessibility tree while the overlay is on. fn poll_ax(&mut self, cx: &mut Context) { if !self.ax_overlay || !self.visible { self._ax = Task::ready(()); self.ax.clear(); return; } let id = self.device.id.clone(); self._ax = cx.spawn(async move |this, cx| { loop { let id = id.clone(); let tree = cx.background_executor().spawn(async move { device::service().accessibility(&id) }).await; let keep_going = this .update(cx, |this, cx| { // A failed poll keeps the last good tree. if let Ok(tree) = tree { this.ax = tree; cx.notify(); } this.ax_overlay && this.visible }) .unwrap_or(false); if !keep_going { break; } cx.background_executor().timer(AX_POLL).await; } }); } fn save_screenshot(&mut self, cx: &mut Context) { if self.screenshot_pending || !self.visible { return; } self.screenshot_pending = true; let id = self.device.id.clone(); let name: String = self.device.name.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '-' }).collect(); let file = format!("{name}-{}.png", chrono::Local::now().timestamp_millis()); let dir = std::env::var_os("HOME").map(std::path::PathBuf::from).map(|home| { let downloads = home.join("Downloads"); if downloads.is_dir() { downloads } else { home } }); let path = cx.prompt_for_new_path(&dir.unwrap_or_default(), Some(&file)); cx.spawn(async move |this, cx| { let target = path.await.ok().and_then(Result::ok).flatten(); let result = match target { Some(target) => { cx.background_executor() .spawn(async move { let (_, png) = device::service().screenshot(&id)?; std::fs::write(&target, png)?; anyhow::Ok(()) }) .await } None => Ok(()), }; let _ = this.update(cx, |this, cx| { this.screenshot_pending = false; if let Err(err) = result { this.error = Some(format!("{err:#}").into()); } cx.notify(); }); }) .detach(); cx.notify(); } /// A window position as a 0..1 point on the screen. fn screen_point(&self, position: Point) -> (f32, f32) { let bounds = self.screen_area; let width = f32::from(bounds.size.width).max(1.); let height = f32::from(bounds.size.height).max(1.); ( (f32::from(position.x - bounds.origin.x) / width).clamp(0., 1.), (f32::from(position.y - bounds.origin.y) / height).clamp(0., 1.), ) } fn touch(&self, phase: TouchPhase, position: Point) { if let Some(stream) = &self.stream { let (x, y) = self.screen_point(position); stream.touch(phase, x, y); } } fn on_key_down(&mut self, event: &KeyDownEvent, _: &mut Window, cx: &mut Context) { let Some(stream) = &self.stream else { return }; let keystroke = &event.keystroke; let mods = &keystroke.modifiers; if keystroke.key == "v" && mods.control && !mods.alt { if let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) { stream.text(text); } cx.stop_propagation(); return; } // Leave the app's own shortcuts alone. if mods.platform || (mods.control && keystroke.key != "c") { return; } stream.key(Key { key: keystroke.key.clone(), text: keystroke.key_char.clone(), shift: mods.shift, command: mods.control || mods.alt || mods.platform, }); cx.stop_propagation(); } /// The shown aspect ratio, width over height. fn aspect(&self) -> f32 { match &self.screen { Some(screen) => screen.aspect(), None if self.device.platform == Platform::Ios => 9. / 19.5, None => 9. / 20., } } // ---- rendering ---- fn render_screen(&self, cx: &mut Context) -> AnyElement { let p = metal::palette(cx); let entity = cx.entity().downgrade(); let track_area = canvas( move |bounds, _, cx| { let _ = entity.update(cx, |this, cx| { if this.area != bounds { this.area = bounds; cx.notify(); } }); }, |_, _, _, _| {}, ) .absolute() .size_full(); let rail = if self.compact { 0. } else { RAIL_WIDTH }; let width = (f32::from(self.area.size.width) - rail - 16.).max(0.); let height = (f32::from(self.area.size.height) - 16.).max(0.); let aspect = self.aspect(); let (frame_w, frame_h) = if width / height.max(1.) > aspect { (height * aspect, height) } else { (width, width / aspect) }; let entity = cx.entity().downgrade(); let track_screen = canvas( move |bounds, _, cx| { let _ = entity.update(cx, |this, _| this.screen_area = bounds); }, |_, _, _, _| {}, ) .absolute() .size_full(); let streaming = self.status == Status::Streaming; let screen = div() .id("device-screen") .relative() .flex_none() .w(px(frame_w)) .h(px(frame_h)) .rounded(px(if self.compact { 10. } else { 14. })) .overflow_hidden() .bg(black()) .cursor_default() .child(track_screen) .when_some(self.frame.as_ref(), |this, frame| { this.child(img(ImageSource::Render(frame.image.clone())).size_full().object_fit(ObjectFit::Fill)) }) .children(self.ax.iter().map(|element| { div() .absolute() .left(relative(element.x)) .top(relative(element.y)) .w(relative(element.width)) .h(relative(element.height)) .border_1() .border_color(p.blue_ink.opacity(0.8)) .bg(p.blue_ink.opacity(0.1)) .when(!element.label.is_empty(), |this| { this.child( div() .absolute() .top(px(-14.)) .left_0() .max_w_full() .truncate() .px(px(3.)) .rounded(px(2.)) .bg(p.blue_ink) .text_size(px(9.)) .line_height(px(14.)) .text_color(white()) .child(element.label.clone()), ) }) })) .on_mouse_down( MouseButton::Left, cx.listener(|this, event: &MouseDownEvent, window, cx| { this.focus_handle.focus(window, cx); this.touching = true; this.touch(TouchPhase::Begin, event.position); cx.stop_propagation(); }), ) .on_mouse_move(cx.listener(|this, event: &MouseMoveEvent, _, _| { if this.touching && event.pressed_button == Some(MouseButton::Left) { this.touch(TouchPhase::Move, event.position); } })) .on_mouse_up( MouseButton::Left, cx.listener(|this, event: &MouseUpEvent, _, _| { if std::mem::take(&mut this.touching) { this.touch(TouchPhase::End, event.position); } }), ) .on_mouse_up_out( MouseButton::Left, cx.listener(|this, event: &MouseUpEvent, _, _| { if std::mem::take(&mut this.touching) { this.touch(TouchPhase::End, event.position); } }), ); let overlay = if streaming { (!self.input_connected).then(|| { let detail = self.input_detail.as_ref().map(|detail| format!(" ({detail})")).unwrap_or_default(); div() .absolute() .bottom(px(8.)) .left_0() .right(px(rail)) .flex() .justify_center() .child( div() .px(px(8.)) .py(px(4.)) .rounded(px(6.)) .bg(p.s.opacity(0.9)) .text_size(px(11.5)) .text_color(p.ink3) .child(format!("Input disconnected{detail}, reconnecting…")), ) .into_any_element() }) } else { Some(self.render_loading(cx).into_any_element()) }; div() .id("device-viewer") .key_context("DeviceScreen") .track_focus(&self.focus_handle) .on_key_down(cx.listener(Self::on_key_down)) .relative() .size_full() .flex() .items_center() .justify_center() .when(!self.compact, |this| this.pr(px(RAIL_WIDTH))) .child(track_area) .child(screen) .children(overlay) .when(!self.compact, |this| this.child(self.render_rail(cx))) .into_any_element() } fn render_loading(&self, cx: &mut Context) -> impl IntoElement { let p = metal::palette(cx); let error = self.status == Status::Error; let message: SharedString = if error { self.status_detail.clone().unwrap_or_else(|| "Stream failed.".into()) } else { "Connecting video…".into() }; super::loading_view( &self.device.name, &format!("{} · {}", self.host_label, self.device.version), message, error, p, ) .absolute() .inset_0() .when(!self.compact, |this| this.right(px(RAIL_WIDTH))) .bg(p.s) .when(error, |this| { this.child( MetalButton::new("device-reconnect") .compact() .label("Reconnect") .on_click(cx.listener(|this, _, _, cx| this.reconnect(cx))), ) }) } fn rail_button( &self, id: &'static str, icon: impl Into, label: &'static str, disabled: bool, cx: &mut Context, on_click: impl Fn(&mut Self, &mut Window, &mut Context) + 'static, ) -> MetalButton { MetalButton::new(id) .ghost() .mini() .icon(icon) .tooltip(label) .disabled(disabled) .on_click(cx.listener(move |this, _, window, cx| on_click(this, window, cx))) } fn render_rail(&self, cx: &mut Context) -> impl IntoElement { let p = metal::palette(cx); let input_disabled = !self.input_connected || self.stream.is_none(); let settings = self.detail.as_ref().map(|detail| &detail.settings); let dark = settings.and_then(|settings| settings.dark); let text_size = settings.and_then(|settings| settings.text_size); let controls_disabled = self.controls_disabled(); let platform = self.device.platform; let press = |button: Button| move |this: &mut Self, _: &mut Window, _: &mut Context| { if let Some(stream) = &this.stream { stream.button(button); } }; let divider = || div().my(px(3.)).h(px(1.)).w(px(18.)).bg(p.rule); let weak = cx.weak_entity(); let mut pill = v_flex() .items_center() .gap(px(3.)) .p(px(6.)) .rounded_full() .raised(p) .child(self.rail_button("device-home", Lucide::House, "Home", input_disabled, cx, press(Button::Home))); if platform == Platform::Android { let weak = weak.clone(); pill = pill .child(self.rail_button("device-back", IconName::ChevronLeft, "Back", input_disabled, cx, press(Button::Back))) .child(self.rail_button("device-recents", Lucide::Square, "Recents", input_disabled, cx, press(Button::Recents))) .child( MetalButton::new("device-rotate") .ghost() .mini() .icon(Lucide::RotateCcw) .tooltip("Rotate device") .disabled(controls_disabled) .dropdown_menu_with_anchor(Anchor::TopRight, move |menu, _, _| { let pick = |orientation: Orientation| { let weak = weak.clone(); move |_: &ClickEvent, _: &mut Window, cx: &mut App| { let _ = weak.update(cx, |this, cx| this.act(Action::SetOrientation(orientation), cx)); } }; menu.item(PopupMenuItem::new("Portrait").on_click(pick(Orientation::Portrait))) .item(PopupMenuItem::new("Landscape").on_click(pick(Orientation::LandscapeLeft))) }), ); } else { pill = pill.child(self.rail_button("device-rotate", Lucide::RotateCcw, "Rotate device", input_disabled, cx, |this, _, _| { if let Some(stream) = &this.stream { stream.rotate(); } })); } let next_dark = dark != Some(true); let text_weak = weak.clone(); let more_weak = weak.clone(); pill = pill .child(divider()) .child(self.rail_button( "device-appearance", if dark == Some(true) { IconName::Sun } else { IconName::Moon }, if next_dark { "Switch device to dark mode" } else { "Switch device to light mode" }, controls_disabled || dark.is_none(), cx, move |this, _, cx| this.act(Action::SetAppearance(next_dark), cx), )) .child( MetalButton::new("device-text-size") .ghost() .mini() .icon(Lucide::Type) .tooltip("Device text size") .disabled(controls_disabled || text_size.is_none()) .dropdown_menu_with_anchor(Anchor::TopRight, move |mut menu, _, _| { menu = menu.min_w(px(160.)); for size in TextSize::ALL { let weak = text_weak.clone(); menu = menu.item(PopupMenuItem::new(size.label()).checked(text_size == Some(size)).on_click( move |_, _, cx| { let _ = weak.update(cx, |this, cx| this.act(Action::SetTextSize(size), cx)); }, )); } menu }), ) .child( self.rail_button("device-tools", Lucide::SlidersHorizontal, "Device tools", false, cx, |this, _, cx| { this.tools_open = !this.tools_open; cx.notify(); }) .selected(self.tools_open), ) .child(self.rail_button( "device-screenshot", Lucide::Camera, if self.screenshot_pending { "Capturing screenshot" } else { "Save screenshot" }, self.status != Status::Streaming || self.screenshot_pending, cx, |this, _, cx| this.save_screenshot(cx), )) .child( MetalButton::new("device-more") .ghost() .mini() .icon(IconName::Ellipsis) .tooltip("More device actions") .dropdown_menu_with_anchor(Anchor::BottomRight, move |menu, _, _| { let emit = |event: fn() -> DeviceViewEvent| { let weak = more_weak.clone(); move |_: &ClickEvent, _: &mut Window, cx: &mut App| { let _ = weak.update(cx, |_, cx| cx.emit(event())); } }; menu.item( PopupMenuItem::new("Float device over chat") .icon(Icon::new(Lucide::PictureInPicture2)) .on_click(emit(|| DeviceViewEvent::Float)), ) .item(PopupMenuItem::new("Close device panel").icon(Icon::new(Lucide::X)).on_click(emit(|| DeviceViewEvent::Close))) .separator() .item(PopupMenuItem::new("Power off device").icon(Icon::new(Lucide::Power)).on_click(emit(|| DeviceViewEvent::PowerOff))) }), ); div() .absolute() .top_0() .bottom_0() .right(px(8.)) .flex() .flex_col() .justify_center() .child(pill) } fn render_tools(&self, cx: &mut Context) -> impl IntoElement { let p = metal::palette(cx); let ios = self.device.platform == Platform::Ios; let disabled = self.controls_disabled(); let settings = self.detail.as_ref().map(|detail| detail.settings.clone()).unwrap_or_default(); let foreground = self.detail.as_ref().and_then(|detail| detail.foreground.clone()); let weak = cx.weak_entity(); let mut body = v_flex().id("device-tools-body").flex_1().min_h_0().overflow_y_scroll(); if let Some(error) = &self.error { body = body.child( div().px(px(12.)).py(px(8.)).bg(p.red.opacity(0.1)).text_size(px(11.5)).text_color(p.red).child(error.clone()), ); } if self.detail.is_none() && self.error.is_none() { body = body.child( h_flex() .gap(px(8.)) .px(px(12.)) .py(px(10.)) .text_size(px(11.5)) .text_color(p.ink3) .child(metal::led("device-reading", Led::Waiting, true, p)) .child("Reading device settings…"), ); } if let Some(diagnostics) = cx.global::().state.host_detail.clone().filter(|_| cx.global::().state.host_status == device::HostStatus::Ready) { body = body.child(section("Host diagnostics", p).child(small_text(diagnostics, p))); } // App. let mut app = section("App", p).child(row( "Foreground", div() .min_w_0() .truncate() .font_family(metal::MONO) .text_size(px(11.5)) .child(foreground.as_ref().map(|app| app.id.clone()).unwrap_or_else(|| "—".into())), p, )); if let Some(app_id) = foreground.as_ref().map(|app| app.id.clone()) { let relaunch = app_id.clone(); app = app.child( h_flex() .gap(px(6.)) .child(small_button("device-terminate", "Terminate", disabled, cx, move |this, _, cx| { this.act(Action::TerminateApp(app_id.clone()), cx) })) .child(small_button("device-relaunch", "Relaunch", disabled, cx, move |this, _, cx| { this.act(Action::LaunchApp(relaunch.clone()), cx) })), ); } app = app .child(self.submit_row("device-open-url", &self.fields.url, "Open", disabled, cx, |text| Action::OpenUrl(text))) .child(self.submit_row("device-launch", &self.fields.launch, "Launch", disabled, cx, |text| Action::LaunchApp(text))); body = body.child(app); // Simulator / Emulator. let mut system = section(if ios { "Simulator" } else { "Emulator" }, p).child(row( "Appearance", { let weak = weak.clone(); choice_segment("device-appearance-segment", &[(false, "Light"), (true, "Dark")], settings.dark, disabled, p, move |dark, _, cx| { let _ = weak.update(cx, |this, cx| this.act(Action::SetAppearance(dark), cx)); }) }, p, )); { let weak = weak.clone(); system = system.child(row( "Text size", select( "device-text-size-select", settings.text_size.map(TextSize::label).unwrap_or("Unknown"), disabled, TextSize::ALL.iter().map(|size| (size.label(), settings.text_size == Some(*size))).collect(), move |ix, cx| { let _ = weak.update(cx, |this, cx| this.act(Action::SetTextSize(TextSize::ALL[ix]), cx)); }, ), p, )); } if ios { let glass_weak = weak.clone(); let filter_weak = weak.clone(); system = system .child(row( "Liquid Glass", choice_segment( "device-liquid-glass", &[(false, "Clear"), (true, "Tinted")], settings.liquid_glass_tinted, disabled || settings.liquid_glass_tinted.is_none(), p, move |tinted, _, cx| { let _ = glass_weak.update(cx, |this, cx| this.act(Action::SetLiquidGlass(tinted), cx)); }, ), p, )) .child(row( "Color filter", select( "device-color-filter", settings.color_filter.map(ColorFilter::label).unwrap_or("Unknown"), disabled, ColorFilter::ALL.iter().map(|filter| (filter.label(), settings.color_filter == Some(*filter))).collect(), move |ix, cx| { let _ = filter_weak.update(cx, |this, cx| this.act(Action::SetColorFilter(ColorFilter::ALL[ix]), cx)); }, ), p, )); } else { let weak = weak.clone(); system = system.child(row( "Orientation", select( "device-orientation", "Rotate to…", disabled, Orientation::ALL.iter().map(|orientation| (orientation.label(), false)).collect(), move |ix, cx| { let _ = weak.update(cx, |this, cx| this.act(Action::SetOrientation(Orientation::ALL[ix]), cx)); }, ), p, )); } let toggles: &[(Toggle, &str)] = if ios { &[ (Toggle::ReduceMotion, "Reduce Motion"), (Toggle::IncreaseContrast, "Increase Contrast"), (Toggle::ReduceTransparency, "Reduce Transparency"), (Toggle::ShowBorders, "Show Borders"), (Toggle::VoiceOver, "VoiceOver"), ] } else { &[(Toggle::ReduceMotion, "Reduce Motion"), (Toggle::NetworkEnabled, "Network")] }; for (toggle, label) in toggles { let value = settings.toggle(*toggle); let weak = weak.clone(); let toggle = *toggle; system = system.child(row( label, switch(("device-toggle", toggle as usize), value, disabled, p, move |on, _, cx| { let _ = weak.update(cx, |this, cx| this.act(Action::SetToggle { setting: toggle, value: on }, cx)); }), p, )); } body = body.child(system); // Accessibility. { let weak = weak.clone(); body = body.child(section("Accessibility", p).child(row( "Overlay element frames", switch("device-ax-overlay", Some(self.ax_overlay), self.stream.is_none(), p, move |on, _, cx| { let _ = weak.update(cx, |this, cx| { this.ax_overlay = on; this.poll_ax(cx); cx.notify(); }); }), p, ))); } // Location. { let preset_weak = weak.clone(); let mut location = section("Location", p) .child( h_flex() .gap(px(6.)) .child(field(&self.fields.latitude, p)) .child(field(&self.fields.longitude, p)), ) .child( h_flex() .gap(px(6.)) .child( MetalButton::new("device-location-preset") .compact() .label("Preset…") .caret() .disabled(disabled) .dropdown_menu_with_anchor(Anchor::TopLeft, move |mut menu, _, _| { for (label, latitude, longitude) in LOCATION_PRESETS { let weak = preset_weak.clone(); menu = menu.item(PopupMenuItem::new(label).on_click(move |_, window, cx| { let _ = weak.update(cx, |this, cx| { this.fields.latitude.update(cx, |input, cx| input.set_value(latitude.to_string(), window, cx)); this.fields.longitude.update(cx, |input, cx| input.set_value(longitude.to_string(), window, cx)); this.act(Action::SetLocation { latitude, longitude }, cx); }); })); } menu }), ) .child(small_button("device-location-set", "Set", disabled || self.location(cx).is_none(), cx, |this, _, cx| { if let Some((latitude, longitude)) = this.location(cx) { this.act(Action::SetLocation { latitude, longitude }, cx); } })), ); if ios { location = location.child( MetalButton::new("device-location-clear") .ghost() .compact() .label("Clear") .disabled(disabled) .on_click(cx.listener(|this, _, window, cx| { this.fields.latitude.update(cx, |input, cx| input.set_value("", window, cx)); this.fields.longitude.update(cx, |input, cx| input.set_value("", window, cx)); this.act(Action::ClearLocation, cx); })), ); } body = body.child(location); } // Permissions. { let permissions: &[(&'static str, &'static str)] = if ios { &IOS_PERMISSIONS } else { &ANDROID_PERMISSIONS }; let current = permissions.iter().find(|(key, _)| *key == self.permission).map(|(_, label)| *label).unwrap_or("Camera"); let app_id = self.permission_app(foreground.as_ref().map(|app| app.id.as_str()), cx); let no_app = app_id.is_none(); let pick_weak = weak.clone(); let keys: Vec<&'static str> = permissions.iter().map(|(key, _)| *key).collect(); let decide = |decision: &'static str| { let app_id = app_id.clone(); move |this: &mut Self, _: &mut Window, cx: &mut Context| { if let Some(app_id) = app_id.clone() { let permission = this.permission; this.act(Action::SetPermission { app_id, permission, decision }, cx); } } }; let mut buttons = h_flex() .gap(px(6.)) .flex_wrap() .child(select( "device-permission", current, disabled, permissions.iter().map(|(key, label)| (*label, *key == self.permission)).collect(), move |ix, cx| { let key = keys[ix]; let _ = pick_weak.update(cx, |this, cx| { this.permission = key; cx.notify(); }); }, )) .child(small_button("device-permission-grant", "Grant", disabled || no_app, cx, decide("grant"))) .child(small_button("device-permission-revoke", "Revoke", disabled || no_app, cx, decide("revoke"))); if ios { buttons = buttons.child(small_button("device-permission-reset", "Reset", disabled || no_app, cx, decide("reset"))); } body = body.child(section("Permissions", p).child(field(&self.fields.permission_app, p)).child(buttons)); } if ios { let app = foreground.as_ref().map(|app| app.id.clone()); let mut push = section("Push notification", p).child(self.submit_row( "device-push", &self.fields.push, "Send", disabled || app.is_none(), cx, move |text| Action::SendPush { app_id: app.clone().unwrap_or_default(), text }, )); if foreground.is_none() { push = push.child(small_text("Open an app first.", p)); } body = body.child(push); } v_flex() .h_full() .w(px(TOOLS_WIDTH)) .flex_none() .bg(p.s) .border_l_1() .border_color(p.rule) .child( h_flex() .h(px(36.)) .flex_none() .px(px(12.)) .gap(px(8.)) .border_b_1() .border_color(p.rule) .child(div().text_size(px(13.)).font_weight(FontWeight::MEDIUM).child("Tools")) .when(self.pending, |this| this.child(metal::led("device-tools-pending", Led::Waiting, true, p))) .child(div().flex_1()) .child( MetalButton::new("device-tools-close") .ghost() .mini() .icon(Lucide::X) .tooltip("Close tools") .on_click(cx.listener(|this, _, _, cx| { this.tools_open = false; cx.notify(); })), ), ) .child(body) } /// The latitude and longitude typed in, when both are valid. fn location(&self, cx: &App) -> Option<(f64, f64)> { let read = |input: &Entity| input.read(cx).value().trim().parse::().ok(); let (latitude, longitude) = (read(&self.fields.latitude)?, read(&self.fields.longitude)?); (latitude.abs() <= 90. && longitude.abs() <= 180.).then_some((latitude, longitude)) } /// The app permissions apply to: the field, or the foreground app. fn permission_app(&self, foreground: Option<&str>, cx: &App) -> Option { let typed = self.fields.permission_app.read(cx).value().trim().to_string(); if typed.is_empty() { foreground.map(str::to_string) } else { Some(typed) } } /// A text field with its button, which runs the action on its text. fn submit_row( &self, id: &'static str, input: &Entity, label: &'static str, disabled: bool, cx: &mut Context, action: impl Fn(String) -> Action + 'static, ) -> impl IntoElement { let p = metal::palette(cx); let empty = input.read(cx).value().trim().is_empty(); let input_handle = input.clone(); h_flex().gap(px(6.)).child(field(input, p)).child(small_button(id, label, disabled || empty, cx, move |this, window, cx| { let text = input_handle.read(cx).value().trim().to_string(); if text.is_empty() { return; } this.act(action(text), cx); input_handle.update(cx, |input, cx| input.set_value("", window, cx)); })) } } impl Render for DeviceView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let p = metal::palette(cx); let wide = f32::from(self.area.size.width) + if self.tools_open { TOOLS_WIDTH } else { 0. } >= TOOLS_BESIDE; let screen = self.render_screen(cx); let tools = (self.tools_open && !self.compact).then(|| self.render_tools(cx)); let error = (!self.tools_open).then(|| self.error.clone()).flatten(); h_flex() .relative() .size_full() .bg(p.s) .child( v_flex() .relative() .flex_1() .min_w_0() .h_full() .when_some(error.filter(|_| !self.compact), |this, error| { this.child(div().px(px(12.)).py(px(6.)).text_size(px(11.5)).text_color(p.red).child(error)) }) .child(div().flex_1().min_h_0().child(screen)), ) .when_some(tools, |this, tools| { if wide { this.child(tools) } else { this.child(div().absolute().top_0().bottom_0().right_0().shadow_lg().child(tools)) } }) } } fn section(title: &'static str, p: &Palette) -> Div { v_flex() .gap(px(8.)) .px(px(12.)) .py(px(10.)) .border_b_1() .border_color(p.rule) .child(div().text_size(px(11.5)).font_weight(FontWeight::MEDIUM).text_color(p.ink3).child(title)) } fn row(label: &str, control: impl IntoElement, p: &Palette) -> Div { h_flex() .min_h(px(26.)) .gap(px(12.)) .justify_between() .child(div().flex_none().text_size(px(11.5)).text_color(p.ink3).child(label.to_string())) .child(h_flex().min_w_0().justify_end().child(control)) } fn small_text(text: impl Into, p: &Palette) -> Div { div().text_size(px(11.5)).line_height(px(16.)).text_color(p.ink3).child(text.into()) } fn field(input: &Entity, p: &Palette) -> Div { h_flex() .flex_1() .min_w_0() .h(px(26.)) .px(px(8.)) .rounded(px(8.)) .well(p) .text_color(p.ink) .child(Input::new(input).appearance(false).xsmall()) } fn small_button( id: &'static str, label: &'static str, disabled: bool, cx: &mut Context, on_click: impl Fn(&mut DeviceView, &mut Window, &mut Context) + 'static, ) -> MetalButton { MetalButton::new(id).compact().label(label).disabled(disabled).on_click(cx.listener(move |this, _, window, cx| on_click(this, window, cx))) } /// A switch that shows unknown values as off and can't be flipped then. fn switch( id: impl Into, value: Option, disabled: bool, p: &Palette, on_toggle: impl Fn(bool, &mut Window, &mut App) + 'static, ) -> AnyElement { let blocked = disabled || value.is_none(); metal::toggle(id, value.unwrap_or(false), p, move |on, window, cx| { if !blocked { on_toggle(on, window, cx); } }) .when(blocked, |this| this.opacity(0.5).cursor_default()) .into_any_element() } /// Two options as a segmented switch; an unknown value raises neither. fn choice_segment( id: &'static str, options: &[(bool, &'static str)], value: Option, disabled: bool, p: &Palette, on_pick: impl Fn(bool, &mut Window, &mut App) + 'static, ) -> AnyElement { // `segmented` needs a current value; an unknown one picks an option nobody has. let options: Vec<(Option, &'static str)> = options.iter().map(|(value, label)| (Some(*value), *label)).collect(); metal::segmented(id, &options, value, p, move |picked, window, cx| { if !disabled && let Some(picked) = picked { on_pick(picked, window, cx); } }) .when(disabled, |this| this.opacity(0.5)) .into_any_element() } /// A dropdown of labelled options; `on_pick` gets the picked index. fn select( id: &'static str, current: &'static str, disabled: bool, options: Vec<(&'static str, bool)>, on_pick: impl Fn(usize, &mut App) + 'static, ) -> AnyElement { let on_pick = std::rc::Rc::new(on_pick); MetalButton::new(id) .compact() .label(current) .caret() .disabled(disabled) .dropdown_menu_with_anchor(Anchor::TopRight, move |mut menu, _, _| { for (ix, (label, checked)) in options.iter().enumerate() { let on_pick = on_pick.clone(); menu = menu.item(PopupMenuItem::new(*label).checked(*checked).on_click(move |_, _, cx| on_pick(ix, cx))); } menu }) .into_any_element() }