//! 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::Backend;
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,
backend: Backend,
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, backend: Backend, 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,
backend,
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(hub) = self.backend.hub() 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(hub, 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();
let backend = self.backend.clone();
self._detail = cx.spawn(async move |this, cx| {
let result = cx.background_executor().spawn(async move { backend.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(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();
let backend = self.backend.clone();
cx.spawn(async move |this, cx| {
let result = cx.background_executor().spawn(async move { backend.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(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();
let backend = self.backend.clone();
self._ax = cx.spawn(async move |this, cx| {
loop {
let (id, backend) = (id.clone(), backend.clone());
let tree = cx.background_executor().spawn(async move { backend.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 backend = self.backend.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 = backend.screenshot(&id)?;
std::fs::write(&target, png).map_err(|err| err.to_string())
})
.await
}
None => Ok(()),
};
let _ = this.update(cx, |this, cx| {
this.screenshot_pending = false;
if let Err(err) = result {
this.error = Some(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…"),
);
}
let host = self.backend.state(cx);
if let Some(diagnostics) = host.host_detail.clone().filter(|_| host.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: permission.into(), decision: decision.into() }, 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()
}