From 99414165d30405af3e510039ef4330ea548c68a9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 17 May 2026 20:16:15 +0000 Subject: [PATCH] Add QR code to remote control toast Co-authored-by: Zach Lloyd --- Cargo.lock | 10 +++ app/Cargo.toml | 1 + app/src/view_components/dismissible_toast.rs | 52 +++++++++++-- app/src/workspace/view.rs | 82 +++++++++++++++++++- 4 files changed, 134 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5fffb3eb0f..8585ea4f3b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10198,6 +10198,15 @@ version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" +dependencies = [ + "image", +] + [[package]] name = "quick-error" version = "2.0.1" @@ -14439,6 +14448,7 @@ dependencies = [ "prost 0.14.3", "prost-build", "prost-types", + "qrcode", "rand 0.8.6", "rangemap", "rayon", diff --git a/app/Cargo.toml b/app/Cargo.toml index a88eee5de0..9ca4d3189b 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -256,6 +256,7 @@ rmcp = { workspace = true, features = ["client"] } warp_isolation_platform.workspace = true warp_ripgrep.workspace = true warp_managed_secrets.workspace = true +qrcode = "0.14.1" [target.'cfg(target_os = "macos")'.dependencies] block.workspace = true diff --git a/app/src/view_components/dismissible_toast.rs b/app/src/view_components/dismissible_toast.rs index 9faba52c62..49734bf7e3 100644 --- a/app/src/view_components/dismissible_toast.rs +++ b/app/src/view_components/dismissible_toast.rs @@ -6,15 +6,17 @@ use pathfinder_geometry::vector::vec2f; use uuid::Uuid; use warp_core::ui::builder::UiBuilder; use warp_core::ui::theme::color::internal_colors; +use warpui::assets::asset_cache::AssetSource; use warpui::elements::ChildView; use warpui::keymap::Keystroke; use warpui::r#async::Timer; use warpui::{ elements::{ - Border, ChildAnchor, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, - DispatchEventResult, EventHandler, Flex, Hoverable, Icon, MainAxisAlignment, MainAxisSize, - MouseStateHandle, OffsetPositioning, ParentElement, PositionedElementAnchor, - PositionedElementOffsetBounds, Radius, SavePosition, Shrinkable, Stack, + Border, CacheOption, ChildAnchor, ConstrainedBox, Container, CornerRadius, + CrossAxisAlignment, DispatchEventResult, EventHandler, Flex, Hoverable, Icon, Image, + MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning, ParentElement, + PositionedElementAnchor, PositionedElementOffsetBounds, Radius, SavePosition, Shrinkable, + Stack, }, fonts::Weight, r#async::SpawnedFutureHandle, @@ -27,7 +29,7 @@ use crate::{appearance::Appearance, themes::theme::Fill}; use super::action_button::ActionButton; -const TOAST_WIDTH: f32 = 464.; +pub const DISMISSIBLE_TOAST_WIDTH: f32 = 464.; const TOAST_CORNER_RADIUS: f32 = 4.; const TEXT_MARGIN: f32 = 16.; const VERTICAL_PADDING: f32 = 8.; @@ -321,6 +323,7 @@ pub struct DismissibleToast { /// same ID, as it's likely the older ones are now out-of-date. object_id: Option, action_button: Option>, + qr_code_asset_id: Option, /// Optional callback invoked when the toast body is clicked. pub(crate) on_body_click: Option>, } @@ -339,6 +342,7 @@ impl DismissibleToast { close_button_hover_state: Default::default(), object_id: Default::default(), action_button: Default::default(), + qr_code_asset_id: Default::default(), on_body_click: None, } } @@ -371,6 +375,12 @@ impl DismissibleToast { self } + /// Renders a QR code image below the toast body. + pub fn with_qr_code_asset_id(mut self, asset_id: String) -> Self { + self.qr_code_asset_id = Some(asset_id); + self + } + /// Sets a callback to be invoked when the toast body is clicked. /// When set, the entire toast body becomes clickable. pub fn with_on_body_click(mut self, callback: F) -> Self @@ -428,7 +438,7 @@ impl DismissibleToast { ConstrainedBox::new( link.render(ui_builder, self.flavor.text_color(appearance)), ) - .with_max_width(TOAST_WIDTH / 3.) + .with_max_width(DISMISSIBLE_TOAST_WIDTH / 3.) .finish(), ) .with_margin_left(TEXT_MARGIN) @@ -479,8 +489,36 @@ impl DismissibleToast { toast_container }; + let toast_content: Box = + if let Some(qr_code_asset_id) = &self.qr_code_asset_id { + let toast_body = ConstrainedBox::new(toast_element) + .with_width(DISMISSIBLE_TOAST_WIDTH) + .finish(); + let qr_code = ConstrainedBox::new( + Image::new( + AssetSource::Raw { + id: qr_code_asset_id.clone(), + }, + CacheOption::BySize, + ) + .stretch() + .finish(), + ) + .with_width(DISMISSIBLE_TOAST_WIDTH) + .with_height(DISMISSIBLE_TOAST_WIDTH) + .finish(); + + Flex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_child(toast_body) + .with_child(Container::new(qr_code).with_margin_top(5.).finish()) + .finish() + } else { + toast_element + }; + let mut stack = Stack::new() - .with_child(SavePosition::new(toast_element, &self.position_id(uuid)).finish()); + .with_child(SavePosition::new(toast_content, &self.position_id(uuid)).finish()); if mouse_state.is_hovered() || is_mobile { stack.add_positioned_overlay_child( diff --git a/app/src/workspace/view.rs b/app/src/workspace/view.rs index 640a23e0be..100eaf852f 100644 --- a/app/src/workspace/view.rs +++ b/app/src/workspace/view.rs @@ -376,6 +376,7 @@ use crate::view_components::callout_bubble::{ }; use crate::view_components::{ AgentToast, AgentToastStack, DismissibleToast, DismissibleToastStack, ToastLink, + DISMISSIBLE_TOAST_WIDTH, }; use crate::window_settings::{WindowSettings, WindowSettingsChangedEvent, ZoomLevel}; use crate::workflows::{ @@ -402,12 +403,14 @@ use futures::Future; use itertools::Itertools; use parking_lot::FairMutex; use pathfinder_geometry::rect::RectF; +use qrcode::{types::Color as QrColor, QrCode}; #[cfg(feature = "local_fs")] use repo_metadata::repositories::DetectedRepositories; use session_sharing_protocol::common::SessionId as SharedSessionId; use std::collections::{HashMap, HashSet}; #[cfg(feature = "local_fs")] use std::convert::TryFrom; +use std::io::Cursor as IoCursor; use std::time::Duration; #[cfg(target_os = "macos")] use std::time::{SystemTime, UNIX_EPOCH}; @@ -419,6 +422,7 @@ use warpui::fonts::Weight; use warpui::modals::{AlertDialogWithCallbacks, AppModalCallback}; use warp_core::user_preferences::GetUserPreferences as _; +use warpui::assets::asset_cache::AssetCache; use warpui::clipboard::ClipboardContent; #[cfg(target_family = "wasm")] use warpui::elements::Percentage; @@ -426,6 +430,7 @@ use warpui::elements::{ CacheOption, DispatchEventResult, DraggableState, DropTarget, EventHandler, Image, MouseInBehavior, Rect, }; +use warpui::image_cache::ImageType; use warpui::ui_components::button::{Button, ButtonVariant}; use warpui::windowing::{state::ApplicationStage, StateEvent, WindowManager}; use warpui::{elements::MouseStateHandle, fonts::Properties}; @@ -612,6 +617,51 @@ const NEW_SESSION_SIDECAR_POSITION_ID: &str = "new_session_sidecar"; const NEW_SESSION_SIDECAR_WIDTH: f32 = 300.; const NEW_SESSION_SIDECAR_SEARCH_BOX_HEIGHT: f32 = 32.; const NEW_SESSION_SIDECAR_SEARCH_BOX_HORIZONTAL_PADDING: f32 = 12.; + +const QR_CODE_QUIET_ZONE_MODULES: usize = 4; + +fn render_remote_control_qr_code_png(shared_session_link: &str) -> Option> { + let code = QrCode::new(shared_session_link.as_bytes()).ok()?; + let image_size = DISMISSIBLE_TOAST_WIDTH.round() as u32; + let module_count = code.width(); + let modules_with_quiet_zone = module_count + (QR_CODE_QUIET_ZONE_MODULES * 2); + let pixels_per_module = ((image_size as usize) / modules_with_quiet_zone).max(1); + let qr_size = modules_with_quiet_zone * pixels_per_module; + let quiet_zone_offset = ((image_size as usize).saturating_sub(qr_size)) / 2; + + let mut image = + image::RgbaImage::from_pixel(image_size, image_size, image::Rgba([255, 255, 255, 255])); + let dark_module = image::Rgba([0, 0, 0, 255]); + + for y in 0..module_count { + for x in 0..module_count { + if code[(x, y)] != QrColor::Dark { + continue; + } + + let start_x = + quiet_zone_offset + ((x + QR_CODE_QUIET_ZONE_MODULES) * pixels_per_module); + let start_y = + quiet_zone_offset + ((y + QR_CODE_QUIET_ZONE_MODULES) * pixels_per_module); + let end_x = (start_x + pixels_per_module).min(image_size as usize); + let end_y = (start_y + pixels_per_module).min(image_size as usize); + + for pixel_y in start_y..end_y { + for pixel_x in start_x..end_x { + image.put_pixel(pixel_x as u32, pixel_y as u32, dark_module); + } + } + } + } + + let mut png_bytes = Vec::new(); + let mut cursor = IoCursor::new(&mut png_bytes); + image::DynamicImage::ImageRgba8(image) + .write_to(&mut cursor, image::ImageFormat::Png) + .ok()?; + + Some(png_bytes) +} const NEW_SESSION_SIDECAR_SEARCH_BOX_VERTICAL_PADDING: f32 = 6.; const NEW_SESSION_SIDECAR_FOOTER_HORIZONTAL_PADDING: f32 = 16.; const NEW_SESSION_SIDECAR_FOOTER_VERTICAL_PADDING: f32 = 8.; @@ -4233,16 +4283,40 @@ impl Workspace { session_id: &SharedSessionId, ctx: &mut ViewContext, ) { - ctx.clipboard().write(ClipboardContent::plain_text( - terminal::shared_session::join_link(session_id), - )); + let shared_session_link = terminal::shared_session::join_link(session_id); + ctx.clipboard() + .write(ClipboardContent::plain_text(shared_session_link.clone())); + + let qr_code_asset_id = + Self::insert_remote_control_qr_code(&shared_session_link, session_id, ctx); self.toast_stack.update(ctx, |toast_stack, ctx| { - let toast = DismissibleToast::default("Remote control link copied.".to_string()); + let mut toast = DismissibleToast::default("Remote control link copied.".to_string()); + if let Some(qr_code_asset_id) = qr_code_asset_id { + toast = toast.with_qr_code_asset_id(qr_code_asset_id); + } toast_stack.add_ephemeral_toast(toast, ctx); }); } + fn insert_remote_control_qr_code( + shared_session_link: &str, + session_id: &SharedSessionId, + ctx: &mut ViewContext, + ) -> Option { + let asset_id = format!("remote-control-link-qr-{session_id}"); + let Some(qr_code_png) = render_remote_control_qr_code_png(shared_session_link) else { + log::warn!("Failed to generate remote control QR code"); + return None; + }; + + AssetCache::handle(ctx).update(ctx, |asset_cache, ctx| { + asset_cache.insert_raw_asset_bytes::(asset_id.clone(), &qr_code_png, ctx); + }); + + Some(asset_id) + } + // Returns true if the focused pane is the viewer of a shared session pub fn is_shared_session_viewer_focused(&self, app: &AppContext) -> bool { self.active_tab_pane_group()