diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 9373ad66e8..cc03e9a8e6 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 caf7cddf69..7246af46a8 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 27a0e4615f..c2b80b56c1 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 1e1b66fffe..176d391d82 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 5114a11364..8cc633182e 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 7c719c9ac7..e53833be6e 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 8e48cbef30..cbe8dfae14 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 70dcce9033..4dac341d69 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 78a57f5ac4..88a04e4d36 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 207eac6381..e66ffbb00d 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 f615b192ee..cc01e5223f 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 0000000000..9c75f223db --- /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(), + } + } +}