Vectrum
Sign In
1255 lines · 49.0 KB · Rust
//! 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<InputState>,
    launch: Entity<InputState>,
    latitude: Entity<InputState>,
    longitude: Entity<InputState>,
    permission_app: Entity<InputState>,
    push: Entity<InputState>,
}

pub struct DeviceView {
    pub device: DeviceSummary,
    host_label: String,
    stream: Option<DeviceStream>,
    frame: Option<Frame>,
    status: Status,
    status_detail: Option<SharedString>,
    screen: Option<Screen>,
    input_connected: bool,
    input_detail: Option<SharedString>,
    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<Pixels>,
    screen_area: Bounds<Pixels>,
    touching: bool,
    tools_open: bool,
    ax_overlay: bool,
    ax: Vec<AxElement>,
    detail: Option<Detail>,
    pending: bool,
    error: Option<SharedString>,
    screenshot_pending: bool,
    fields: Fields,
    permission: &'static str,
    _events: Task<()>,
    _ax: Task<()>,
    _detail: Task<()>,
}

impl EventEmitter<DeviceViewEvent> for DeviceView {}

impl DeviceView {
    pub fn new(device: DeviceSummary, host_label: String, window: &mut Window, cx: &mut Context<Self>) -> Self {
        let field = |placeholder: &str, window: &mut Window, cx: &mut Context<Self>| {
            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<Self>) {
        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<Self>) {
        if self.device != device {
            self.device = device;
            cx.notify();
        }
    }

    fn ensure_stream(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        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<Self>) {
        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<Self>) {
        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<Self>) {
        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<Self>) {
        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<Self>) {
        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<Self>) {
        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<Pixels>) -> (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<Pixels>) {
        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<Self>) {
        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<Self>) -> 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<Self>) -> 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<Icon>,
        label: &'static str,
        disabled: bool,
        cx: &mut Context<Self>,
        on_click: impl Fn(&mut Self, &mut Window, &mut Context<Self>) + '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<Self>) -> 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<Self>| {
            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<Self>) -> 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::<DeviceWatch>().state.host_detail.clone().filter(|_| cx.global::<DeviceWatch>().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<Self>| {
                    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<InputState>| input.read(cx).value().trim().parse::<f64>().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<String> {
        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<InputState>,
        label: &'static str,
        disabled: bool,
        cx: &mut Context<Self>,
        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<Self>) -> 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<SharedString>, p: &Palette) -> Div {
    div().text_size(px(11.5)).line_height(px(16.)).text_color(p.ink3).child(text.into())
}

fn field(input: &Entity<InputState>, 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<DeviceView>,
    on_click: impl Fn(&mut DeviceView, &mut Window, &mut Context<DeviceView>) + '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<ElementId>,
    value: Option<bool>,
    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<bool>,
    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<bool>, &'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()
}