From c0edb5bd6c5f8bb9597b1d270b67fd256fcd7787 Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Wed, 6 Mar 2024 18:15:06 -0800 Subject: [PATCH] GPUI custom window prompts (#8980) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This adds a GPUI fallback for window prompts. Linux does not support this feature by default, so we have to implement it ourselves. This implementation also makes it possible for GPUI clients to override the platform prompts with their own implementations. This is just a first pass. These alerts are not keyboard accessible yet, does not reflect the prompt level, they're implemented in-window, rather than as popups, and the whole feature need a pass from a designer. Regardless, this gets us one step closer to Linux support :) Screenshot 2024-03-06 at 5 58 08 PM Release Notes: - N/A --- crates/gpui/src/app.rs | 27 ++- crates/gpui/src/color.rs | 10 + crates/gpui/src/elements/div.rs | 2 +- crates/gpui/src/platform.rs | 2 +- .../gpui/src/platform/linux/wayland/window.rs | 5 +- crates/gpui/src/platform/linux/x11/window.rs | 5 +- crates/gpui/src/platform/mac/window.rs | 4 +- crates/gpui/src/platform/test/window.rs | 16 +- crates/gpui/src/platform/windows/window.rs | 2 +- crates/gpui/src/view.rs | 2 +- crates/gpui/src/window.rs | 63 ++++- crates/gpui/src/window/prompts.rs | 229 ++++++++++++++++++ 12 files changed, 339 insertions(+), 28 deletions(-) create mode 100644 crates/gpui/src/window/prompts.rs diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 9373ad66e85a760695ed1501b1d6ececd85fa467..cc03e9a8e6b242d4e38352d89767701b26c4399b 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -28,14 +28,14 @@ use util::{ ResultExt, }; -use crate::WindowAppearance; use crate::{ current_platform, image_cache::ImageCache, init_app_menus, Action, ActionRegistry, Any, AnyView, AnyWindowHandle, AppMetadata, AssetSource, BackgroundExecutor, ClipboardItem, Context, DispatchPhase, Entity, EventEmitter, ForegroundExecutor, Global, KeyBinding, Keymap, Keystroke, - LayoutId, Menu, PathPromptOptions, Pixels, Platform, PlatformDisplay, Point, Render, - SharedString, SubscriberSet, Subscription, SvgRenderer, Task, TextStyle, TextStyleRefinement, - TextSystem, View, ViewContext, Window, WindowContext, WindowHandle, WindowId, + LayoutId, Menu, PathPromptOptions, Pixels, Platform, PlatformDisplay, Point, PromptBuilder, + PromptHandle, PromptLevel, Render, RenderablePromptHandle, SharedString, SubscriberSet, + Subscription, SvgRenderer, Task, TextStyle, TextStyleRefinement, TextSystem, View, ViewContext, + Window, WindowAppearance, WindowContext, WindowHandle, WindowId, }; mod async_context; @@ -242,6 +242,7 @@ pub struct AppContext { pub(crate) quit_observers: SubscriberSet<(), QuitHandler>, pub(crate) layout_id_buffer: Vec, // We recycle this memory across layout requests. pub(crate) propagate_event: bool, + pub(crate) prompt_builder: Option, } impl AppContext { @@ -301,6 +302,7 @@ impl AppContext { quit_observers: SubscriberSet::new(), layout_id_buffer: Default::default(), propagate_event: true, + prompt_builder: Some(PromptBuilder::Default), }), }); @@ -1207,6 +1209,23 @@ impl AppContext { pub fn has_active_drag(&self) -> bool { self.active_drag.is_some() } + + /// Set the prompt renderer for GPUI. This will replace the default or platform specific + /// prompts with this custom implementation. + pub fn set_prompt_builder( + &mut self, + renderer: impl Fn( + PromptLevel, + &str, + Option<&str>, + &[&str], + PromptHandle, + &mut WindowContext, + ) -> RenderablePromptHandle + + 'static, + ) { + self.prompt_builder = Some(PromptBuilder::Custom(Box::new(renderer))) + } } impl Context for AppContext { diff --git a/crates/gpui/src/color.rs b/crates/gpui/src/color.rs index caf7cddf69a09c314096e56115de00f60be7aac5..7246af46a8fcdf81bae9e683d14398afba479e59 100644 --- a/crates/gpui/src/color.rs +++ b/crates/gpui/src/color.rs @@ -247,6 +247,16 @@ pub fn transparent_black() -> Hsla { } } +/// Opaque grey in [`Hsla`], values will be clamped to the range [0, 1] +pub fn opaque_grey(lightness: f32, opacity: f32) -> Hsla { + Hsla { + h: 0., + s: 0., + l: lightness.clamp(0., 1.), + a: opacity.clamp(0., 1.), + } +} + /// Pure white in [`Hsla`] pub fn white() -> Hsla { Hsla { diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 27a0e4615f72a8fc250be4b43a70df1c5d674e00..c2b80b56c1f5edc87542c7da556e106b7ee58579 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -499,7 +499,7 @@ pub trait InteractiveElement: Sized { self } - /// Assign this elements + /// Assign this element an ID, so that it can be used with interactivity fn id(mut self, id: impl Into) -> Stateful { self.interactivity().element_id = Some(id.into()); diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 1e1b66fffeb33090a28b9d32dcd37db12d7381e2..176d391d8234107fc21ead8da4330fc3b941325b 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -183,7 +183,7 @@ pub(crate) trait PlatformWindow: HasWindowHandle + HasDisplayHandle { msg: &str, detail: Option<&str>, answers: &[&str], - ) -> oneshot::Receiver; + ) -> Option>; fn activate(&self); fn set_title(&mut self, title: &str); fn set_edited(&mut self, edited: bool); diff --git a/crates/gpui/src/platform/linux/wayland/window.rs b/crates/gpui/src/platform/linux/wayland/window.rs index 5114a1136486b8b99421ac4808595a089da191dd..8cc633182e64cd85d89e2b0a3c78102aab62641f 100644 --- a/crates/gpui/src/platform/linux/wayland/window.rs +++ b/crates/gpui/src/platform/linux/wayland/window.rs @@ -310,15 +310,14 @@ impl PlatformWindow for WaylandWindow { self.0.inner.borrow_mut().input_handler.take() } - // todo(linux) fn prompt( &self, level: PromptLevel, msg: &str, detail: Option<&str>, answers: &[&str], - ) -> Receiver { - unimplemented!() + ) -> Option> { + None } fn activate(&self) { diff --git a/crates/gpui/src/platform/linux/x11/window.rs b/crates/gpui/src/platform/linux/x11/window.rs index 7c719c9ac77efa4d5ddc4ccf5cf8a3da4ceb84f9..e53833be6efc4bf9eef4e9e00aa8f06a302a6a06 100644 --- a/crates/gpui/src/platform/linux/x11/window.rs +++ b/crates/gpui/src/platform/linux/x11/window.rs @@ -399,15 +399,14 @@ impl PlatformWindow for X11Window { self.0.inner.borrow_mut().input_handler.take() } - // todo(linux) fn prompt( &self, _level: PromptLevel, _msg: &str, _detail: Option<&str>, _answers: &[&str], - ) -> futures::channel::oneshot::Receiver { - unimplemented!() + ) -> Option> { + None } fn activate(&self) { diff --git a/crates/gpui/src/platform/mac/window.rs b/crates/gpui/src/platform/mac/window.rs index 8e48cbef306186c237696bc7b17e9154ef1bbdc0..cbe8dfae14d44b450fc23cac971c705f5b31e868 100644 --- a/crates/gpui/src/platform/mac/window.rs +++ b/crates/gpui/src/platform/mac/window.rs @@ -840,7 +840,7 @@ impl PlatformWindow for MacWindow { msg: &str, detail: Option<&str>, answers: &[&str], - ) -> oneshot::Receiver { + ) -> Option> { // macOs applies overrides to modal window buttons after they are added. // Two most important for this logic are: // * Buttons with "Cancel" title will be displayed as the last buttons in the modal @@ -913,7 +913,7 @@ impl PlatformWindow for MacWindow { }) .detach(); - done_rx + Some(done_rx) } } diff --git a/crates/gpui/src/platform/test/window.rs b/crates/gpui/src/platform/test/window.rs index 70dcce9033b8e9a41022e3e31b1ec264ffbaac26..4dac341d695fc68addd7a1896817a0d6fed89208 100644 --- a/crates/gpui/src/platform/test/window.rs +++ b/crates/gpui/src/platform/test/window.rs @@ -169,13 +169,15 @@ impl PlatformWindow for TestWindow { _msg: &str, _detail: Option<&str>, _answers: &[&str], - ) -> futures::channel::oneshot::Receiver { - self.0 - .lock() - .platform - .upgrade() - .expect("platform dropped") - .prompt() + ) -> Option> { + Some( + self.0 + .lock() + .platform + .upgrade() + .expect("platform dropped") + .prompt(), + ) } fn activate(&self) { diff --git a/crates/gpui/src/platform/windows/window.rs b/crates/gpui/src/platform/windows/window.rs index 78a57f5ac40c1efd4394f491274a3ffca6390934..88a04e4d36534c9998ba93a69ace0f2c1ddc4168 100644 --- a/crates/gpui/src/platform/windows/window.rs +++ b/crates/gpui/src/platform/windows/window.rs @@ -746,7 +746,7 @@ impl PlatformWindow for WindowsWindow { msg: &str, detail: Option<&str>, answers: &[&str], - ) -> Receiver { + ) -> Option> { unimplemented!() } diff --git a/crates/gpui/src/view.rs b/crates/gpui/src/view.rs index 207eac63817a2b688a2d8a6c05cc00262bbf1da9..e66ffbb00d7dfe89b12f66197739d4ef142cc72e 100644 --- a/crates/gpui/src/view.rs +++ b/crates/gpui/src/view.rs @@ -203,7 +203,7 @@ impl Eq for WeakView {} #[derive(Clone, Debug)] pub struct AnyView { model: AnyModel, - request_layout: fn(&AnyView, &mut ElementContext) -> (LayoutId, AnyElement), + pub(crate) request_layout: fn(&AnyView, &mut ElementContext) -> (LayoutId, AnyElement), cache: bool, } diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index f615b192ee46d1bb521d600fd63301258a72a081..cc01e5223f2b1c072565af034293eae50e421c59 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -35,7 +35,10 @@ use std::{ use util::{measure, ResultExt}; mod element_cx; +mod prompts; + pub use element_cx::*; +pub use prompts::*; const ACTIVE_DRAG_Z_INDEX: u16 = 1; @@ -280,6 +283,7 @@ pub struct Window { pub(crate) focus: Option, focus_enabled: bool, pending_input: Option, + prompt: Option, } #[derive(Default, Debug)] @@ -473,6 +477,7 @@ impl Window { focus: None, focus_enabled: true, pending_input: None, + prompt: None, } } fn new_focus_listener( @@ -960,6 +965,7 @@ impl<'a> WindowContext<'a> { } let root_view = self.window.root_view.take().unwrap(); + let mut prompt = self.window.prompt.take(); self.with_element_context(|cx| { cx.with_z_index(0, |cx| { cx.with_key_dispatch(Some(KeyContext::default()), None, |_, cx| { @@ -978,10 +984,24 @@ impl<'a> WindowContext<'a> { } let available_space = cx.window.viewport_size.map(Into::into); - root_view.draw(Point::default(), available_space, cx); + + let origin = Point::default(); + cx.paint_view(root_view.entity_id(), |cx| { + cx.with_absolute_element_offset(origin, |cx| { + let (layout_id, mut rendered_element) = + (root_view.request_layout)(&root_view, cx); + cx.compute_layout(layout_id, available_space); + rendered_element.paint(cx); + + if let Some(prompt) = &mut prompt { + prompt.paint(cx).draw(origin, available_space, cx) + } + }); + }); }) }) }); + self.window.prompt = prompt; if let Some(active_drag) = self.app.active_drag.take() { self.with_element_context(|cx| { @@ -1551,15 +1571,48 @@ impl<'a> WindowContext<'a> { /// The provided message will be presented, along with buttons for each answer. /// When a button is clicked, the returned Receiver will receive the index of the clicked button. pub fn prompt( - &self, + &mut self, level: PromptLevel, message: &str, detail: Option<&str>, answers: &[&str], ) -> oneshot::Receiver { - self.window - .platform_window - .prompt(level, message, detail, answers) + let prompt_builder = self.app.prompt_builder.take(); + let Some(prompt_builder) = prompt_builder else { + unreachable!("Re-entrant window prompting is not supported by GPUI"); + }; + + let receiver = match &prompt_builder { + PromptBuilder::Default => self + .window + .platform_window + .prompt(level, message, detail, answers) + .unwrap_or_else(|| { + self.build_custom_prompt(&prompt_builder, level, message, detail, answers) + }), + PromptBuilder::Custom(_) => { + self.build_custom_prompt(&prompt_builder, level, message, detail, answers) + } + }; + + self.app.prompt_builder = Some(prompt_builder); + + receiver + } + + fn build_custom_prompt( + &mut self, + prompt_builder: &PromptBuilder, + level: PromptLevel, + message: &str, + detail: Option<&str>, + answers: &[&str], + ) -> oneshot::Receiver { + let (sender, receiver) = oneshot::channel(); + let handle = PromptHandle::new(sender); + let handle = (prompt_builder)(level, message, detail, answers, handle, self); + self.window.prompt = Some(handle); + receiver } /// Returns all available actions for the focused element. diff --git a/crates/gpui/src/window/prompts.rs b/crates/gpui/src/window/prompts.rs new file mode 100644 index 0000000000000000000000000000000000000000..9c75f223db97193f9ab1716a78c825647ccf97d6 --- /dev/null +++ b/crates/gpui/src/window/prompts.rs @@ -0,0 +1,229 @@ +use std::ops::Deref; + +use futures::channel::oneshot; + +use crate::{ + div, opaque_grey, white, AnyElement, AnyView, ElementContext, EventEmitter, FocusHandle, + FocusableView, InteractiveElement, IntoElement, ParentElement, PromptLevel, Render, + StatefulInteractiveElement, Styled, View, ViewContext, VisualContext, WindowContext, +}; + +/// The event emitted when a prompt's option is selected. +/// The usize is the index of the selected option, from the actions +/// passed to the prompt. +pub struct PromptResponse(pub usize); + +/// A prompt that can be rendered in the window. +pub trait Prompt: EventEmitter + FocusableView {} + +impl + FocusableView> Prompt for V {} + +/// A handle to a prompt that can be used to interact with it. +pub struct PromptHandle { + sender: oneshot::Sender, +} + +impl PromptHandle { + pub(crate) fn new(sender: oneshot::Sender) -> Self { + Self { sender } + } + + /// Construct a new prompt handle from a view of the appropriate types + pub fn with_view( + self, + view: View, + cx: &mut WindowContext, + ) -> RenderablePromptHandle { + let mut sender = Some(self.sender); + let previous_focus = cx.focused(); + cx.subscribe(&view, move |_, e: &PromptResponse, cx| { + if let Some(sender) = sender.take() { + sender.send(e.0).ok(); + cx.window.prompt.take(); + if let Some(previous_focus) = &previous_focus { + cx.focus(&previous_focus); + } + } + }) + .detach(); + + cx.focus_view(&view); + + RenderablePromptHandle { + view: Box::new(view), + } + } +} + +/// A prompt handle capable of being rendered in a window. +pub struct RenderablePromptHandle { + view: Box, +} + +impl RenderablePromptHandle { + pub(crate) fn paint(&mut self, _: &mut ElementContext) -> AnyElement { + self.view.any_view().into_any_element() + } +} + +/// Use this function in conjunction with [AppContext::set_prompt_renderer] to force +/// GPUI to always use the fallback prompt renderer. +pub fn fallback_prompt_renderer( + level: PromptLevel, + message: &str, + detail: Option<&str>, + actions: &[&str], + handle: PromptHandle, + cx: &mut WindowContext, +) -> RenderablePromptHandle { + let renderer = cx.new_view({ + |cx| FallbackPromptRenderer { + _level: level, + message: message.to_string(), + detail: detail.map(ToString::to_string), + actions: actions.iter().map(ToString::to_string).collect(), + focus: cx.focus_handle(), + } + }); + + handle.with_view(renderer, cx) +} + +/// The default GPUI fallback for rendering prompts, when the platform doesn't support it. +pub struct FallbackPromptRenderer { + _level: PromptLevel, + message: String, + detail: Option, + actions: Vec, + focus: FocusHandle, +} + +impl Render for FallbackPromptRenderer { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + let prompt = div() + .cursor_default() + .track_focus(&self.focus) + .w_72() + .bg(white()) + .rounded_lg() + .overflow_hidden() + .p_3() + .child( + div() + .w_full() + .flex() + .flex_row() + .justify_around() + .child(div().overflow_hidden().child(self.message.clone())), + ) + .children(self.detail.clone().map(|detail| { + div() + .w_full() + .flex() + .flex_row() + .justify_around() + .text_sm() + .mb_2() + .child(div().child(detail)) + })) + .children(self.actions.iter().enumerate().map(|(ix, action)| { + div() + .flex() + .flex_row() + .justify_around() + .border_1() + .border_color(opaque_grey(0.2, 0.5)) + .mt_1() + .rounded_sm() + .cursor_pointer() + .text_sm() + .child(action.clone()) + .id(ix) + .on_click(cx.listener(move |_, _, cx| { + cx.emit(PromptResponse(ix)); + })) + })); + + div() + .size_full() + .z_index(u16::MAX) + .child( + div() + .size_full() + .bg(opaque_grey(0.5, 0.6)) + .absolute() + .top_0() + .left_0(), + ) + .child( + div() + .size_full() + .absolute() + .top_0() + .left_0() + .flex() + .flex_col() + .justify_around() + .child( + div() + .w_full() + .flex() + .flex_row() + .justify_around() + .child(prompt), + ), + ) + } +} + +impl EventEmitter for FallbackPromptRenderer {} + +impl FocusableView for FallbackPromptRenderer { + fn focus_handle(&self, _: &crate::AppContext) -> FocusHandle { + self.focus.clone() + } +} + +trait PromptViewHandle { + fn any_view(&self) -> AnyView; +} + +impl PromptViewHandle for View { + fn any_view(&self) -> AnyView { + self.clone().into() + } +} + +pub(crate) enum PromptBuilder { + Default, + Custom( + Box< + dyn Fn( + PromptLevel, + &str, + Option<&str>, + &[&str], + PromptHandle, + &mut WindowContext, + ) -> RenderablePromptHandle, + >, + ), +} + +impl Deref for PromptBuilder { + type Target = dyn Fn( + PromptLevel, + &str, + Option<&str>, + &[&str], + PromptHandle, + &mut WindowContext, + ) -> RenderablePromptHandle; + + fn deref(&self) -> &Self::Target { + match self { + Self::Default => &fallback_prompt_renderer, + Self::Custom(f) => f.as_ref(), + } + } +}