diff --git a/crates/moon-gpui-linux/src/linux/wayland/window.rs b/crates/moon-gpui-linux/src/linux/wayland/window.rs index 1342f5a..10431ad 100644 --- a/crates/moon-gpui-linux/src/linux/wayland/window.rs +++ b/crates/moon-gpui-linux/src/linux/wayland/window.rs @@ -558,7 +558,7 @@ impl WaylandWindow { WaylandWindow::gpu_canvas_refresh_interval(&state) }; - window.frame(); + window.poll_frame(); let now = std::time::Instant::now(); while instant < now { @@ -646,9 +646,19 @@ impl WaylandWindowStatePtr { !state.children.is_empty() } + /// Start the compositor callback chain on first configure, then replace each completed + /// callback once. Timer polls must not extend this chain: every request survives until Done. pub fn frame(&self) { - let mut state = self.state.borrow_mut(); + let state = self.state.borrow(); state.surface.frame(&state.globals.qh, state.surface.id()); + drop(state); + self.poll_frame(); + } + + /// Poll GPUI and its canvases without registering another Wayland callback. The fallback + /// timer keeps Skip-to-Present transitions live even while the compositor withholds Done. + fn poll_frame(&self) { + let mut state = self.state.borrow_mut(); state.resize_throttle = false; let force_render = state.force_render_after_recovery; state.force_render_after_recovery = false; @@ -1469,7 +1479,7 @@ impl PlatformWindow for WaylandWindow { self.0.callbacks.borrow_mut().button_layout_changed = Some(callback); } - fn draw(&self, scene: &Scene) { + fn draw(&self, scene: &Scene) -> bool { let mut state = self.borrow_mut(); if state.renderer.device_lost() { @@ -1491,7 +1501,7 @@ impl PlatformWindow for WaylandWindow { } state.force_render_after_recovery = true; - return; + return false; } state.renderer_presented = state.renderer.draw(scene); @@ -1499,6 +1509,7 @@ impl PlatformWindow for WaylandWindow { if state.renderer.needs_redraw() { state.force_render_after_recovery = true; } + state.renderer_presented } fn set_gpu_canvas_active(&self, active: bool) { @@ -1605,6 +1616,12 @@ impl PlatformWindow for WaylandWindow { self.borrow().renderer.gpu_specs().into() } + /// Invalidate the shared device; the next native draw runs normal recovery. + fn request_gpu_device_reset(&self) -> bool { + self.borrow().renderer.request_device_reset(); + true + } + fn play_system_bell(&self) { let state = self.borrow(); let surface = if state.surface_state.toplevel().is_some() { diff --git a/crates/moon-gpui-linux/src/linux/x11/client.rs b/crates/moon-gpui-linux/src/linux/x11/client.rs index 49438df..d0a691b 100644 --- a/crates/moon-gpui-linux/src/linux/x11/client.rs +++ b/crates/moon-gpui-linux/src/linux/x11/client.rs @@ -1577,6 +1577,7 @@ impl LinuxClient for X11Client { gpui::scap_screen_capture::scap_screen_sources(&self.0.borrow().common.foreground_executor) } + /// Open a native window and deliver events buffered by its synchronous X11 setup. fn open_window( &self, handle: AnyWindowHandle, @@ -1656,6 +1657,13 @@ impl LinuxClient for X11Client { }; state.windows.insert(x_window, window_ref); + // Synchronous setup can buffer X11 events without leaving the socket readable. + // Drain after GPUI installs callbacks and maps the window, so the first frame + // does not depend on unrelated input. An idle alone does not wake calloop's poll. + state.loop_handle.insert_idle(move |client| { + client.process_x11_events(&xcb_connection).log_err(); + }); + state.common.signal.wakeup(); Ok(Box::new(window)) } diff --git a/crates/moon-gpui-linux/src/linux/x11/window.rs b/crates/moon-gpui-linux/src/linux/x11/window.rs index f829be7..60f7a1e 100644 --- a/crates/moon-gpui-linux/src/linux/x11/window.rs +++ b/crates/moon-gpui-linux/src/linux/x11/window.rs @@ -1689,7 +1689,7 @@ impl PlatformWindow for X11Window { self.0.callbacks.borrow_mut().button_layout_changed = Some(callback); } - fn draw(&self, scene: &Scene) { + fn draw(&self, scene: &Scene) -> bool { let mut inner = self.0.state.borrow_mut(); if inner.renderer.device_lost() { @@ -1709,14 +1709,15 @@ impl PlatformWindow for X11Window { } inner.force_render_after_recovery = true; - return; + return false; } - inner.renderer.draw(scene); + let accepted = inner.renderer.draw(scene); if inner.renderer.needs_redraw() { inner.force_render_after_recovery = true; } + accepted } fn sprite_atlas(&self) -> Arc { @@ -1912,6 +1913,12 @@ impl PlatformWindow for X11Window { self.0.state.borrow().renderer.gpu_specs().into() } + /// Invalidate the shared device; the next native draw runs normal recovery. + fn request_gpu_device_reset(&self) -> bool { + self.0.state.borrow().renderer.request_device_reset(); + true + } + fn play_system_bell(&self) { // Volume 0% means don't increase or decrease from system volume let _ = self.0.xcb.bell(0); diff --git a/crates/moon-gpui-macos/src/metal_renderer.rs b/crates/moon-gpui-macos/src/metal_renderer.rs index 1f2ef98..f58d50d 100644 --- a/crates/moon-gpui-macos/src/metal_renderer.rs +++ b/crates/moon-gpui-macos/src/metal_renderer.rs @@ -551,14 +551,15 @@ impl MetalRenderer { // nothing to do } - pub fn draw(&mut self, scene: &Scene) { + /// Return whether a drawable was acquired and its frame submitted for presentation. + pub fn draw(&mut self, scene: &Scene) -> bool { let layer = match &self.layer { Some(l) => l.clone(), None => { log::error!( "draw() called on headless renderer - use render_scene_to_image() instead" ); - return; + return false; } }; let viewport_size = layer.drawable_size(); @@ -573,7 +574,7 @@ impl MetalRenderer { "failed to retrieve next drawable, drawable size: {:?}", viewport_size ); - return; + return false; }; loop { @@ -605,7 +606,7 @@ impl MetalRenderer { command_buffer.present_drawable(drawable); command_buffer.commit(); } - return; + return true; } Err(err) => { log::error!( @@ -626,6 +627,7 @@ impl MetalRenderer { } } } + false } /// Renders the scene to a texture and returns the pixel data as an RGBA image. @@ -1060,6 +1062,7 @@ impl MetalRenderer { self.draw_primitives_to_texture(scene, instance_buffer, drawable.texture(), viewport_size) } + /// Record the scene, preserving canvas bakes when an instance-buffer retry is required. fn draw_primitives_to_texture( &mut self, scene: &Scene, @@ -1105,6 +1108,8 @@ impl MetalRenderer { &mut instance_offset, ); if !ok { + self.sync_instance_buffer(instance_buffer, instance_offset); + command_buffer.commit(); anyhow::bail!( "gpu canvas text too large: {} mono, {} poly", scene.gpu_canvas_text_under_scene.monochrome_sprites.len(), @@ -1211,6 +1216,8 @@ impl MetalRenderer { }; if !ok { command_encoder.end_encoding(); + self.sync_instance_buffer(instance_buffer, instance_offset); + command_buffer.commit(); anyhow::bail!( "scene too large: {} paths, {} shadows, {} quads, {} underlines, {} mono, {} poly, {} surfaces", scene.paths.len(), @@ -1238,6 +1245,8 @@ impl MetalRenderer { &mut instance_offset, ); if !ok { + self.sync_instance_buffer(instance_buffer, instance_offset); + command_buffer.commit(); anyhow::bail!( "gpu canvas text too large: {} mono, {} poly", scene.gpu_canvas_text_over_scene.monochrome_sprites.len(), @@ -1245,15 +1254,19 @@ impl MetalRenderer { ); } + self.sync_instance_buffer(instance_buffer, instance_offset); + + Ok(command_buffer.to_owned()) + } + + /// Publish CPU instance writes before submitting either a complete scene or a failed retry. + fn sync_instance_buffer(&self, instance_buffer: &InstanceBuffer, instance_offset: usize) { if !self.is_unified_memory { - // Sync the instance buffer to the GPU instance_buffer.metal_buffer.did_modify_range(NSRange { location: 0, length: instance_offset as NSUInteger, }); } - - Ok(command_buffer.to_owned()) } fn draw_paths_to_intermediate( diff --git a/crates/moon-gpui-macos/src/window.rs b/crates/moon-gpui-macos/src/window.rs index 664ce29..c27467c 100644 --- a/crates/moon-gpui-macos/src/window.rs +++ b/crates/moon-gpui-macos/src/window.rs @@ -1792,9 +1792,9 @@ impl PlatformWindow for MacWindow { self.0.as_ref().lock().toggle_tab_bar_callback = Some(callback); } - fn draw(&self, scene: &gpui::Scene) { + fn draw(&self, scene: &gpui::Scene) -> bool { let mut this = self.0.lock(); - this.renderer.draw(scene); + this.renderer.draw(scene) } fn sprite_atlas(&self) -> Arc { diff --git a/crates/moon-gpui-web/src/window.rs b/crates/moon-gpui-web/src/window.rs index aa8f782..65c6de0 100644 --- a/crates/moon-gpui-web/src/window.rs +++ b/crates/moon-gpui-web/src/window.rs @@ -666,7 +666,7 @@ impl PlatformWindow for WebWindow { self.inner.callbacks.borrow_mut().appearance_changed = Some(callback); } - fn draw(&self, scene: &Scene) { + fn draw(&self, scene: &Scene) -> bool { if let Some((width, height)) = self.inner.pending_physical_size.take() { if self.inner.canvas.width() != width || self.inner.canvas.height() != height { self.inner.canvas.set_width(width); @@ -681,7 +681,7 @@ impl PlatformWindow for WebWindow { drop(state); } - self.inner.state.borrow_mut().renderer.draw(scene); + self.inner.state.borrow_mut().renderer.draw(scene) } fn completed_frame(&self) { diff --git a/crates/moon-gpui-wgpu/src/wgpu_renderer.rs b/crates/moon-gpui-wgpu/src/wgpu_renderer.rs index 95b73c0..e3d622f 100644 --- a/crates/moon-gpui-wgpu/src/wgpu_renderer.rs +++ b/crates/moon-gpui-wgpu/src/wgpu_renderer.rs @@ -19,6 +19,9 @@ use std::ptr::NonNull; use std::rc::Rc; use std::sync::{Arc, Mutex, Weak}; +#[cfg(all(test, any(windows, target_os = "linux")))] +mod tests; + #[repr(C)] #[derive(Clone, Copy, Pod, Zeroable)] struct GlobalParams { @@ -514,7 +517,8 @@ impl WgpuRenderer { let last_error_clone = Arc::clone(&last_error); device.on_uncaptured_error(Arc::new(move |error| { let mut guard = last_error_clone.lock().unwrap(); - *guard = Some(error.to_string()); + // Preserve the cause: later submissions may only report that its pipeline is invalid. + guard.get_or_insert_with(|| error.to_string()); })); let resources = WgpuResources { @@ -1283,6 +1287,8 @@ impl WgpuRenderer { self.draw_gpu_canvas_text(text_frame, instance_offset, pass) } + /// Submit scene commands, retaining canvas preparation when instance growth retries it. + /// Returns false when the surface cannot accept this frame. pub fn draw(&mut self, scene: &Scene) -> bool { // Bail out early if the surface has been unconfigured (e.g. during // Android background/rotation transitions). Attempting to acquire @@ -1461,7 +1467,9 @@ impl WgpuRenderer { } } if overflow { - drop(encoder); + // Canvas prepare already committed its CPU state. Keep those GPU commands; + // the retry clears the partial surface before the only present below. + self.resources().queue.submit([encoder.finish()]); if self.instance_buffer_capacity >= self.max_buffer_size { log::error!( "instance buffer size grew too large: {}", @@ -1581,7 +1589,7 @@ impl WgpuRenderer { } if overflow { - drop(encoder); + self.resources().queue.submit([encoder.finish()]); if self.instance_buffer_capacity >= self.max_buffer_size { log::error!( "instance buffer size grew too large: {}", @@ -1625,7 +1633,7 @@ impl WgpuRenderer { } if overflow { - drop(encoder); + self.resources().queue.submit([encoder.finish()]); if self.instance_buffer_capacity >= self.max_buffer_size { log::error!( "instance buffer size grew too large: {}", @@ -2394,6 +2402,17 @@ impl WgpuRenderer { self.device_lost.load(std::sync::atomic::Ordering::SeqCst) } + /// Destroy the shared device and explicitly request recovery for diagnostics. + /// Ordinary device destruction intentionally does not set the lost callback's flag. + pub fn request_device_reset(&self) { + if !self + .device_lost + .swap(true, std::sync::atomic::Ordering::SeqCst) + { + self.resources().device.destroy(); + } + } + /// Returns true if a redraw is needed because GPU state was cleared. /// Calling this method clears the flag. pub fn needs_redraw(&mut self) -> bool { @@ -2435,7 +2454,9 @@ impl WgpuRenderer { *gpu_context.borrow_mut() = None; // Wait briefly for the GPU driver to stabilize, then try to - // recreate the context without software renderers. If this fails + // recreate the context. A software session must be able to recover on + // its existing adapter class; a hardware session must not silently + // fall back to software. If this fails // the caller should request another frame and retry — the real GPU // may need more time to come back (e.g. after suspend/resume). std::thread::sleep(std::time::Duration::from_millis(350)); @@ -2443,8 +2464,11 @@ impl WgpuRenderer { let instance = WgpuContext::instance(Box::new(window.clone())); let surface = create_surface(&instance, display_handle.as_raw(), window_handle.as_raw())?; - let new_context = - WgpuContext::new_rejecting_software(instance, &surface, self.compositor_gpu)?; + let new_context = if self.adapter_info.device_type == wgpu::DeviceType::Cpu { + WgpuContext::new(instance, &surface, self.compositor_gpu)? + } else { + WgpuContext::new_rejecting_software(instance, &surface, self.compositor_gpu)? + }; *gpu_context.borrow_mut() = Some(new_context); surface } else { diff --git a/crates/moon-gpui-wgpu/src/wgpu_renderer/tests.rs b/crates/moon-gpui-wgpu/src/wgpu_renderer/tests.rs new file mode 100644 index 0000000..d386469 --- /dev/null +++ b/crates/moon-gpui-wgpu/src/wgpu_renderer/tests.rs @@ -0,0 +1,182 @@ +//! Real-surface regression for a retained canvas bake followed by a scene-buffer retry. + +mod native_window; + +use super::{GpuContext, WgpuRenderer, WgpuSurfaceConfig}; +use gpui::{ + Bounds, ContentMask, DevicePixels, GpuCanvasDrawContext, GpuCanvasDriver, GpuCanvasHandle, + GpuCanvasLayer, GpuCanvasPrepareContext, GpuFrameDecision, GpuFrameInfo, PaintGpuCanvas, Quad, + RawGpuAccess, ScaledPixels, Scene, Underline, point, size, +}; +use std::{cell::RefCell, rc::Rc, sync::Arc, time::Duration}; + +/// A chart-like owner that marks a retained bake clean as soon as it records the GPU commands. +struct RetainedBake { + texture: wgpu::Texture, + dirty: bool, +} + +impl GpuCanvasDriver for RetainedBake { + fn frame(&mut self, _info: GpuFrameInfo) -> GpuFrameDecision { + GpuFrameDecision::Skip + } + + fn prepare_gpu(&mut self, ctx: &mut GpuCanvasPrepareContext<'_>) -> anyhow::Result<()> { + if self.dirty { + let RawGpuAccess::Wgpu(raw) = ctx.gpu else { + unreachable!() + }; + // The raw API lends the renderer's current encoder for this synchronous callback. + let encoder = + unsafe { &mut *raw.command_encoder.as_ptr().cast::() }; + let view = self.texture.create_view(&Default::default()); + let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("retained bake before overflow"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color { + r: 1.0, + g: 0.25, + b: 0.0, + a: 1.0, + }), + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + })], + ..Default::default() + }); + self.dirty = false; + } + Ok(()) + } + + fn draw(&mut self, _ctx: &mut GpuCanvasDrawContext<'_>) -> anyhow::Result<()> { + Ok(()) + } +} + +/// Read the completed retained texture, rather than treating a CPU clean flag as a successful bake. +fn read_pixel(device: &wgpu::Device, queue: &wgpu::Queue, texture: &wgpu::Texture) -> [u8; 4] { + let readback = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("retry pixel readback"), + size: 256, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + let mut encoder = device.create_command_encoder(&Default::default()); + encoder.copy_texture_to_buffer( + texture.as_image_copy(), + wgpu::TexelCopyBufferInfo { + buffer: &readback, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(256), + rows_per_image: None, + }, + }, + wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + ); + queue.submit([encoder.finish()]); + let (sent, received) = std::sync::mpsc::channel(); + readback + .slice(..) + .map_async(wgpu::MapMode::Read, move |result| { + sent.send(result).unwrap(); + }); + device + .poll(wgpu::PollType::Wait { + submission_index: None, + timeout: Some(Duration::from_secs(10)), + }) + .unwrap(); + received + .recv_timeout(Duration::from_secs(10)) + .unwrap() + .unwrap(); + let mapped = readback.slice(..).get_mapped_range(); + mapped[..4].try_into().unwrap() +} + +/// Dropping the failed scene encoder loses the only bake, even though the larger retry succeeds. +#[test] +#[ignore = "requires a native GPU surface and DISPLAY on Linux; run explicitly with --ignored"] +fn scene_overflow_preserves_the_already_prepared_canvas_texture() { + let window = native_window::TestWindow::new(); + let context: GpuContext = Rc::new(RefCell::new(None)); + let mut renderer = WgpuRenderer::new( + context, + &window, + WgpuSurfaceConfig { + size: size(DevicePixels(64), DevicePixels(64)), + transparent: false, + preferred_present_mode: None, + }, + None, + ) + .unwrap(); + let device = Arc::clone(&renderer.resources().device); + let queue = Arc::clone(&renderer.resources().queue); + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("retained chart bake"), + size: wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let bounds = Bounds::new( + point(ScaledPixels(0.0), ScaledPixels(0.0)), + size(ScaledPixels(64.0), ScaledPixels(64.0)), + ); + let mut scene = Scene::default(); + scene.insert_gpu_canvas( + GpuCanvasLayer::UnderScene, + PaintGpuCanvas { + order: Default::default(), + bounds, + content_mask: ContentMask { bounds }, + text_layer: GpuCanvasLayer::UnderScene, + driver: GpuCanvasHandle::new(RetainedBake { + texture: texture.clone(), + dirty: true, + }), + }, + ); + scene.insert_primitive(Quad { + bounds, + content_mask: ContentMask { bounds }, + ..Default::default() + }); + scene.insert_primitive(Underline { + order: Default::default(), + pad: 0, + bounds, + content_mask: ContentMask { bounds }, + color: gpui::black(), + thickness: ScaledPixels(1.0), + wavy: 0, + }); + scene.finish(); + // The first batch uses the old buffer; the second overflows. Its lifetime must survive grow. + let initial_capacity = std::mem::size_of::() as u64; + renderer.instance_buffer_capacity = initial_capacity; + assert!(renderer.draw(&scene)); + assert!( + renderer.instance_buffer_capacity > initial_capacity, + "the scene must actually retry" + ); + assert_eq!(read_pixel(&device, &queue, &texture), [255, 64, 0, 255]); +} diff --git a/crates/moon-gpui-wgpu/src/wgpu_renderer/tests/native_window.rs b/crates/moon-gpui-wgpu/src/wgpu_renderer/tests/native_window.rs new file mode 100644 index 0000000..191a0ba --- /dev/null +++ b/crates/moon-gpui-wgpu/src/wgpu_renderer/tests/native_window.rs @@ -0,0 +1,182 @@ +//! Minimal native surface owner for renderer tests, without a GPUI app or an added dependency. + +#[cfg(windows)] +mod platform { + use raw_window_handle::{ + DisplayHandle, HandleError, HasDisplayHandle, HasWindowHandle, RawDisplayHandle, + RawWindowHandle, Win32WindowHandle, WindowHandle, WindowsDisplayHandle, + }; + use std::{ffi::c_void, num::NonZeroIsize, sync::Arc}; + + #[link(name = "user32")] + unsafe extern "system" { + fn CreateWindowExW( + ex_style: u32, + class: *const u16, + name: *const u16, + style: u32, + x: i32, + y: i32, + width: i32, + height: i32, + parent: *mut c_void, + menu: *mut c_void, + instance: *mut c_void, + param: *mut c_void, + ) -> *mut c_void; + fn DestroyWindow(window: *mut c_void) -> i32; + } + + /// Keep the HWND alive through all clones retained by the GPU instance. + #[derive(Clone, Debug)] + pub struct TestWindow(Arc); + + #[derive(Debug)] + struct Owner(usize); + + impl TestWindow { + pub fn new() -> Self { + let class: Vec<_> = "STATIC\0".encode_utf16().collect(); + // A visible non-activating tool window also works on drivers that reject hidden surfaces. + let window = unsafe { + CreateWindowExW( + 0x08000080, + class.as_ptr(), + class.as_ptr(), + 0x90000000, + 0, + 0, + 64, + 64, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }; + assert!(!window.is_null(), "create renderer test window"); + Self(Arc::new(Owner(window as usize))) + } + } + + impl Drop for Owner { + fn drop(&mut self) { + unsafe { + DestroyWindow(self.0 as *mut c_void); + } + } + } + + impl HasWindowHandle for TestWindow { + fn window_handle(&self) -> Result, HandleError> { + let handle = Win32WindowHandle::new(NonZeroIsize::new(self.0.0 as isize).unwrap()); + Ok(unsafe { WindowHandle::borrow_raw(RawWindowHandle::Win32(handle)) }) + } + } + + impl HasDisplayHandle for TestWindow { + fn display_handle(&self) -> Result, HandleError> { + Ok(unsafe { + DisplayHandle::borrow_raw(RawDisplayHandle::Windows(WindowsDisplayHandle::new())) + }) + } + } +} + +#[cfg(target_os = "linux")] +mod platform { + use raw_window_handle::{ + DisplayHandle, HandleError, HasDisplayHandle, HasWindowHandle, RawDisplayHandle, + RawWindowHandle, WindowHandle, XlibDisplayHandle, XlibWindowHandle, + }; + use std::{ + ffi::{c_char, c_int, c_ulong, c_void}, + ptr::NonNull, + sync::Arc, + }; + + #[link(name = "X11")] + unsafe extern "C" { + fn XOpenDisplay(name: *const c_char) -> *mut c_void; + fn XDefaultScreen(display: *mut c_void) -> c_int; + fn XRootWindow(display: *mut c_void, screen: c_int) -> c_ulong; + fn XCreateSimpleWindow( + display: *mut c_void, + parent: c_ulong, + x: c_int, + y: c_int, + width: u32, + height: u32, + border_width: u32, + border: c_ulong, + background: c_ulong, + ) -> c_ulong; + fn XMapWindow(display: *mut c_void, window: c_ulong) -> c_int; + fn XFlush(display: *mut c_void) -> c_int; + fn XDestroyWindow(display: *mut c_void, window: c_ulong) -> c_int; + fn XCloseDisplay(display: *mut c_void) -> c_int; + } + + /// Keep the Xlib connection and window alive through the GPU instance's window clone. + #[derive(Clone, Debug)] + pub struct TestWindow(Arc); + + #[derive(Debug)] + struct Owner { + display: usize, + screen: i32, + window: c_ulong, + } + + impl TestWindow { + pub fn new() -> Self { + let display = unsafe { XOpenDisplay(std::ptr::null()) }; + assert!( + !display.is_null(), + "renderer test requires DISPLAY (xvfb-run is supported)" + ); + let screen = unsafe { XDefaultScreen(display) }; + let window = unsafe { + XCreateSimpleWindow(display, XRootWindow(display, screen), 0, 0, 64, 64, 0, 0, 0) + }; + unsafe { + XMapWindow(display, window); + XFlush(display); + } + Self(Arc::new(Owner { + display: display as usize, + screen, + window, + })) + } + } + + impl Drop for Owner { + fn drop(&mut self) { + unsafe { + XDestroyWindow(self.display as *mut c_void, self.window); + XCloseDisplay(self.display as *mut c_void); + } + } + } + + impl HasWindowHandle for TestWindow { + fn window_handle(&self) -> Result, HandleError> { + Ok(unsafe { + WindowHandle::borrow_raw(RawWindowHandle::Xlib(XlibWindowHandle::new( + self.0.window, + ))) + }) + } + } + + impl HasDisplayHandle for TestWindow { + fn display_handle(&self) -> Result, HandleError> { + let handle = + XlibDisplayHandle::new(NonNull::new(self.0.display as *mut c_void), self.0.screen); + Ok(unsafe { DisplayHandle::borrow_raw(RawDisplayHandle::Xlib(handle)) }) + } + } +} + +pub use platform::TestWindow; diff --git a/crates/moon-gpui-windows/src/directx_renderer.rs b/crates/moon-gpui-windows/src/directx_renderer.rs index 883f718..00284f2 100644 --- a/crates/moon-gpui-windows/src/directx_renderer.rs +++ b/crates/moon-gpui-windows/src/directx_renderer.rs @@ -544,7 +544,8 @@ impl DirectXRenderer { } #[inline] - fn present(&mut self) -> Result<()> { + /// Report occlusion separately from a present accepted by DXGI. + fn present(&mut self) -> Result { let result = unsafe { self.resources .as_ref() @@ -554,12 +555,12 @@ impl DirectXRenderer { }; if result == DXGI_STATUS_OCCLUDED { self.presentable = false; - return Ok(()); + return Ok(false); } result.ok().context("Presenting swap chain failed")?; self.presentable = true; - Ok(()) + Ok(true) } pub(crate) fn handle_device_lost(&mut self, directx_devices: &DirectXDevices) -> Result<()> { @@ -640,19 +641,20 @@ impl DirectXRenderer { Ok(()) } + /// Draw only while presentable and return whether DXGI accepted the resulting frame. pub(crate) fn draw( &mut self, scene: &Scene, background_appearance: WindowBackgroundAppearance, clear_color: Option, - ) -> Result<()> { + ) -> Result { if self.skip_draws { // skip drawing this frame, we just recovered from a device lost event // and so likely do not have the textures anymore that are required for drawing - return Ok(()); + return Ok(false); } if !self.can_present() { - return Ok(()); + return Ok(false); } self.run_gpu_canvas_prepare(&scene.gpu_canvases_under_scene, GpuCanvasLayer::UnderScene); self.run_gpu_canvas_prepare(&scene.gpu_canvases_over_scene, GpuCanvasLayer::OverScene); diff --git a/crates/moon-gpui-windows/src/window.rs b/crates/moon-gpui-windows/src/window.rs index 7231528..14be8df 100644 --- a/crates/moon-gpui-windows/src/window.rs +++ b/crates/moon-gpui-windows/src/window.rs @@ -1007,7 +1007,7 @@ impl PlatformWindow for WindowsWindow { self.state.renderer.borrow_mut().can_present() } - fn draw(&self, scene: &Scene) { + fn draw(&self, scene: &Scene) -> bool { self.state .renderer .borrow_mut() @@ -1016,7 +1016,8 @@ impl PlatformWindow for WindowsWindow { self.state.background_appearance.get(), self.state.clear_color.get(), ) - .log_err(); + .log_err() + .unwrap_or(false) } fn sprite_atlas(&self) -> Arc { @@ -1031,6 +1032,14 @@ impl PlatformWindow for WindowsWindow { self.state.renderer.borrow().gpu_specs().log_err() } + /// Let the existing VSync owner recreate and distribute the shared DirectX devices. + fn request_gpu_device_reset(&self) -> bool { + self.state + .invalidate_devices + .store(true, std::sync::atomic::Ordering::Release); + true + } + fn update_ime_position(&self, bounds: Bounds) { let scale_factor = self.state.scale_factor.get(); let caret_position = POINT { diff --git a/crates/moon-gpui/src/app.rs b/crates/moon-gpui/src/app.rs index 57bdbdd..a846b07 100644 --- a/crates/moon-gpui/src/app.rs +++ b/crates/moon-gpui/src/app.rs @@ -683,6 +683,8 @@ pub struct App { #[cfg(any(test, feature = "test-support", debug_assertions))] pub(crate) name: Option<&'static str>, pub(crate) text_rendering_mode: Rc>, + /// Opt-in actual view renders, kept per App so independent applications/tests do not mix. + pub(crate) view_render_counts: Option>, pub(crate) window_update_stack: Vec, pub(crate) mode: GpuiMode, @@ -729,6 +731,7 @@ impl App { platform: platform.clone(), text_system, text_rendering_mode: Rc::new(Cell::new(TextRenderingMode::default())), + view_render_counts: None, mode: GpuiMode::Production, actions: Rc::new(ActionRegistry::default()), flushing_effects: false, diff --git a/crates/moon-gpui/src/app/test_context.rs b/crates/moon-gpui/src/app/test_context.rs index 9e32c5d..cd03ccd 100644 --- a/crates/moon-gpui/src/app/test_context.rs +++ b/crates/moon-gpui/src/app/test_context.rs @@ -909,6 +909,12 @@ impl VisualTestContext { self.background_executor.run_until_parked(); } + /// Run one native frame callback, including retained GPU canvas text preparation. + pub fn simulate_frame(&mut self) { + self.test_window(self.window).simulate_frame(); + self.run_until_parked(); + } + /// Simulates the user closing the window. /// Returns true if the window was closed. pub fn simulate_close(&mut self) -> bool { diff --git a/crates/moon-gpui/src/gpu_canvas.rs b/crates/moon-gpui/src/gpu_canvas.rs index e0c4607..eea2a67 100644 --- a/crates/moon-gpui/src/gpu_canvas.rs +++ b/crates/moon-gpui/src/gpu_canvas.rs @@ -1136,6 +1136,8 @@ pub struct MetalRawAccess<'a> { } /// Opaque borrowed wgpu handles for a canvas callback. +/// These point to Rust objects, not a stable C ABI: a consumer casting them must use the +/// exact same Cargo wgpu package (source and version) as the runtime producer. #[derive(Clone, Copy, Debug)] pub struct WgpuRawAccess<'a> { /// Monotonic generation bumped when GPUI recreates wgpu resources after device loss. diff --git a/crates/moon-gpui/src/platform.rs b/crates/moon-gpui/src/platform.rs index 828bfdc..110fec2 100644 --- a/crates/moon-gpui/src/platform.rs +++ b/crates/moon-gpui/src/platform.rs @@ -703,7 +703,9 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle { fn frame_diagnostics(&self) -> Option { None } - fn draw(&self, scene: &Scene); + /// Submit the scene and return whether the backend accepted its presentation. + /// A false result leaves the frame owed until a later native tick can accept it. + fn draw(&self, scene: &Scene) -> bool; fn completed_frame(&self) {} fn sprite_atlas(&self) -> Arc; fn is_subpixel_rendering_supported(&self) -> bool; @@ -758,6 +760,12 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle { fn set_client_inset(&self, _inset: Pixels) {} fn gpu_specs(&self) -> Option; + /// Request the normal device-recovery path for diagnostics, without resetting the OS GPU. + /// Returns false when the platform has no such recovery path. + fn request_gpu_device_reset(&self) -> bool { + false + } + fn update_ime_position(&self, _bounds: Bounds); fn play_system_bell(&self) {} diff --git a/crates/moon-gpui/src/platform/test/window.rs b/crates/moon-gpui/src/platform/test/window.rs index e2c0986..912c616 100644 --- a/crates/moon-gpui/src/platform/test/window.rs +++ b/crates/moon-gpui/src/platform/test/window.rs @@ -1,6 +1,6 @@ use crate::{ AnyWindowHandle, AtlasKey, AtlasTextureId, AtlasTile, Bounds, DevicePixels, - DispatchEventResult, GpuSpecs, Pixels, PlatformAtlas, PlatformDisplay, + DispatchEventResult, GpuSpecs, Pixels, PlatformAtlas, PlatformDisplay, PlatformFrameRequester, PlatformHeadlessRenderer, PlatformInput, PlatformInputHandler, PlatformWindow, Point, PromptButton, RequestFrameOptions, Rgba, Scene, Size, TestPlatform, TileId, WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowParams, @@ -12,7 +12,10 @@ use parking_lot::Mutex; use raw_window_handle::{HasDisplayHandle, HasWindowHandle}; use std::{ rc::{Rc, Weak}, - sync::{self, Arc}, + sync::{ + self, Arc, + atomic::{AtomicUsize, Ordering}, + }, }; pub(crate) struct TestWindowState { @@ -35,6 +38,21 @@ pub(crate) struct TestWindowState { moved_callback: Option>, input_handler: Option, is_fullscreen: bool, + request_frame_callback: Option>, + pub(crate) frame_requests: Arc, + pub(crate) deferred_presents: usize, + pub(crate) draw_attempts: usize, + pub(crate) presentable: bool, +} + +/// Count requests without scheduling a timer; tests explicitly deliver the next platform tick. +#[derive(Default)] +pub(crate) struct TestFrameRequests(pub(crate) AtomicUsize); + +impl PlatformFrameRequester for TestFrameRequests { + fn request_frame(&self) { + self.0.fetch_add(1, Ordering::SeqCst); + } } #[derive(Clone)] @@ -87,9 +105,24 @@ impl TestWindow { moved_callback: None, input_handler: None, is_fullscreen: false, + request_frame_callback: None, + frame_requests: Arc::new(TestFrameRequests::default()), + deferred_presents: 0, + draw_attempts: 0, + presentable: true, }))) } + /// Deliver the actual frame callback after releasing the platform-state lock. + #[cfg(any(test, feature = "test-support"))] + pub(crate) fn simulate_frame(&self) { + let callback = self.0.lock().request_frame_callback.take(); + if let Some(mut callback) = callback { + callback(RequestFrameOptions::default()); + self.0.lock().request_frame_callback = Some(callback); + } + } + pub fn simulate_resize(&mut self, size: Size) { let scale_factor = self.scale_factor(); let mut lock = self.0.lock(); @@ -260,7 +293,17 @@ impl PlatformWindow for TestWindow { self.0.lock().is_fullscreen } - fn on_request_frame(&self, _callback: Box) {} + fn on_request_frame(&self, callback: Box) { + self.0.lock().request_frame_callback = Some(callback); + } + + fn can_present(&self) -> bool { + self.0.lock().presentable + } + + fn frame_requester(&self) -> Option> { + Some(self.0.lock().frame_requests.clone()) + } fn on_input(&self, callback: Box DispatchEventResult>) { self.0.lock().input_callback = Some(callback) @@ -294,13 +337,24 @@ impl PlatformWindow for TestWindow { fn on_appearance_changed(&self, _callback: Box) {} - fn draw(&self, scene: &Scene) { + fn draw(&self, scene: &Scene) -> bool { let scale_factor = self.scale_factor(); let mut state = self.0.lock(); - let device_size: Size = state.bounds.size.to_device_pixels(scale_factor); - if let Some(renderer) = &mut state.renderer { - renderer.render_scene(scene, device_size).warn_on_err(); + state.draw_attempts += 1; + if !state.presentable { + return false; + } + if state.deferred_presents > 0 { + state.deferred_presents -= 1; + return false; } + let device_size: Size = state.bounds.size.to_device_pixels(scale_factor); + state.renderer.as_mut().is_none_or(|renderer| { + renderer + .render_scene(scene, device_size) + .warn_on_err() + .is_some() + }) } fn sprite_atlas(&self) -> sync::Arc { diff --git a/crates/moon-gpui/src/view.rs b/crates/moon-gpui/src/view.rs index 55db7d8..5b3054d 100644 --- a/crates/moon-gpui/src/view.rs +++ b/crates/moon-gpui/src/view.rs @@ -11,6 +11,30 @@ use std::mem; use std::rc::Rc; use std::{any::TypeId, fmt, ops::Range}; +#[cfg(test)] +mod tests; + +impl App { + /// Collect actual `Render::render` calls by concrete view type. Disabled by default; + /// the disabled path performs no clock read, allocation, hashing, or synchronization. + /// Disabling clears the interval so re-enabling cannot report stale work. + pub fn set_view_render_tracking(&mut self, enabled: bool) { + if enabled { + self.view_render_counts.get_or_insert_default(); + } else { + self.view_render_counts = None; + } + } + + /// Drain the current render-count interval, retaining capacity for the next interval. + /// Cached scene reuse and notifications that never render are not counted. + pub fn take_view_render_counts(&mut self) -> Vec<(&'static str, u64)> { + self.view_render_counts + .as_mut() + .map_or_else(Vec::new, |counts| counts.drain().collect()) + } +} + struct AnyViewState { prepaint_range: Range, paint_range: Range, @@ -308,12 +332,16 @@ impl std::fmt::Debug for AnyWeakView { mod any_view { use crate::{AnyElement, AnyView, App, IntoElement, Render, Window}; + /// Invoke the concrete view at the common dispatch point, excluding cached scene reuse. pub(crate) fn render( view: &AnyView, window: &mut Window, cx: &mut App, ) -> AnyElement { let view = view.clone().downcast::().unwrap(); + if let Some(counts) = cx.view_render_counts.as_mut() { + *counts.entry(std::any::type_name::()).or_default() += 1; + } view.update(cx, |view, cx| view.render(window, cx).into_any_element()) } } diff --git a/crates/moon-gpui/src/view/tests.rs b/crates/moon-gpui/src/view/tests.rs new file mode 100644 index 0000000..a884eaf --- /dev/null +++ b/crates/moon-gpui/src/view/tests.rs @@ -0,0 +1,92 @@ +//! Actual-render accounting must exclude cache reuse, idle notifications, and disabled periods. + +use std::any::type_name; + +use crate::{ + AnyView, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, + StyleRefinement, TestAppContext, Window, div, +}; + +/// An uninstrumented application view: the runtime must discover its concrete type itself. +struct CountedView; + +impl Render for CountedView { + /// Return an ordinary element without any diagnostic call site. + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div().child("rendered") + } +} + +/// Keep a child cached while the uncached parent still builds on each draw. +struct CachedChild { + child: Entity, +} + +impl Render for CachedChild { + /// Return the normal AnyView cache path, without custom instrumentation. + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div().child(AnyView::from(self.child.clone()).cached(StyleRefinement::default())) + } +} + +/// A counter in request_layout/prepaint would incorrectly count cached reuse as a render. +#[test] +fn cached_reuse_does_not_count_as_rendering_the_child() { + let mut app = TestAppContext::single(); + let window = app.add_window(|_, cx| CachedChild { + child: cx.new(|_| CountedView), + }); + app.update_window(window.into(), |_, window, cx| { + cx.set_view_render_tracking(true); + window.refresh(); + window.draw(cx).clear(); + window.draw(cx).clear(); + let counts: std::collections::BTreeMap<_, _> = + cx.take_view_render_counts().into_iter().collect(); + assert_eq!(counts.get(type_name::()), Some(&2)); + assert_eq!(counts.get(type_name::()), Some(&1)); + }) + .unwrap(); +} + +/// Removing common dispatch accounting, counting notify instead, retaining old samples, or +/// leaving tracking active after disable must break this full view-render path. +#[test] +fn tracking_counts_actual_renders_and_clears_disabled_intervals() { + let mut app = TestAppContext::single(); + let window = app.add_window(|_, _| CountedView); + app.update_window(window.into(), |_, window, cx| { + window.draw(cx).clear(); + assert!( + cx.take_view_render_counts().is_empty(), + "tracking defaults off" + ); + cx.set_view_render_tracking(true); + window.draw(cx).clear(); + window.draw(cx).clear(); + assert_eq!( + cx.take_view_render_counts(), + vec![(type_name::(), 2)] + ); + assert!( + cx.take_view_render_counts().is_empty(), + "sampling drains the interval" + ); + window.draw(cx).clear(); + cx.set_view_render_tracking(true); + assert_eq!( + cx.take_view_render_counts(), + vec![(type_name::(), 1)], + "reapplying the gate preserves the current interval" + ); + window.draw(cx).clear(); + cx.set_view_render_tracking(false); + window.draw(cx).clear(); + cx.set_view_render_tracking(true); + assert!( + cx.take_view_render_counts().is_empty(), + "disabled work cannot leak into a new interval" + ); + }) + .unwrap(); +} diff --git a/crates/moon-gpui/src/window.rs b/crates/moon-gpui/src/window.rs index 4a0d5d0..9647ba8 100644 --- a/crates/moon-gpui/src/window.rs +++ b/crates/moon-gpui/src/window.rs @@ -180,28 +180,29 @@ impl WindowInvalidator { } } + /// Retain frame debt and notify observers even when the changed entity is a layout model. pub fn invalidate_view(&self, entity: EntityId, cx: &mut App) -> bool { let mut inner = self.inner.borrow_mut(); inner.update_count += 1; inner.dirty_views.insert(entity); + // A model has no dispatch-tree view path; its observers must invalidate their views. + // Effects are flushed after the current update, so this does not re-enter the draw. + cx.push_effect(Effect::Notify { emitter: entity }); if inner.draw_phase == DrawPhase::None { Self::record_frame_dirty(&mut inner); inner.dirty = true; Self::request_frame_inner(&inner); - cx.push_effect(Effect::Notify { emitter: entity }); true } else { false } } + /// Whether the UI owes a draw, including views changed during the preceding draw. + /// Frame dispatch, keyboard dispatch and test flushing must consume the same debt. pub fn is_dirty(&self) -> bool { - self.inner.borrow().dirty - } - - /// Views invalidated while a draw was in progress; they still owe a frame. - pub fn has_dirty_views(&self) -> bool { - !self.inner.borrow().dirty_views.is_empty() + let inner = self.inner.borrow(); + inner.dirty || !inner.dirty_views.is_empty() } pub fn set_dirty(&self, dirty: bool) { @@ -1231,11 +1232,15 @@ mod input_modality_tests { } } +/// Shared close policy used by both native and client-drawn window controls. +type WindowCloseCallback = Rc bool>; + /// Holds the state for a specific window. pub struct Window { pub(crate) handle: AnyWindowHandle, pub(crate) invalidator: WindowInvalidator, pub(crate) removed: bool, + should_close: RefCell>, pub(crate) platform_window: Box, display_id: Option, sprite_atlas: Arc, @@ -1727,7 +1732,6 @@ impl Window { // refused mid-draw, a callback queued for the next frame, a present // still owed) must wake the frame clock again. if window.invalidator.is_dirty() - || window.invalidator.has_dirty_views() || !window.next_frame_callbacks.borrow().is_empty() || window.needs_present.get() { @@ -1888,6 +1892,7 @@ impl Window { handle, invalidator, removed: false, + should_close: RefCell::new(None), platform_window, display_id, sprite_atlas, @@ -2078,11 +2083,24 @@ impl Window { } } - /// Close this window. + /// Close this window unconditionally, including during owner teardown. pub fn remove_window(&mut self) { self.removed = true; } + /// Request a user close, respecting the same veto as the native window controls. + pub fn request_close(&mut self, cx: &mut App) { + if self.can_close(cx) { + self.remove_window(); + } + } + + /// Release the callback borrow before invoking user code, which may replace the policy. + fn can_close(&mut self, cx: &mut App) -> bool { + let callback = self.should_close.borrow().clone(); + callback.is_none_or(|callback| callback(self, cx)) + } + /// Whether the focused element is taking typed text on the frame that was drawn last. /// /// An application binding a bare key — Caps Lock, a lone modifier, a single letter — has to @@ -2992,7 +3010,7 @@ impl Window { // `force_present` is true on every UI redraw, so the scene's text is rebuilt from // scratch then; reuse only happens on GPU-only frames, for canvases that did not // ask to present and whose environment is unchanged. - if gpu_canvas_prepare_text(force_present, request_present) { + if presentable && gpu_canvas_prepare_text(force_present, request_present) { let mut seen = FxHashSet::default(); let mut occurrences: FxHashMap<(usize, bool), usize> = FxHashMap::default(); for (layer, canvas, bounds, wants_present) in canvases { @@ -3244,11 +3262,14 @@ impl Window { } #[profiling::function] + /// Keep the frame owed until the native backend accepts its presentation. fn present(&mut self) { - self.platform_window.draw(&self.rendered_frame.scene); + let accepted = self.platform_window.draw(&self.rendered_frame.scene); #[cfg(feature = "input-latency-histogram")] - self.input_latency_tracker.record_frame_presented(); - self.needs_present.set(false); + if accepted { + self.input_latency_tracker.record_frame_presented(); + } + self.needs_present.set(!accepted); profiling::finish_frame!(); } @@ -5979,9 +6000,10 @@ impl Window { cx: &App, f: impl Fn(&mut Window, &mut App) -> bool + 'static, ) { + *self.should_close.borrow_mut() = Some(Rc::new(f)); let mut cx = self.to_async(cx); self.platform_window.on_should_close(Box::new(move || { - cx.update(|window, cx| f(window, cx)).unwrap_or(true) + cx.update(|window, cx| window.can_close(cx)).unwrap_or(true) })) } @@ -6034,6 +6056,17 @@ impl Window { self.platform_window.gpu_specs() } + /// Exercise native device recovery and wake every window sharing the device. + /// This diagnostic request does not simulate a driver hang or an operating-system reset. + pub fn request_gpu_device_reset(&mut self, cx: &mut App) -> bool { + if !self.platform_window.request_gpu_device_reset() { + return false; + } + self.refresh(); + cx.refresh_windows(); + true + } + /// Perform titlebar double-click action. /// This is macOS specific. pub fn titlebar_double_click(&self) { diff --git a/crates/moon-gpui/src/window/tests.rs b/crates/moon-gpui/src/window/tests.rs index bd9a328..0767ae3 100644 --- a/crates/moon-gpui/src/window/tests.rs +++ b/crates/moon-gpui/src/window/tests.rs @@ -21,6 +21,171 @@ use crate::{ TestAppContext, TouchPhase, UTF16Selection, Window, div, gpu_canvas, point, px, size, }; +/// A view that invalidates once from prepaint or paint after its first stable frame. +struct MidDrawNotification { + armed: Rc>, + renders: Rc>, + during_paint: bool, +} + +impl Render for MidDrawNotification { + /// Register the one-shot mutation in the selected draw phase. + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + self.renders.set(self.renders.get() + 1); + let entity = cx.entity_id(); + let prepaint = self.armed.clone(); + let paint = self.armed.clone(); + let during_paint = self.during_paint; + crate::canvas( + move |_, _, cx| { + if !during_paint && prepaint.replace(false) { + cx.notify(entity); + } + }, + move |_, _, _, cx| { + if during_paint && paint.replace(false) { + cx.notify(entity); + } + }, + ) + .size(px(40.)) + } +} + +/// Catches ignoring the queued views in the frame-entry predicate: a measured layout change +/// would remain invisible until unrelated input, even though the frame clock was woken for it. +#[test] +fn mid_draw_notification_is_drawn_without_another_event() { + for during_paint in [false, true] { + let armed = Rc::new(Cell::new(false)); + let renders = Rc::new(Cell::new(0)); + let (mut app, any) = open_window({ + let armed = armed.clone(); + let renders = renders.clone(); + move |_, _| MidDrawNotification { + armed, + renders, + during_paint, + } + }); + app.update_window(any, |_, window, cx| { + let before = renders.get(); + armed.set(true); + window.draw(cx).clear(); + assert!( + window.invalidator.is_dirty(), + "a mid-draw change still owes a frame" + ); + // The platform's next tick must take the UI branch without another input event. + if window.invalidator.is_dirty() { + window.draw(cx).clear(); + } + assert_eq!( + renders.get() - before, + 2, + "one applying frame follows the mutation" + ); + assert!( + !window.invalidator.is_dirty(), + "the consumed debt must settle" + ); + }) + .unwrap(); + } +} + +/// A cached consumer observes a separate model, as table hosts observe their layout state. +struct MidDrawModelConsumer { + model: crate::Entity, + armed: Rc>, + seen: Rc>, + during_paint: bool, +} + +impl Render for MidDrawModelConsumer { + /// Publish one layout measurement while recording the model value actually rendered. + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + self.seen.set(*self.model.read(cx)); + let prepaint_model = self.model.clone(); + let paint_model = self.model.clone(); + let prepaint = self.armed.clone(); + let paint = self.armed.clone(); + let during_paint = self.during_paint; + crate::canvas( + move |_, _, cx| { + if !during_paint && prepaint.replace(false) { + prepaint_model.update(cx, |value, cx| { + *value += 1; + cx.notify(); + }); + } + }, + move |_, _, _, cx| { + if during_paint && paint.replace(false) { + paint_model.update(cx, |value, cx| { + *value += 1; + cx.notify(); + }); + } + }, + ) + .size(px(40.)) + } +} + +/// The model itself has no view path, so only its observer can invalidate this cached child. +struct CachedModelHost(crate::Entity); + +impl Render for CachedModelHost { + /// Use the production view cache rather than repainting every consumer unconditionally. + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div().child(crate::AnyView::from(self.0.clone()).cached(crate::StyleRefinement::default())) + } +} + +/// Queuing a frame alone cannot deliver a model notification: the observer must also run. +#[test] +fn mid_draw_model_notification_reaches_its_cached_observer() { + for during_paint in [false, true] { + let armed = Rc::new(Cell::new(false)); + let seen = Rc::new(Cell::new(0)); + let (mut app, window) = open_window({ + let armed = armed.clone(); + let seen = seen.clone(); + move |_, cx| { + let model = cx.new(|_| 0_usize); + CachedModelHost(cx.new(|cx| { + cx.observe(&model, |_, _, cx| cx.notify()).detach(); + MidDrawModelConsumer { + model, + armed, + seen, + during_paint, + } + })) + } + }); + app.update_window(window, |_, window, cx| { + armed.set(true); + window.refresh(); + window.draw(cx).clear(); + }) + .unwrap(); + assert_eq!( + seen.get(), + 1, + "the cached consumer must draw the measured value without input" + ); + app.update_window(window, |_, window, _| { + assert!( + !window.invalidator.is_dirty(), + "the observer and frame debt must settle" + ); + }) + .unwrap(); + } +} + /// A root view that draws nothing; the window's own state is what these tests read. struct EmptyView; @@ -41,6 +206,210 @@ fn open_window( (app, any) } +/// Native and client-drawn close use one replaceable policy; a veto keeps the real window alive. +#[test] +fn user_close_respects_and_replaces_the_native_policy() { + let (mut app, any) = open_window(|_, _| EmptyView); + let calls = Rc::new(Cell::new(0)); + app.update_window(any, |_, window, cx| { + let calls = calls.clone(); + window.on_window_should_close(cx, move |_, _| { + calls.set(calls.get() + 1); + false + }); + }) + .unwrap(); + let platform = app.test_window(any); + let mut native = platform.0.lock().should_close_handler.take().unwrap(); + assert!(!native()); + app.update_window(any, |_, window, cx| window.request_close(cx)) + .unwrap(); + assert_eq!(calls.get(), 2); + app.update_window(any, |_, window, cx| { + assert!(!window.removed); + window.on_window_should_close(cx, |_, _| true); + window.request_close(cx); + }) + .unwrap(); + assert!( + app.update_window(any, |_, _, _| ()).is_err(), + "accepted close must release the window" + ); + assert_eq!(calls.get(), 2, "the superseded veto must not run"); +} + +/// Teardown is unconditional; a window without a policy still accepts an ordinary user close. +#[test] +fn forced_close_bypasses_policy_and_default_user_close_succeeds() { + let (mut app, any) = open_window(|_, _| EmptyView); + app.update_window(any, |_, window, cx| { + window.on_window_should_close(cx, |_, _| { + panic!("forced close must not ask the user policy") + }); + window.remove_window(); + }) + .unwrap(); + assert!(app.update_window(any, |_, _, _| ()).is_err()); + let (mut app, any) = open_window(|_, _| EmptyView); + app.update_window(any, |_, window, cx| window.request_close(cx)) + .unwrap(); + assert!(app.update_window(any, |_, _, _| ()).is_err()); +} + +/// A static scene still owes its last frame after a transient backend refusal, without new input. +#[test] +fn deferred_idle_present_retries_then_settles() { + let (mut app, any) = open_window(|_, _| EmptyView); + let platform = app.test_window(any); + platform.0.lock().deferred_presents = 2; + let requests = platform.0.lock().frame_requests.clone(); + for _ in 0..2 { + let before = requests.0.load(std::sync::atomic::Ordering::SeqCst); + platform.simulate_frame(); + assert_eq!( + requests.0.load(std::sync::atomic::Ordering::SeqCst) - before, + 1, + "one next native tick is requested" + ); + app.update_window(any, |_, window, _| { + assert!( + window.needs_present.get(), + "the rejected idle frame remains owed" + ); + assert!( + !window.invalidator.is_dirty(), + "retry must not rebuild the UI" + ); + }) + .unwrap(); + } + platform.simulate_frame(); + app.update_window(any, |_, window, _| assert!(!window.needs_present.get())) + .unwrap(); + assert_eq!(platform.0.lock().draw_attempts, 3); + platform.simulate_frame(); + assert_eq!( + platform.0.lock().draw_attempts, + 3, + "a successful present settles" + ); +} + +/// Request one GPU-only frame, consuming the local request just as a retained chart does. +struct OneShotPresent { + request: Rc>, + text_prepares: Rc>, +} + +impl GpuCanvasDriver for OneShotPresent { + fn frame(&mut self, info: GpuFrameInfo) -> GpuFrameDecision { + if info.presentable && self.request.replace(false) { + GpuFrameDecision::RequestPresent + } else { + GpuFrameDecision::Skip + } + } + + fn prepare_text(&mut self, _: &mut GpuCanvasTextContext<'_>) -> anyhow::Result<()> { + self.text_prepares.set(self.text_prepares.get() + 1); + Ok(()) + } + + fn draw(&mut self, _: &mut GpuCanvasDrawContext<'_>) -> anyhow::Result<()> { + Ok(()) + } +} + +/// Open the production canvas path with a retained request and count actual text preparations. +fn one_shot_present_window() -> ( + TestAppContext, + AnyWindowHandle, + Rc>, + Rc>, +) { + let request = Rc::new(Cell::new(false)); + let prepares = Rc::new(Cell::new(0)); + let handles = vec![GpuCanvasHandle::new(OneShotPresent { + request: request.clone(), + text_prepares: prepares.clone(), + })]; + let (app, any) = open_window(move |_, _| CanvasesView { handles }); + (app, any, request, prepares) +} + +/// A failed GPU-only request must CREATE window debt even when its initial window debt was false. +#[test] +fn deferred_gpu_only_present_creates_and_consumes_window_debt() { + let (mut app, any, request, _) = one_shot_present_window(); + let platform = app.test_window(any); + platform.simulate_frame(); + app.update_window(any, |_, window, _| assert!(!window.needs_present.get())) + .unwrap(); + let initial_attempts = platform.0.lock().draw_attempts; + platform.0.lock().deferred_presents = 2; + request.set(true); + let requests = platform.0.lock().frame_requests.clone(); + for _ in 0..2 { + let before = requests.0.load(std::sync::atomic::Ordering::SeqCst); + platform.simulate_frame(); + assert_eq!( + requests.0.load(std::sync::atomic::Ordering::SeqCst) - before, + 1 + ); + app.update_window(any, |_, window, _| assert!(window.needs_present.get())) + .unwrap(); + } + assert!( + !request.get(), + "the producer already consumed its sole change" + ); + platform.simulate_frame(); + app.update_window(any, |_, window, _| assert!(!window.needs_present.get())) + .unwrap(); + assert_eq!(platform.0.lock().draw_attempts - initial_attempts, 3); + platform.simulate_frame(); + assert_eq!(platform.0.lock().draw_attempts - initial_attempts, 3); +} + +/// An occluded window retains debt but must not repeatedly shape the same pending text. +#[test] +fn deferred_present_skips_text_while_unpresentable_and_finishes_on_restore() { + let (mut app, any, _, prepares) = one_shot_present_window(); + let platform = app.test_window(any); + let before = prepares.get(); + platform.0.lock().presentable = false; + for _ in 0..3 { + platform.simulate_frame(); + } + assert_eq!( + prepares.get(), + before, + "no text preparation while the backend cannot present" + ); + app.update_window(any, |_, window, _| assert!(window.needs_present.get())) + .unwrap(); + platform.0.lock().presentable = true; + platform.simulate_frame(); + assert_eq!(prepares.get(), before + 1); + app.update_window(any, |_, window, _| assert!(!window.needs_present.get())) + .unwrap(); +} + +/// A late native tick after close must not keep requesting work for the removed window. +#[test] +fn deferred_present_does_not_rearm_a_closed_window() { + let (mut app, any) = open_window(|_, _| EmptyView); + let platform = app.test_window(any); + platform.0.lock().deferred_presents = 1; + platform.simulate_frame(); + app.update_window(any, |_, window, _| window.remove_window()) + .unwrap(); + let requests = platform.0.lock().frame_requests.clone(); + let before = requests.0.load(std::sync::atomic::Ordering::SeqCst); + platform.simulate_frame(); + assert_eq!(requests.0.load(std::sync::atomic::Ordering::SeqCst), before); +} + /// Draw one frame of `any`. fn draw(app: &mut TestAppContext, any: AnyWindowHandle) { app.update_window(any, |_, window, cx| { diff --git a/crates/moon-ui-components/component-manifest.json b/crates/moon-ui-components/component-manifest.json index 157759c..9eceefc 100644 --- a/crates/moon-ui-components/component-manifest.json +++ b/crates/moon-ui-components/component-manifest.json @@ -150,7 +150,7 @@ "theme_source": "MoonTheme bridge through MoonCombobox facade", "public_path": "moon_ui::MoonCombobox", "upstream_ref": "Longbridge::combobox@cda0fc7fd4e4809dd2a8bae0337ac43b49e9f675", - "fork_reason": "Reviewed TrackedFork drift: Moon trigger variant/size and menu-look popup hooks live in the base combobox and searchable-list rows, so a combobox can stand in a row of Moon dropdowns; Longbridge combobox behavior remains source-owned", + "fork_reason": "Reviewed TrackedFork drift: Moon trigger variant/size and menu-look popup hooks live in the base combobox and searchable-list rows; dropdown visibility stays local so other inputs can open nested context menus", "donor_drift_budget": 1, "contracts": ["gallery.visual_coverage"] }, @@ -176,12 +176,13 @@ }, { "concept": "calendar", - "class": "Mirror", + "class": "TrackedFork", "behavior_source": "Longbridge Calendar", "theme_source": "MoonTheme bridge through MoonCalendar facade", "public_path": "moon_ui::MoonCalendar", "upstream_ref": "Longbridge::time::calendar@cda0fc7fd4e4809dd2a8bae0337ac43b49e9f675", - "fork_reason": null, + "fork_reason": "Reviewed calendar correctness fork: complete five/six-week month grids preserve every selectable date and month boundaries in multi-month views", + "donor_drift_budget": 1, "contracts": ["gallery.visual_coverage"] }, { @@ -347,7 +348,7 @@ "theme_source": "MoonTheme tokens through theme bridge / Moon wrapper", "public_path": "moon_ui::MoonInput", "upstream_ref": "Longbridge::input@cda0fc7fd4e4809dd2a8bae0337ac43b49e9f675", - "fork_reason": "Reviewed TrackedFork drift: Moon tone/theme hooks plus UTF-8, mask, number, and search hardening live in base input; Longbridge editor engine remains source-owned", + "fork_reason": "Reviewed TrackedFork drift: Moon tone/theme hooks, UTF-8/mask/number/search hardening, convergent paint/scroll/property invalidation, and nested context-menu support live in base input; Longbridge editor engine remains source-owned", "donor_drift_budget": 20, "contracts": ["input.utf8_boundary_clamp", "input.mask_contract", "gallery.visual_coverage"] }, @@ -408,7 +409,7 @@ "theme_source": "MoonTheme bridge through MoonList facade", "public_path": "moon_ui::MoonList", "upstream_ref": "Longbridge::list@cda0fc7fd4e4809dd2a8bae0337ac43b49e9f675", - "fork_reason": "Reviewed TrackedFork drift: Moon list item/separator styling and optional per-section virtual chrome geometry/tests live in base list files; remaining Longbridge list behavior stays source-owned", + "fork_reason": "Reviewed TrackedFork drift: Moon list styling, optional per-section virtual chrome, and idempotent search-mode invalidation live in base list files; remaining Longbridge list behavior stays source-owned", "donor_drift_budget": 6, "contracts": ["gallery.visual_coverage"] }, @@ -419,7 +420,7 @@ "theme_source": "MoonTheme tokens through theme bridge / Moon wrapper", "public_path": "moon_ui::MoonPopover", "upstream_ref": "Longbridge::popover@cda0fc7fd4e4809dd2a8bae0337ac43b49e9f675", - "fork_reason": "Reviewed TrackedFork drift: Moon placement/style hook lives in base popover; Longbridge popover lifecycle remains source-owned", + "fork_reason": "Reviewed TrackedFork drift: Moon placement/style hooks and local visibility ownership allow nested context menus; Longbridge popover lifecycle remains source-owned", "donor_drift_budget": 1, "contracts": [ "popover.open_close_lifecycle", @@ -490,7 +491,7 @@ "theme_source": "MoonTheme tokens through theme bridge / Moon wrapper", "public_path": "moon_ui::MoonSelect", "upstream_ref": "Longbridge::select@cda0fc7fd4e4809dd2a8bae0337ac43b49e9f675", - "fork_reason": "Reviewed TrackedFork drift: Moon trigger variant/style and configurable deferred menu layer live in base select; MoonSelect explicitly opts popover-hosted menus above their parent, while ordinary selects retain layer 1 and Longbridge selection behavior", + "fork_reason": "Reviewed TrackedFork drift: Moon trigger variant/style, configurable deferred menu layer, and local visibility ownership live in base select; MoonSelect places popover-hosted menus above their parent while ordinary selects retain layer 1", "donor_drift_budget": 1, "contracts": ["select.open_select_lifecycle", "gallery.visual_coverage"] }, @@ -532,8 +533,8 @@ "theme_source": "MoonTheme bridge through MoonSidebar facade", "public_path": "moon_ui::MoonSidebar", "upstream_ref": "Longbridge::sidebar@cda0fc7fd4e4809dd2a8bae0337ac43b49e9f675", - "fork_reason": "Reviewed TrackedFork drift: Moon footer/header/menu styling hooks live in base sidebar; contiguous hit rows (group/menu + menu tests) live there too; Longbridge sidebar behavior remains source-owned", - "donor_drift_budget": 5, + "fork_reason": "Reviewed TrackedFork drift: Moon footer/header/menu styling hooks live in base sidebar; contiguous hit rows (group/menu + menu tests) live there too; the faceted skin's unfilled sidebar surface and fading edge are resolved in sidebar/mod.rs; Longbridge sidebar behavior remains source-owned", + "donor_drift_budget": 6, "contracts": ["gallery.visual_coverage"] }, { @@ -618,8 +619,8 @@ "theme_source": "MoonTheme tokens through MoonProgress wrapper", "public_path": "moon_ui::MoonProgress", "upstream_ref": "Longbridge::progress@cda0fc7fd4e4809dd2a8bae0337ac43b49e9f675", - "fork_reason": "Reviewed TrackedFork drift: Moon progress circle style hook shares the base progress module; Longbridge progress behavior remains source-owned", - "donor_drift_budget": 1, + "fork_reason": "Moon progress circle and linear track/fill style hooks share the donor animation engines without duplicating their lifecycle", + "donor_drift_budget": 2, "contracts": ["gallery.visual_coverage"] }, { @@ -639,8 +640,8 @@ "theme_source": "MoonTheme bridge through MoonProgressCircle wrapper", "public_path": "moon_ui::MoonProgressCircle", "upstream_ref": "Longbridge::progress::ProgressCircle@cda0fc7fd4e4809dd2a8bae0337ac43b49e9f675", - "fork_reason": "Reviewed TrackedFork drift: Moon progress circle style hook lives in base progress module; Longbridge progress circle behavior remains source-owned", - "donor_drift_budget": 1, + "fork_reason": "The shared progress module contains circle and linear track/fill style hooks; both keep the donor animation engines", + "donor_drift_budget": 2, "contracts": ["gallery.visual_coverage"] }, { @@ -660,7 +661,7 @@ "theme_source": "MoonTheme bridge through resize handles", "public_path": "moon_ui::MoonResizablePanelGroup", "upstream_ref": "Longbridge::resizable@cda0fc7fd4e4809dd2a8bae0337ac43b49e9f675", - "fork_reason": "Reviewed TrackedFork drift: Moon resize handle/panel style hooks live in base resizable files; Longbridge resize behavior remains source-owned", + "fork_reason": "Reviewed TrackedFork drift: Moon resize handle/panel style hooks and convergent measured-layout invalidation live in base resizable files; Longbridge resize behavior remains source-owned", "donor_drift_budget": 3, "contracts": ["gallery.visual_coverage"] }, diff --git a/crates/moon-ui-components/src/button/button.rs b/crates/moon-ui-components/src/button/button.rs index db831e1..1c9d544 100644 --- a/crates/moon-ui-components/src/button/button.rs +++ b/crates/moon-ui-components/src/button/button.rs @@ -5,16 +5,27 @@ use crate::{ StyledExt, button::ButtonIcon, h_flex, - moon::{MoonPalette, MoonTheme, rgba_from}, + moon::{ + MoonFacet, MoonFacetKind, MoonFacetTint, MoonPalette, MoonTheme, MoonThemeTokens, facet, + facet_glow, facet_tinted, rgba_from, + }, tooltip::{ManagedTooltipExt as _, Tooltip}, }; use gpui::{ AnyElement, App, ClickEvent, Corners, Div, Edges, ElementId, Hsla, InteractiveElement, Interactivity, IntoElement, MouseButton, ParentElement, Pixels, RenderOnce, SharedString, Stateful, StatefulInteractiveElement as _, StyleRefinement, Styled, Window, div, - prelude::FluentBuilder as _, px, relative, + linear_color_stop, linear_gradient, prelude::FluentBuilder as _, px, relative, }; +/// Opacity of the second glow a solid faceted button gains under the pointer. +const SOLID_HOVER_GLOW_ALPHA: f32 = 0.26; +/// Blur of that second glow as the design gives it, in design-reference pixels. +const SOLID_HOVER_GLOW_BLUR: f32 = 18.0; +/// Opacity of a loading button's faceted fill and edge relative to its resting ones; the same +/// share the flat colours of a loading button are faded to. +const LOADING_FACET_OPACITY: f32 = 0.8; + #[derive(Default, Clone, Copy)] pub enum ButtonRounded { None, @@ -38,6 +49,9 @@ pub struct ButtonCustomVariant { shadow: bool, hover: Hsla, active: Hsla, + /// The tone, as `0xRRGGBB`, this variant is painted in as a solid facet under the Facet + /// skin. `None` keeps the flat colours above on every skin. + facet_tone: Option, } pub trait ButtonVariants: Sized { @@ -147,9 +161,25 @@ impl ButtonCustomVariant { hover: cx.theme().transparent, active: cx.theme().transparent, shadow: false, + facet_tone: None, } } + /// Paint this variant as a solid facet of `tone` under the Facet skin. + /// + /// The flat colours set through the other builders still paint it under the Flat skin, and + /// the foreground is the same under both. + /// + /// Args: + /// tone: The fill tone as `0xRRGGBB`. + /// + /// Returns: + /// The updated variant. + pub(crate) fn facet_solid(mut self, tone: u32) -> Self { + self.facet_tone = Some(tone); + self + } + /// Set background color, default is transparent. pub fn color(mut self, color: Hsla) -> Self { self.color = color; @@ -595,6 +625,10 @@ impl RenderOnce for Button { /// /// The window supplies focus state, the app supplies theme tokens, and the returned element /// owns all mouse, focus, tooltip, and click wiring for this one-shot button. + /// + /// Every state is painted flat first, exactly as a flat theme draws it. Under the Facet skin + /// the faceted surface of the same state, where the variant has one, is then painted over + /// its background, border colour and shadows; text colours are the flat ones on both skins. fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let style: ButtonVariant = self.variant; let clickable = self.clickable(); @@ -606,6 +640,11 @@ impl RenderOnce for Button { .unwrap_or_else(|| MoonButtonMetrics::for_size(self.size, cx)); let normal_style = style.normal(self.outline, cx); let icon_size = Size::Size(metrics.icon_size); + // Resolved once: the facet of each state is read off these tokens, and is `None` for + // every state under the Flat skin. + let tokens = MoonTheme::active_tokens(cx); + let outline = self.outline; + let facet_in = |state: ButtonVisualState| style.resolve_facet(&tokens, outline, state); let focus_handle = window .use_keyed_state(self.id.clone(), cx, |_, cx| cx.focus_handle()) @@ -672,37 +711,51 @@ impl RenderOnce for Button { this.bg(selected_style.bg) .border_color(selected_style.border) .text_color(selected_style.fg) + .map(|this| faceted(this, facet_in(ButtonVisualState::Selected))) .when(pointer_feedback, |this| { this.hover(|this| { let hover_style = style.selected_hovered(self.outline, cx); - this.bg(hover_style.bg) - .border_color(hover_style.border) - .text_color(hover_style.fg) + faceted( + this.bg(hover_style.bg) + .border_color(hover_style.border) + .text_color(hover_style.fg), + facet_in(ButtonVisualState::SelectedHovered), + ) }) .active(|this| { let active_style = style.selected_active(self.outline, cx); - this.bg(active_style.bg) - .border_color(active_style.border) - .text_color(active_style.fg) + faceted( + this.bg(active_style.bg) + .border_color(active_style.border) + .text_color(active_style.fg), + facet_in(ButtonVisualState::SelectedActive), + ) }) }) }) .when(!self.disabled && !self.selected, |this| { this.border_color(normal_style.border) .bg(normal_style.bg) + .map(|this| faceted(this, facet_in(ButtonVisualState::Normal))) .when(normal_style.underline, |this| this.text_decoration_1()) .when(pointer_feedback, |this| { this.hover(|this| { let hover_style = style.hovered(self.outline, cx); - this.bg(hover_style.bg) - .border_color(hover_style.border) - .text_color(hover_style.fg) + faceted( + this.bg(hover_style.bg) + .border_color(hover_style.border) + .text_color(hover_style.fg), + facet_in(ButtonVisualState::Hovered), + ) }) .active(|this| { let active_style = style.active(self.outline, cx); - this.bg(active_style.bg) - .border_color(active_style.border) - .text_color(active_style.fg) + faceted( + this.bg(active_style.bg) + .border_color(active_style.border) + .text_color(active_style.fg), + facet_in(ButtonVisualState::Active), + ) }) }) }) @@ -780,6 +833,17 @@ impl RenderOnce for Button { this.bg(normal_style.bg.opacity(0.8)) .border_color(normal_style.border.opacity(0.8)) .text_color(normal_style.fg.opacity(0.8)) + .map(|this| { + let resting = facet_in(ButtonVisualState::Normal); + faceted( + this, + resting.map(|surface| MoonFacet { + fill: surface.fill.opacity(LOADING_FACET_OPACITY), + edge: surface.edge.opacity(LOADING_FACET_OPACITY), + shadows: surface.shadows, + }), + ) + }) }) .map(|this| { if let Some(builder) = self.tooltip_builder { @@ -827,8 +891,11 @@ pub(crate) struct MoonButtonStyle { } /// Internal visual states used to resolve one button variant. -#[derive(Clone, Copy)] -enum ButtonVisualState { +/// +/// Crate-visible so a Moon wrapper's tests can ask [`ButtonVariant::resolve_facet`] for the +/// surface of one state. +#[derive(Clone, Copy, Debug)] +pub(crate) enum ButtonVisualState { Normal, Hovered, Active, @@ -866,6 +933,107 @@ fn pointer_feedback_enabled(disabled: bool, loading: bool) -> bool { !disabled && !loading } +/// Paint a faceted surface over the flat style already set on `this`. +/// +/// Args: +/// this: The element or style refinement carrying one state's flat style. +/// surface: The faceted surface of that state; `None` under the Flat skin and for a variant +/// with no facet. +/// +/// Returns: +/// `this` with the surface's fill, edge colour and shadow stack replacing the flat +/// background, border colour and shadows, or `this` untouched when there is no surface. +fn faceted(this: S, surface: Option) -> S { + match surface { + Some(surface) => surface.paint(this), + None => this, + } +} + +/// Strip a faceted surface of every shadow cast outside it, its drop shadow and its glow, so +/// that it reads as pressed flush with the panel. The highlight inside the surface is kept. +fn flush(mut surface: MoonFacet) -> MoonFacet { + surface.shadows.retain(|shadow| shadow.inset); + surface +} + +/// Resolve the surface of a pressed raised control under the Facet skin. +/// +/// It is the [`MoonFacetKind::Raised`] surface lit from below: the two ends of that fill +/// swapped, so `panel` is on top and `panel_high` at the bottom, with the same edge and no drop +/// shadow. Crate-visible so every raised control presses the same way. +/// +/// Args: +/// tokens: The theme side to resolve it for; supplies the palette and the UI scale. +/// +/// Returns: +/// The pressed surface. +pub(crate) fn raised_pressed_facet(tokens: &MoonThemeTokens) -> MoonFacet { + let p = tokens.palette; + let raised = facet(MoonFacetKind::Raised, tokens); + flush(MoonFacet { + fill: linear_gradient( + 180.0, + linear_color_stop(rgba_from(p.panel, 1.0), 0.0), + linear_color_stop(rgba_from(p.panel_high, 1.0), 1.0), + ), + ..raised + }) +} + +/// Resolve the surface of a neutral raised button in `state`: raised at rest, relit under the +/// pointer and lit from below while pressed. Selection does not change it. +fn raised_button_facet(state: ButtonVisualState, tokens: &MoonThemeTokens) -> MoonFacet { + if state.is_active() { + raised_pressed_facet(tokens) + } else if state.is_hovered() { + facet(MoonFacetKind::RaisedHover, tokens) + } else { + facet(MoonFacetKind::Raised, tokens) + } +} + +/// Resolve the surface of a button washed with `tone` (`0xRRGGBB`) in `state`. +/// +/// At rest it is the soft wash. Under the pointer and when selected it is the selected wash, +/// with its glow. A selected button under the pointer also takes the tone's full edge, and a +/// pressed one, selected or not, is the selected wash pressed flush. +fn tinted_button_facet(tone: u32, state: ButtonVisualState, tokens: &MoonThemeTokens) -> MoonFacet { + let lit = || facet_tinted(tone, MoonFacetTint::Selected, tokens); + if state.is_active() { + flush(lit()) + } else if state.is_hovered() && state.is_selected() { + MoonFacet { + edge: rgba_from(tone, 1.0), + ..lit() + } + } else if state.is_hovered() || state.is_selected() { + lit() + } else { + facet_tinted(tone, MoonFacetTint::Soft, tokens) + } +} + +/// Resolve the surface of a button filled with `tone` (`0xRRGGBB`) in `state`: the solid facet +/// at rest and when selected, with a second glow under the pointer, and pressed flush while +/// pressed. +fn solid_button_facet(tone: u32, state: ButtonVisualState, tokens: &MoonThemeTokens) -> MoonFacet { + let mut solid = facet_tinted(tone, MoonFacetTint::Solid, tokens); + if state.is_active() { + flush(solid) + } else if state.is_hovered() { + solid.shadows.push(facet_glow( + tone, + SOLID_HOVER_GLOW_ALPHA, + SOLID_HOVER_GLOW_BLUR, + tokens, + )); + solid + } else { + solid + } +} + impl ButtonVariant { /// Return whether the variant uses the shared neutral interaction surface. fn uses_neutral_interaction_surface(&self) -> bool { @@ -1188,6 +1356,82 @@ impl ButtonVariant { } } + /// Resolve the faceted surface one state is painted with under the Facet skin. + /// + /// The neutral variants (`Default`, `Panel`, `Secondary`, `Soft`) are raised controls. The + /// tone variants (`Primary`/`Blue`/`Info`, `Warning`/`Amber`, `Success`/`Green`, + /// `Danger`/`Red`) are a wash of their flat fill's tone, as is a selected `Default`, which + /// takes the accent. Where the flat fill of a tone variant is opaque, its ink was chosen + /// for a solid fill, so the surface is the solid facet of that tone. A custom variant is a + /// solid facet only when it names a tone through [`ButtonCustomVariant::facet_solid`]; like + /// its flat colours, it draws every selected state as the pressed one. + /// + /// Args: + /// tokens: The theme side to resolve for; supplies the skin, the palette and the UI + /// scale. + /// outline: Whether the button is drawn in its outline style. + /// state: The visual state to resolve. + /// + /// Returns: + /// The surface to paint over the flat style of `state`. `None` under the Flat skin, for + /// a disabled button, for an outlined tone variant, and for the variants that have no + /// surface of their own (`Ghost`, `Bare`, `Link`, `Text`, `OutlineAmber`, `OutlineRed` + /// and a custom variant with no facet tone): all of those keep the flat style. + pub(crate) fn resolve_facet( + &self, + tokens: &MoonThemeTokens, + outline: bool, + state: ButtonVisualState, + ) -> Option { + if !tokens.skin.is_facet() || matches!(state, ButtonVisualState::Disabled) { + return None; + } + let p = tokens.palette; + let selected = state.is_selected(); + + match self { + Self::Default if selected => Some(tinted_button_facet(p.accent, state, tokens)), + Self::Default | Self::Panel | Self::Secondary | Self::Soft => { + Some(raised_button_facet(state, tokens)) + } + Self::Primary + | Self::Blue + | Self::Info + | Self::Warning + | Self::Amber + | Self::Success + | Self::Green + | Self::Danger + | Self::Red => { + if outline { + return None; + } + let flat = self.moon_style(p, outline, selected)?; + Some(if flat.bg_alpha >= 1.0 { + solid_button_facet(flat.bg, state, tokens) + } else { + tinted_button_facet(flat.bg, state, tokens) + }) + } + Self::Custom(colors) => { + let state = if selected { + ButtonVisualState::Active + } else { + state + }; + colors + .facet_tone + .map(|tone| solid_button_facet(tone, state, tokens)) + } + Self::Ghost + | Self::Bare + | Self::Link + | Self::Text + | Self::OutlineAmber + | Self::OutlineRed => None, + } + } + /// Resolve custom colors or delegate a built-in variant to the active Moon palette. fn resolve(&self, outline: bool, state: ButtonVisualState, cx: &mut App) -> ButtonVariantStyle { if let Self::Custom(colors) = self { diff --git a/crates/moon-ui-components/src/button/button/tests.rs b/crates/moon-ui-components/src/button/button/tests.rs index c5836c9..c68ce1e 100644 --- a/crates/moon-ui-components/src/button/button/tests.rs +++ b/crates/moon-ui-components/src/button/button/tests.rs @@ -1,14 +1,17 @@ //! Unit coverage for base button builders, interaction states, and Moon sizing. use super::{ - Button, ButtonRounded, ButtonVariant, ButtonVariantStyle, ButtonVariants, ButtonVisualState, - MoonButtonMetrics, pointer_feedback_enabled, + Button, ButtonCustomVariant, ButtonRounded, ButtonVariant, ButtonVariantStyle, ButtonVariants, + ButtonVisualState, MoonButtonMetrics, pointer_feedback_enabled, raised_pressed_facet, }; use crate::{ Disableable as _, Selectable as _, Sizable as _, Size, - moon::{MoonPalette, rgba_from}, + moon::{ + MoonFacet, MoonFacetKind, MoonFacetTint, MoonPalette, MoonSkin, MoonTheme, MoonThemeConfig, + MoonThemeTokens, ThemeMode, facet, facet_tinted, rgba_from, + }, }; -use gpui::px; +use gpui::{Background, BoxShadow, Hsla, linear_color_stop, linear_gradient, point, px}; /// Catches removing an assignment from a `Button` builder, which would make the corresponding /// public modifier silently render with its default value. @@ -256,3 +259,692 @@ fn test_moon_button_variant_tokens_match_terminal_palette() { assert_eq!(soft.bg_alpha, 0.02); assert_eq!(soft.hover_bg_alpha, 0.055); } + +// --- Facet skin ----------------------------------------------------------------------------- + +/// Every visual state a button resolves. +const ALL_STATES: [ButtonVisualState; 7] = [ + ButtonVisualState::Normal, + ButtonVisualState::Hovered, + ButtonVisualState::Active, + ButtonVisualState::Selected, + ButtonVisualState::SelectedHovered, + ButtonVisualState::SelectedActive, + ButtonVisualState::Disabled, +]; + +/// The neutral variants, which the Facet skin draws as raised controls. +const RAISED_VARIANTS: [ButtonVariant; 4] = [ + ButtonVariant::Default, + ButtonVariant::Panel, + ButtonVariant::Secondary, + ButtonVariant::Soft, +]; + +/// The built-in variants the Facet skin leaves flat. +const FLAT_ONLY_VARIANTS: [ButtonVariant; 6] = [ + ButtonVariant::Ghost, + ButtonVariant::Bare, + ButtonVariant::Link, + ButtonVariant::Text, + ButtonVariant::OutlineAmber, + ButtonVariant::OutlineRed, +]; + +/// The tone variants, each with the palette entry that fills it on a dark palette. +fn dark_tone_variants(p: MoonPalette) -> [(ButtonVariant, u32); 9] { + [ + (ButtonVariant::Primary, p.blue), + (ButtonVariant::Blue, p.blue), + (ButtonVariant::Info, p.blue), + (ButtonVariant::Warning, p.amber), + (ButtonVariant::Amber, p.amber), + (ButtonVariant::Success, p.green), + (ButtonVariant::Green, p.green), + (ButtonVariant::Danger, p.red), + (ButtonVariant::Red, p.red), + ] +} + +/// Theme tokens painting `palette` with `skin` at the reference scale. +fn tokens_with(palette: MoonPalette, skin: MoonSkin) -> MoonThemeTokens { + MoonThemeTokens { + skin, + palette, + ..MoonThemeTokens::default() + } +} + +/// An opaque gradient from `top` at the top edge to `bottom` at the bottom edge, written out +/// here rather than taken from the code under test. +fn top_to_bottom(top: u32, bottom: u32) -> Background { + linear_gradient( + 180.0, + linear_color_stop(rgba_from(top, 1.0), 0.0), + linear_color_stop(rgba_from(bottom, 1.0), 1.0), + ) +} + +/// A shadow with no horizontal offset and no spread, written out here rather than taken from +/// the code under test. `design_blur` is the blur as the design gives it, a CSS `box-shadow` +/// blur. +fn shadow(y: f32, design_blur: f32, color: Hsla, inset: bool) -> BoxShadow { + BoxShadow { + color, + offset: point(px(0.0), px(y)), + // The renderer's blur radius is the gaussian's standard deviation: half a CSS blur. + blur_radius: px(design_blur / 2.0), + spread_radius: px(0.0), + inset, + } +} + +/// Keep only the shadows drawn inside a surface. +fn inset_only(surface: &MoonFacet) -> Vec { + surface + .shadows + .iter() + .filter(|shadow| shadow.inset) + .cloned() + .collect() +} + +/// One variant family's flat paint on the dark terminal palette, as it was before the Facet +/// skin existed. Every pair is `(0xRRGGBB, alpha)`. +struct FlatFamily { + variants: &'static [ButtonVariant], + /// The fill at rest, under the pointer, pressed and selected. + fills: [(u32, f32); 4], + /// The border in the same four states. + borders: [(u32, f32); 4], + /// The ink at rest, under the pointer and pressed. + ink: (u32, f32), + /// The ink when selected. + selected_ink: (u32, f32), +} + +/// The flat paint of every variant the Facet skin restyles, on the dark terminal palette `p`. +/// +/// The alphas, and the two colours that are not palette entries, are copied from the flat +/// design rather than read back from `ButtonVariant::moon_style`. +fn flat_dark_families(p: MoonPalette) -> Vec { + const DEFAULT_FILL: u32 = 0x1F2126; + const WHITE: u32 = 0xFFFFFF; + let neutral_hover = (p.overlay, 0.10); + let neutral_pressed = (p.overlay, 0.06); + let hover_border = (p.border_hover, 1.0); + let tone = |color: u32, fills: [f32; 4], borders: [f32; 4]| { + ( + fills.map(|alpha| (color, alpha)), + borders.map(|alpha| (color, alpha)), + ) + }; + let (blue_fills, blue_borders) = + tone(p.blue, [0.10, 0.18, 0.12, 0.18], [0.22, 0.42, 0.30, 0.38]); + let (amber_fills, amber_borders) = + tone(p.amber, [0.10, 0.18, 0.12, 0.18], [0.22, 0.42, 0.30, 0.38]); + let (green_fills, green_borders) = + tone(p.green, [0.14, 0.22, 0.14, 0.22], [0.30, 0.44, 0.34, 0.30]); + let (red_fills, red_borders) = tone(p.red, [0.10, 0.22, 0.14, 0.10], [0.38, 0.48, 0.36, 0.38]); + let (danger_fills, danger_borders) = + tone(p.red, [0.14, 0.22, 0.14, 0.14], [0.38, 0.48, 0.36, 0.38]); + + vec![ + FlatFamily { + variants: &[ButtonVariant::Default], + fills: [ + (DEFAULT_FILL, 1.0), + neutral_hover, + neutral_pressed, + (p.accent, p.accent_tint_a), + ], + borders: [(p.border, 1.0), hover_border, hover_border, (p.accent, 1.0)], + ink: (p.text, 0.86), + selected_ink: (p.accent_fg, 1.0), + }, + FlatFamily { + variants: &[ButtonVariant::Panel], + fills: [ + (p.shell_high, 1.0), + neutral_hover, + neutral_pressed, + (p.shell_high, 1.0), + ], + borders: [(p.border, 1.0), hover_border, hover_border, (p.border, 1.0)], + ink: (p.text, 1.0), + selected_ink: (p.text, 1.0), + }, + FlatFamily { + variants: &[ButtonVariant::Secondary, ButtonVariant::Soft], + fills: [(WHITE, 0.02), neutral_hover, neutral_pressed, (WHITE, 0.02)], + borders: [(WHITE, 0.05), hover_border, hover_border, (WHITE, 0.05)], + ink: (p.text_soft, 1.0), + selected_ink: (p.text_soft, 1.0), + }, + FlatFamily { + variants: &[ + ButtonVariant::Primary, + ButtonVariant::Blue, + ButtonVariant::Info, + ], + fills: blue_fills, + borders: blue_borders, + ink: (p.blue, 1.0), + selected_ink: (p.blue, 1.0), + }, + FlatFamily { + variants: &[ButtonVariant::Warning, ButtonVariant::Amber], + fills: amber_fills, + borders: amber_borders, + ink: (p.amber, 1.0), + selected_ink: (p.amber, 1.0), + }, + FlatFamily { + variants: &[ButtonVariant::Success, ButtonVariant::Green], + fills: green_fills, + borders: green_borders, + ink: (p.green, 1.0), + selected_ink: (p.green, 1.0), + }, + FlatFamily { + variants: &[ButtonVariant::Red], + fills: red_fills, + borders: red_borders, + ink: (p.red, 1.0), + selected_ink: (p.red, 1.0), + }, + FlatFamily { + variants: &[ButtonVariant::Danger], + fills: danger_fills, + borders: danger_borders, + ink: (p.red, 1.0), + selected_ink: (p.red, 1.0), + }, + ] +} + +/// One variant family's flat resting paint on the light palette, as it was before the Facet +/// skin existed. Every pair is `(0xRRGGBB, alpha)`. +struct FlatLightFamily { + variants: &'static [ButtonVariant], + fill: (u32, f32), + border: (u32, f32), + ink: (u32, f32), +} + +/// The flat resting paint of every variant the Facet skin restyles, on the light palette `p`, +/// with the alphas copied from the flat design. +fn flat_light_families(p: MoonPalette) -> Vec { + vec![ + FlatLightFamily { + variants: &[ButtonVariant::Default], + fill: (p.surface, 1.0), + border: (p.border_soft, 1.0), + ink: (p.text, 0.86), + }, + FlatLightFamily { + variants: &[ButtonVariant::Panel], + fill: (p.surface, 1.0), + border: (p.border_soft, 1.0), + ink: (p.text, 1.0), + }, + FlatLightFamily { + variants: &[ButtonVariant::Secondary, ButtonVariant::Soft], + fill: (p.surface, 1.0), + border: (p.border_soft, 1.0), + ink: (p.text_soft, 1.0), + }, + FlatLightFamily { + variants: &[ + ButtonVariant::Primary, + ButtonVariant::Blue, + ButtonVariant::Info, + ], + fill: (p.accent, 0.10), + border: (p.accent, 0.22), + ink: (p.accent, 1.0), + }, + FlatLightFamily { + variants: &[ButtonVariant::Warning, ButtonVariant::Amber], + fill: (p.amber, 0.10), + border: (p.amber, 0.22), + ink: (p.amber, 1.0), + }, + FlatLightFamily { + variants: &[ButtonVariant::Success, ButtonVariant::Green], + fill: (p.green_btn, 0.14), + border: (p.green_btn, 0.30), + ink: (p.green_text, 1.0), + }, + FlatLightFamily { + variants: &[ButtonVariant::Red], + fill: (p.red, 0.10), + border: (p.red, 0.38), + ink: (p.red_text, 1.0), + }, + FlatLightFamily { + variants: &[ButtonVariant::Danger], + fill: (p.red, 0.14), + border: (p.red, 0.38), + ink: (p.red_text, 1.0), + }, + ] +} + +/// Catches the Facet skin leaking into a flat theme: `button.rs:resolve_facet` answering for +/// the Flat skin, or a restyled variant's flat colours drifting while the facet was added. +/// Either one repaints every button of every existing theme. +#[gpui::test] +fn flat_theme_buttons_keep_their_colours_and_resolve_no_facet(cx: &mut gpui::TestAppContext) { + cx.update(crate::init); + cx.update(|cx| MoonTheme::install_config(MoonThemeConfig::default(), cx)); + let paint = |(color, alpha): (u32, f32)| rgba_from(color, alpha); + + cx.update(|cx| { + assert_eq!(MoonSkin::active(cx), MoonSkin::Flat); + let tokens = MoonTheme::active_tokens(cx); + let p = tokens.palette; + assert!(!p.is_light(), "the default theme starts on its dark side"); + + for family in flat_dark_families(p) { + for variant in family.variants { + let resolved = [ + ("resting", variant.normal(false, cx)), + ("hovered", variant.hovered(false, cx)), + ("pressed", variant.active(false, cx)), + ("selected", variant.selected(false, cx)), + ]; + for (ix, (state, style)) in resolved.iter().enumerate() { + let ink = if ix == 3 { + family.selected_ink + } else { + family.ink + }; + assert_eq!( + (style.bg, style.border, style.fg), + ( + paint(family.fills[ix]), + paint(family.borders[ix]), + paint(ink) + ), + "flat {state} {variant:?} drifted on the dark palette" + ); + } + for state in ALL_STATES { + assert_eq!( + variant.resolve_facet(&tokens, false, state), + None, + "{variant:?} {state:?} paints a facet under the Flat skin" + ); + } + } + } + }); + + cx.update(|cx| MoonTheme::set_mode(ThemeMode::Light, cx)); + cx.update(|cx| { + assert_eq!(MoonSkin::active(cx), MoonSkin::Flat); + let tokens = MoonTheme::active_tokens(cx); + let p = tokens.palette; + assert!(p.is_light()); + + for family in flat_light_families(p) { + for variant in family.variants { + let style = variant.normal(false, cx); + assert_eq!( + (style.bg, style.border, style.fg), + (paint(family.fill), paint(family.border), paint(family.ink)), + "flat resting {variant:?} drifted on the light palette" + ); + for state in ALL_STATES { + assert_eq!( + variant.resolve_facet(&tokens, false, state), + None, + "{variant:?} {state:?} paints a facet under the Flat skin" + ); + } + } + } + }); +} + +/// Catches `button.rs:resolve_facet` losing the raised surface of a neutral variant on the +/// faceted theme, swapping its hover and resting surfaces, or pressing it without turning the +/// light: the Obsidian theme would keep flat slabs for buttons, or they would not move under +/// the pointer. +#[gpui::test] +fn faceted_theme_raises_the_neutral_variants(cx: &mut gpui::TestAppContext) { + cx.update(crate::init); + cx.update(|cx| MoonTheme::install_config(MoonThemeConfig::moon_obsidian(), cx)); + + cx.update(|cx| { + let tokens = MoonTheme::active_tokens(cx); + let p = tokens.palette; + assert_eq!(tokens.skin, MoonSkin::Facet); + + for variant in RAISED_VARIANTS { + let rest = variant + .resolve_facet(&tokens, false, ButtonVisualState::Normal) + .unwrap_or_else(|| panic!("{variant:?} has no resting facet")); + assert_eq!( + rest.fill.as_solid(), + None, + "{variant:?} must rest on a gradient, not a solid fill" + ); + assert_eq!(rest.fill, top_to_bottom(p.panel_high, p.panel)); + assert!( + !rest.shadows.is_empty(), + "{variant:?} must rest with a shadow stack" + ); + assert_eq!(rest, facet(MoonFacetKind::Raised, &tokens)); + + assert_eq!( + variant.resolve_facet(&tokens, false, ButtonVisualState::Hovered), + Some(facet(MoonFacetKind::RaisedHover, &tokens)), + "{variant:?} under the pointer" + ); + + let pressed = variant + .resolve_facet(&tokens, false, ButtonVisualState::Active) + .unwrap_or_else(|| panic!("{variant:?} has no pressed facet")); + assert_eq!( + pressed.fill, + top_to_bottom(p.panel, p.panel_high), + "pressed {variant:?} is lit from below: the resting ends swapped" + ); + assert_eq!(pressed.edge, rest.edge); + assert_eq!( + pressed.shadows, + vec![shadow(1.0, 0.0, rgba_from(p.overlay, 0.16), true)], + "pressed {variant:?} keeps its highlight and casts no drop shadow" + ); + assert_eq!(pressed, raised_pressed_facet(&tokens)); + } + + // Selection does not restyle a panel or soft button under the Flat skin either. + for variant in [ + ButtonVariant::Panel, + ButtonVariant::Secondary, + ButtonVariant::Soft, + ] { + assert_eq!( + variant.resolve_facet(&tokens, false, ButtonVisualState::Selected), + Some(facet(MoonFacetKind::Raised, &tokens)), + "selected {variant:?}" + ); + } + }); + + cx.update(|cx| MoonTheme::set_mode(ThemeMode::Light, cx)); + cx.update(|cx| { + let tokens = MoonTheme::active_tokens(cx); + for variant in RAISED_VARIANTS { + for state in ALL_STATES { + assert_eq!( + variant.resolve_facet(&tokens, false, state), + None, + "the light side of Obsidian is flat: {variant:?} {state:?}" + ); + } + } + }); +} + +/// Catches `button.rs:resolve_facet` washing a tone variant with the wrong tone or the wrong +/// strength: a danger button tinted like a neutral one, or a hovered or selected button that +/// does not light up. +#[test] +fn faceted_tone_variants_wash_soft_at_rest_and_light_up_when_hovered_or_selected() { + let p = MoonPalette::OBSIDIAN; + let tokens = tokens_with(p, MoonSkin::Facet); + + for (variant, tone) in dark_tone_variants(p) { + let at = |state: ButtonVisualState| { + variant + .resolve_facet(&tokens, false, state) + .unwrap_or_else(|| panic!("{variant:?} {state:?} has no facet")) + }; + let soft = facet_tinted(tone, MoonFacetTint::Soft, &tokens); + let lit = facet_tinted(tone, MoonFacetTint::Selected, &tokens); + + assert_eq!(at(ButtonVisualState::Normal), soft, "resting {variant:?}"); + assert_eq!(at(ButtonVisualState::Hovered), lit, "hovered {variant:?}"); + assert_eq!(at(ButtonVisualState::Selected), lit, "selected {variant:?}"); + + let selected_hover = at(ButtonVisualState::SelectedHovered); + assert_eq!(selected_hover.edge, rgba_from(tone, 1.0)); + assert_eq!( + (selected_hover.fill, &selected_hover.shadows), + (lit.fill, &lit.shadows), + "a selected {variant:?} under the pointer changes its edge only" + ); + + for state in [ButtonVisualState::Active, ButtonVisualState::SelectedActive] { + let pressed = at(state); + assert_eq!((pressed.fill, pressed.edge), (lit.fill, lit.edge)); + assert_eq!( + pressed.shadows, + inset_only(&lit), + "{state:?} {variant:?} drops its drop shadow and glow" + ); + assert!(pressed.shadows.len() < lit.shadows.len()); + } + + assert_eq!( + variant.resolve_facet(&tokens, false, ButtonVisualState::Disabled), + None, + "a disabled {variant:?} keeps the flat disabled look" + ); + for state in ALL_STATES { + assert_eq!( + variant.resolve_facet(&tokens, true, state), + None, + "an outlined {variant:?} keeps its flat outline in {state:?}" + ); + } + } +} + +/// Catches a selected neutral button falling back to the raised surface in +/// `button.rs:resolve_facet`, which would hide which toolbar toggle is on, and catches its +/// ink changing with the skin. +#[test] +fn a_selected_neutral_button_is_an_accent_facet_with_its_flat_ink() { + let p = MoonPalette::OBSIDIAN; + let tokens = tokens_with(p, MoonSkin::Facet); + let variant = ButtonVariant::Default; + + assert_eq!( + variant.resolve_facet(&tokens, false, ButtonVisualState::Selected), + Some(facet_tinted(p.accent, MoonFacetTint::Selected, &tokens)) + ); + assert_eq!( + variant + .resolve_moon(p, false, ButtonVisualState::Selected) + .fg, + rgba_from(p.accent_fg, 1.0), + "the selected ink is the flat one" + ); +} + +/// Catches a variant with no surface of its own picking one up in `button.rs:resolve_facet`: +/// a ghost toolbar button or a text link drawn as a raised slab on the faceted theme. +#[test] +fn ghost_bare_link_text_and_outline_variants_stay_flat_under_the_facet_skin() { + for palette in [MoonPalette::OBSIDIAN, MoonPalette::LIGHT] { + let tokens = tokens_with(palette, MoonSkin::Facet); + for variant in FLAT_ONLY_VARIANTS { + for outline in [false, true] { + for state in ALL_STATES { + assert_eq!( + variant.resolve_facet(&tokens, outline, state), + None, + "{variant:?} {state:?} (outline={outline}, light={})", + palette.is_light() + ); + } + } + } + } +} + +/// Catches a faceted state resolving to the surface of the state before it in +/// `button.rs:resolve_facet`, which would make a faceted button look inert under the pointer +/// or under a press, on a dark palette or a light one. +#[test] +fn faceted_buttons_keep_pointer_feedback_on_dark_and_light_palettes() { + for palette in [MoonPalette::OBSIDIAN, MoonPalette::LIGHT] { + let tokens = tokens_with(palette, MoonSkin::Facet); + let tones = dark_tone_variants(palette).map(|(variant, _)| variant); + for variant in RAISED_VARIANTS.into_iter().chain(tones) { + let at = |state: ButtonVisualState| { + variant + .resolve_facet(&tokens, false, state) + .unwrap_or_else(|| panic!("{variant:?} {state:?} has no facet")) + }; + let light = palette.is_light(); + + assert_ne!( + at(ButtonVisualState::Normal), + at(ButtonVisualState::Hovered), + "{variant:?} has no hover feedback (light={light})" + ); + assert_ne!( + at(ButtonVisualState::Hovered), + at(ButtonVisualState::Active), + "{variant:?} has no pressed feedback (light={light})" + ); + assert_ne!( + at(ButtonVisualState::Normal), + at(ButtonVisualState::Active), + "{variant:?} has no distinct pressed state (light={light})" + ); + assert_ne!( + at(ButtonVisualState::Selected), + at(ButtonVisualState::SelectedHovered), + "selected {variant:?} has no hover feedback (light={light})" + ); + assert_ne!( + at(ButtonVisualState::Selected), + at(ButtonVisualState::SelectedActive), + "selected {variant:?} has no distinct pressed state (light={light})" + ); + assert_ne!( + at(ButtonVisualState::SelectedHovered), + at(ButtonVisualState::SelectedActive), + "selected {variant:?} has no pressed feedback (light={light})" + ); + } + } +} + +/// Catches `button.rs:resolve_facet` washing a tone variant whose flat fill is opaque: the +/// light palette's selected green button carries white ink for a solid green fill, and on a +/// pale green wash that label would be unreadable. +#[test] +fn an_opaque_tone_fill_becomes_a_solid_facet() { + let p = MoonPalette::LIGHT; + let tokens = tokens_with(p, MoonSkin::Facet); + let variant = ButtonVariant::Green; + + assert_eq!( + variant.resolve_facet(&tokens, false, ButtonVisualState::Normal), + Some(facet_tinted(p.green_btn, MoonFacetTint::Soft, &tokens)), + "the translucent resting fill stays a wash" + ); + assert_eq!( + variant.resolve_facet(&tokens, false, ButtonVisualState::Selected), + Some(facet_tinted(p.green_btn, MoonFacetTint::Solid, &tokens)) + ); + assert_eq!( + variant + .resolve_moon(p, false, ButtonVisualState::Selected) + .fg, + rgba_from(p.on_accent, 1.0), + "the ink this fill has to carry" + ); +} + +/// Catches a custom variant's facet tone being ignored, or honoured under the Flat skin, in +/// `button.rs:resolve_facet`: the solid primary button would stay a flat accent slab on the +/// faceted theme, or would change on every flat one. Also catches a custom variant with no +/// tone picking a facet up. +#[gpui::test] +fn a_custom_variant_is_a_solid_facet_only_with_a_tone_and_only_under_the_facet_skin( + cx: &mut gpui::TestAppContext, +) { + cx.update(crate::init); + cx.update(|cx| MoonTheme::install_config(MoonThemeConfig::default(), cx)); + + cx.update(|cx| { + let p = MoonPalette::OBSIDIAN; + let tone = p.accent; + let fill = rgba_from(tone, 1.0); + let hover = rgba_from(tone, 0.9); + let active = rgba_from(tone, 0.8); + let label = rgba_from(p.shell, 1.0); + let plain = ButtonCustomVariant::new(cx) + .color(fill) + .hover(hover) + .active(active) + .foreground(label); + let toned = ButtonVariant::Custom(plain.facet_solid(tone)); + let plain = ButtonVariant::Custom(plain); + + // Under the Flat skin the tone changes nothing: the flat colours are the ones set. + let flat = MoonTheme::active_tokens(cx); + assert_eq!(flat.skin, MoonSkin::Flat); + for (state, expected) in [ + (ButtonVisualState::Normal, fill), + (ButtonVisualState::Hovered, hover), + (ButtonVisualState::Active, active), + (ButtonVisualState::Selected, active), + ] { + let style = toned.resolve(false, state, cx); + assert_eq!((style.bg, style.fg), (expected, label), "flat {state:?}"); + assert_eq!(toned.resolve_facet(&flat, false, state), None); + } + + let tokens = tokens_with(p, MoonSkin::Facet); + let solid = facet_tinted(tone, MoonFacetTint::Solid, &tokens); + assert_eq!( + toned.resolve_facet(&tokens, false, ButtonVisualState::Normal), + Some(solid.clone()) + ); + + let hovered = toned + .resolve_facet(&tokens, false, ButtonVisualState::Hovered) + .expect("a hovered solid button has a facet"); + let mut with_second_glow = solid.shadows.clone(); + with_second_glow.push(shadow(0.0, 18.0, rgba_from(tone, 0.26), false)); + assert_eq!((hovered.fill, hovered.edge), (solid.fill, solid.edge)); + assert_eq!(hovered.shadows, with_second_glow); + + // Like its flat colours, a custom variant draws every selected state as the pressed one. + for state in [ + ButtonVisualState::Active, + ButtonVisualState::Selected, + ButtonVisualState::SelectedHovered, + ButtonVisualState::SelectedActive, + ] { + let pressed = toned + .resolve_facet(&tokens, false, state) + .unwrap_or_else(|| panic!("solid {state:?} has no facet")); + assert_eq!((pressed.fill, pressed.edge), (solid.fill, solid.edge)); + assert_eq!(pressed.shadows, inset_only(&solid), "solid {state:?}"); + assert!(pressed.shadows.len() < solid.shadows.len()); + } + assert_eq!( + toned.resolve_facet(&tokens, false, ButtonVisualState::Disabled), + None + ); + + for state in ALL_STATES { + assert_eq!( + plain.resolve_facet(&tokens, false, state), + None, + "a custom variant with no tone keeps its own colours in {state:?}" + ); + } + }); +} diff --git a/crates/moon-ui-components/src/checkbox.rs b/crates/moon-ui-components/src/checkbox.rs index d92b3e5..ff0af1b 100644 --- a/crates/moon-ui-components/src/checkbox.rs +++ b/crates/moon-ui-components/src/checkbox.rs @@ -4,7 +4,10 @@ use crate::{ Disableable, Selectable, Sizable, Size, StyledExt as _, moon::MoonTone, moon::foundation::{snap_border, snap_centered}, - moon::{MoonColors, MoonPalette, MoonTheme, MoonThemeTokens, rgba_from, svg::moon_svg}, + moon::{ + MoonColors, MoonFacet, MoonFacetKind, MoonFacetTint, MoonPalette, MoonTheme, + MoonThemeTokens, facet, facet_tinted, rgba_from, svg::moon_svg, + }, text::Text, tooltip::ComponentTooltip, v_flex, @@ -515,6 +518,41 @@ impl ChoiceColors { }, } } + + /// Resolves the faceted surface of a checkbox box or a radio circle. + /// + /// Only the Facet skin paints one. An unchecked box is a socket sunk into its surface; a checked + /// or indeterminate one is its tone lit from above — the control's `tone`, or the palette + /// accent that `bg_brand_solid` stands for. The mark keeps the ink [`Self::resolve`] gives it, + /// which is the ink that reads on that same tone. A disabled box keeps the flat colours at + /// half opacity, so it resolves no facet. + /// + /// Args: + /// tokens: The active theme side; supplies the skin, the palette and the UI scale. + /// tone: The tone set on the control, or `None` for the accent. + /// checked: Whether the box is checked or indeterminate. + /// disabled: Whether the control is disabled. + /// + /// Returns: + /// The facet to paint over the box's flat fill and border, or `None` where the flat + /// colours stand: under the Flat skin and on a disabled control. + pub(crate) fn box_facet( + tokens: &MoonThemeTokens, + tone: Option, + checked: bool, + disabled: bool, + ) -> Option { + if !tokens.skin.is_facet() || disabled { + return None; + } + let p = tokens.palette; + Some(if checked { + let tone = tone.map_or(p.accent, |tone| tone.color(p)); + facet_tinted(tone, MoonFacetTint::Solid, tokens) + } else { + facet(MoonFacetKind::Socket, tokens) + }) + } } /// Returns the focus ring for a checkbox or radio box whose corners have `box_radius`, to add as @@ -701,6 +739,16 @@ impl RenderOnce for Checkbox { .rounded(metrics.radius) .bg(colors.fill) .opacity(colors.box_opacity) + // Under the Facet skin an enabled box takes a faceted surface in place of + // the flat fill and border above; the border keeps its width. + .when_some( + ChoiceColors::box_facet(&tokens, self.tone, checked, self.disabled), + |this, facet| { + this.bg(facet.fill) + .border_color(facet.edge) + .shadow(facet.shadows) + }, + ) .when(is_focused, |this| { this.child(choice_focus_ring( &self.id, diff --git a/crates/moon-ui-components/src/combobox.rs b/crates/moon-ui-components/src/combobox.rs index f3cac61..53476e3 100644 --- a/crates/moon-ui-components/src/combobox.rs +++ b/crates/moon-ui-components/src/combobox.rs @@ -13,9 +13,8 @@ use crate::{ StyleSized, StyledExt, actions::{Cancel, Confirm, SelectDown, SelectUp}, button::{ButtonVariant, MoonButtonMetrics, MoonButtonStyle}, - global_state::GlobalState, h_flex, - input::{clear_button, input_style}, + input::{clear_button, input_style, trigger_facets}, list::{List, ListState}, moon::{ MoonButtonSize, MoonPalette, MoonSize, MoonTheme, resolve_button_size, rgba_from, @@ -529,15 +528,9 @@ where cx.notify(); } + /// Publish dropdown visibility through its owning view. fn set_open(&mut self, open: bool, cx: &mut Context) { self.state.open = open; - - if self.state.open { - GlobalState::global_mut(cx).register_deferred_popover(&self.state.focus_handle) - } else { - GlobalState::global_mut(cx).unregister_deferred_popover(&self.state.focus_handle) - } - cx.notify(); } @@ -718,6 +711,7 @@ where bg, fg, trigger_style, + self.trigger_variant, trigger_metrics, outline_visible, allow_open, @@ -1025,6 +1019,7 @@ fn render_trigger_container( bg: Hsla, fg: Hsla, trigger_style: Option, + trigger_variant: Option, trigger_metrics: Option, outline_visible: bool, allow_open: bool, @@ -1038,6 +1033,16 @@ fn render_trigger_container( || cx.theme().input, |style| rgba_from(style.border, style.border_alpha), ); + // Under the Facet skin the trigger is a surface of its own, with its own hover and press + // ramp. A caller that set a background on it keeps that background. + let trigger_facet = trigger_facets( + &MoonTheme::active_tokens(cx), + trigger_variant, + outline_visible, + disabled, + ) + .filter(|_| appearance && style.background.is_none()); + let flat_ramp = !disabled && trigger_facet.is_none(); div() .id("input") .relative() @@ -1068,7 +1073,7 @@ fn render_trigger_container( .line_height(metrics.line_height) }) .refine_style(style) - .when(!disabled, |this| { + .when(flat_ramp, |this| { this.when_some(trigger_style, |this, style| { this.hover(move |this| { this.bg(rgba_from(style.bg, style.hover_bg_alpha)) @@ -1080,6 +1085,11 @@ fn render_trigger_container( }) }) }) + .when_some(trigger_facet, |this, [rest, hovered, pressed]| { + rest.paint(this) + .hover(move |this| hovered.paint(this)) + .active(move |this| pressed.paint(this)) + }) .when(outline_visible, |this| this.focused_border(cx)) .when(allow_open, |this| { this.when_some(toggle_handler, |this, handler| this.on_click(handler)) diff --git a/crates/moon-ui-components/src/dialog/dialog.rs b/crates/moon-ui-components/src/dialog/dialog.rs index 061ad71..87a5aaf 100644 --- a/crates/moon-ui-components/src/dialog/dialog.rs +++ b/crates/moon-ui-components/src/dialog/dialog.rs @@ -14,6 +14,7 @@ use crate::{ animation::cubic_bezier, button::{Button, ButtonVariant, ButtonVariants as _}, dialog::{DialogContent, DialogTitle}, + moon::{MoonFacetKind, MoonTheme, facet}, scroll::ScrollableElement as _, v_flex, }; @@ -480,6 +481,18 @@ impl RenderOnce for Dialog { let animation = Animation::new(*ANIMATION_DURATION).with_easing(cubic_bezier(0.32, 0.72, 0., 1.)); + // Under the Facet skin the dialog box is a popup surface: gradient fill, lit edge and + // the popup shadow stack. `None` is the Flat skin, which keeps the theme background, + // the theme border and the `shadow_xl` pair below. + let tokens = MoonTheme::active_tokens(cx); + let facet_surface = tokens + .skin + .is_facet() + .then(|| facet(MoonFacetKind::Popup, &tokens)); + let facet_shadows = facet_surface + .as_ref() + .map(|surface| surface.shadows.clone()); + anchored() .position(point(window_paddings.left, window_paddings.top)) .snap_to_window() @@ -528,6 +541,10 @@ impl RenderOnce for Dialog { .border_1() .border_color(cx.theme().border) .rounded(cx.theme().radius_lg) + // Ahead of `refine_style`, so a caller's own fill still wins. + .when_some(facet_surface, |this, surface| { + this.bg(surface.fill).border_color(surface.edge) + }) .min_h_24() .pt(paddings.top) .pb(paddings.bottom) @@ -645,6 +662,17 @@ impl RenderOnce for Dialog { } }) .with_animation("slide-down", animation.clone(), move |this, delta| { + // A faceted dialog fades its popup shadows in the same way. + if let Some(shadows) = &facet_shadows { + let shadow = shadows + .iter() + .map(|shadow| BoxShadow { + color: shadow.color.opacity(delta), + ..shadow.clone() + }) + .collect(); + return this.top(y * delta).shadow(shadow); + } // This is equivalent to `shadow_xl` with an extra opacity. let shadow = vec![ BoxShadow { diff --git a/crates/moon-ui-components/src/global_state.rs b/crates/moon-ui-components/src/global_state.rs index d800d12..836dbbe 100644 --- a/crates/moon-ui-components/src/global_state.rs +++ b/crates/moon-ui-components/src/global_state.rs @@ -1,5 +1,4 @@ -use gpui::{App, ElementId, Entity, FocusHandle, Global, OwnedMenu}; -use std::collections::HashSet; +use gpui::{App, Entity, Global, OwnedMenu}; use crate::text::TextViewState; @@ -9,12 +8,9 @@ pub(crate) fn init(cx: &mut App) { impl Global for GlobalState {} +/// Shared application menus and text-selection coordination. pub struct GlobalState { pub(crate) text_view_state_stack: Vec>, - /// Set of open popover IDs that use deferred rendering. - /// When this set is not empty, we are inside at least one deferred context. - /// This is used to prevent double-deferred elements which would cause GPUI to panic. - open_deferred_popovers: HashSet, /// Application menus storage app_menus: Vec, /// When true, the window-level text selection must not start on the @@ -25,10 +21,10 @@ pub struct GlobalState { } impl GlobalState { + /// Start with no menus or active text-selection owners. pub(crate) fn new() -> Self { Self { text_view_state_stack: Vec::new(), - open_deferred_popovers: HashSet::new(), app_menus: Vec::new(), suppress_text_selection: false, } @@ -55,23 +51,6 @@ impl GlobalState { self.text_view_state_stack.last() } - /// Check if we are currently inside a deferred context (e.g., inside an open Popover). - pub(crate) fn is_in_deferred_context(&self) -> bool { - !self.open_deferred_popovers.is_empty() - } - - /// Register a popover that uses deferred rendering as open. - pub(crate) fn register_deferred_popover(&mut self, focus_handle: &FocusHandle) { - self.open_deferred_popovers - .insert(format!("{focus_handle:?}").into()); - } - - /// Unregister a popover when it closes. - pub(crate) fn unregister_deferred_popover(&mut self, focus_handle: &FocusHandle) { - let element_id: ElementId = format!("{focus_handle:?}").into(); - self.open_deferred_popovers.remove(&element_id); - } - /// Get the application menus pub fn app_menus(&self) -> &[OwnedMenu] { &self.app_menus diff --git a/crates/moon-ui-components/src/input/element.rs b/crates/moon-ui-components/src/input/element.rs index 309b751..c59dd87 100644 --- a/crates/moon-ui-components/src/input/element.rs +++ b/crates/moon-ui-components/src/input/element.rs @@ -1964,6 +1964,8 @@ impl Element for TextElement { } } + /// Paint the prepared input and retain geometry for input handling. Geometry already used + /// by this frame does not invalidate it; scroll or wrapping changes request their own draw. fn paint( &mut self, _id: Option<&GlobalElementId>, @@ -2240,8 +2242,6 @@ impl Element for TextElement { state.scroll_size = prepaint.scroll_size; state.update_scroll_offset(Some(prepaint.cursor_scroll_offset), cx); state.deferred_scroll_offset = None; - - cx.notify(); }); if let Some(hitbox) = prepaint.hover_definition_hitbox.as_ref() { diff --git a/crates/moon-ui-components/src/input/element/regression_tests.rs b/crates/moon-ui-components/src/input/element/regression_tests.rs index 92ce70f..8d5bf65 100644 --- a/crates/moon-ui-components/src/input/element/regression_tests.rs +++ b/crates/moon-ui-components/src/input/element/regression_tests.rs @@ -1,6 +1,10 @@ //! Single-line prepaint regressions, including nonzero glyph advances absent from NoopTextSystem. -use std::{cell::RefCell, rc::Rc, sync::Arc}; +use std::{ + cell::{Cell, RefCell}, + rc::Rc, + sync::Arc, +}; use gpui::{ AppContext as _, Bounds, Context, Entity, FontId, GlyphId, InteractiveElement as _, @@ -12,9 +16,204 @@ use super::TextElement; use crate::{ Root, h_flex, input::{InputState, blink_cursor::CURSOR_WIDTH, display_map::LineLayout}, - moon::{MoonNumberInput, MoonSize, MoonTheme, ThemeMode}, + moon::{MoonInput, MoonNumberInput, MoonSize, MoonTheme, ThemeMode}, }; +/// A real Input whose context-menu hook opens another deferred overlay through Root. +struct NestedMenuHarness { + input: Entity, + nested: bool, + requests: Rc>, +} + +impl Render for NestedMenuHarness { + /// Keep one popover open while testing both its own input and an input in another window. + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + use crate::moon::MoonContextMenuWindowExt as _; + let requests = self.requests.clone(); + let input = crate::input::Input::new(&self.input).context_menu(move |menu, window, cx| { + requests.set(requests.get() + 1); + window.open_fitted_moon_context_menu( + cx, + "nested-input-menu", + window.mouse_position(), + vec![crate::moon::MoonMenuItem::new("Input command")], + 120., + 300., + ); + // Empty NativeMenu avoids OS menu handling in the test; Root paints the real overlay. + menu + }); + let content = if self.nested { + crate::popover::Popover::new("input-parent-popover") + .open(true) + .trigger_any(gpui::div().w(px(240.)).h(px(30.)).child("Open")) + .child(gpui::div().w(px(240.)).child(input)) + .into_any_element() + } else { + gpui::div().w(px(240.)).child(input).into_any_element() + }; + gpui::div().size_full().child(content) + } +} + +/// Catches the app-global deferred-context ban: an open popover must not disable Input's menu +/// inside itself or in another window. The nested Root menu must also survive actual prepaint. +#[gpui::test] +fn input_context_menu_works_inside_popover_and_in_other_window(cx: &mut TestAppContext) { + cx.update(crate::init); + for theme in [ThemeMode::Dark, ThemeMode::Light] { + cx.update(|cx| MoonTheme::set_mode(theme, cx)); + for nested in [true, false] { + let slot = Rc::new(RefCell::new(None)); + let requests = Rc::new(Cell::new(0)); + let window = cx.add_window({ + let slot = slot.clone(); + let requests = requests.clone(); + move |window, cx| { + let input = cx.new(|cx| InputState::new(window, cx).default_value("edit me")); + slot.replace(Some(input.clone())); + let view = cx.new(|_| NestedMenuHarness { + input, + nested, + requests, + }); + Root::new(view, window, cx).bordered(false) + } + }); + let input = slot.borrow().clone().unwrap(); + let mut visual = VisualTestContext::from_window(window.into(), cx); + for _ in 0..4 { + visual.update(|window, _| window.refresh()); + } + let bounds = visual.update(|_, cx| input.read(cx).input_bounds); + assert!(!bounds.is_empty(), "the input must actually be painted"); + visual.simulate_mouse_down( + bounds.center(), + gpui::MouseButton::Right, + Modifiers::none(), + ); + visual.simulate_mouse_up(bounds.center(), gpui::MouseButton::Right, Modifiers::none()); + assert_eq!( + requests.get(), + 1, + "right click was blocked (nested={nested})" + ); + for _ in 0..4 { + visual.update(|window, _| window.refresh()); + } + let popup = visual + .debug_bounds("nested-input-menu:menu:popup") + .expect("nested menu must be painted"); + assert!(!popup.is_empty(), "nested menu must have visible geometry"); + } + } +} + +/// A real input in resizable panels, counting the enclosing view's actual renders. +struct SettlingInputHarness { + state: Entity, + panels: Entity, + renders: Rc>, +} + +impl Render for SettlingInputHarness { + /// Reapply unchanged builder properties just as Select and ordinary application views do. + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + self.renders.set(self.renders.get() + 1); + // A regression may loop inside the initial window draw before the test can poll idle. + assert!( + self.renders.get() < 128, + "unchanged layout entered a render loop" + ); + self.state.update(cx, |state, cx| { + state.set_placeholder("hint", window, cx); + state.set_loading(false, window, cx); + }); + gpui::div().w(px(480.)).h(px(80.)).child( + crate::resizable::h_resizable("settling-input-panels") + .with_state(&self.panels) + .child( + crate::resizable::resizable_panel() + .size(px(240.)) + .child(MoonInput::new("settling-input").state(&self.state)), + ) + .child(crate::resizable::resizable_panel().child("neighbor")), + ) + } +} + +/// Catches restoring paint-time Input notifications, unconditional scroll/placeholder/loading +/// notifications, or unconditional measured-panel notifications: an unchanged form would keep +/// rebuilding after the frame scheduler starts honoring mid-draw debt. Real edits and resizes +/// must still update the input and then settle in both themes. +#[gpui::test] +fn input_and_resizable_layout_settle_after_edits(cx: &mut TestAppContext) { + cx.update(crate::init); + for theme in [ThemeMode::Dark, ThemeMode::Light] { + cx.update(|cx| MoonTheme::set_mode(theme, cx)); + let renders = Rc::new(Cell::new(0)); + let slot = Rc::new(RefCell::new(None)); + let window = cx.add_window({ + let renders = renders.clone(); + let slot = slot.clone(); + move |window, cx| { + let state = cx.new(|cx| InputState::new(window, cx).default_value("before")); + let panels = cx.new(|_| crate::resizable::ResizableState::default()); + slot.replace(Some((state.clone(), panels.clone()))); + let view = cx.new(|_| SettlingInputHarness { + state, + panels, + renders, + }); + Root::new(view, window, cx).bordered(false) + } + }); + let (input, panels) = slot.borrow().clone().expect("controls were created"); + let mut visual = VisualTestContext::from_window(window.into(), cx); + assert_form_settles(&mut visual, &renders); + let before = renders.get(); + let width_before = visual.update(|_, cx| input.read(cx).input_bounds.size.width); + visual.update(|window, cx| { + input.update(cx, |state, cx| state.set_value("after", window, cx)); + panels.update(cx, |state, cx| state.resize_panel(0, px(160.), window, cx)); + }); + assert_form_settles(&mut visual, &renders); + assert!( + renders.get() > before, + "the changed form must actually render" + ); + visual.update(|_, cx| { + let state = input.read(cx); + assert_eq!(state.value().as_ref(), "after"); + assert!( + state.input_bounds.size.width < width_before, + "resize must reach the input layout" + ); + assert!( + state.last_layout.is_some(), + "input geometry must still be retained" + ); + }); + } +} + +/// Give genuine one-shot layout corrections a bounded opportunity to finish, then test idle. +fn assert_form_settles(visual: &mut VisualTestContext, renders: &Cell) { + for _ in 0..8 { + visual.update(|_, _| {}); + } + let settled = renders.get(); + for _ in 0..4 { + visual.update(|_, _| {}); + } + assert_eq!( + renders.get(), + settled, + "unchanged layout must not owe another render" + ); +} + /// Hosts the actual Xs number builder using caller-owned state and an optional unit. struct NumberHarness { state: Entity, diff --git a/crates/moon-ui-components/src/input/input.rs b/crates/moon-ui-components/src/input/input.rs index 2ebeddd..7fce5e0 100644 --- a/crates/moon-ui-components/src/input/input.rs +++ b/crates/moon-ui-components/src/input/input.rs @@ -2,15 +2,18 @@ use std::rc::Rc; use gpui::prelude::FluentBuilder as _; use gpui::{ - AnyElement, App, DefiniteLength, Edges, EdgesRefinement, ElementId, Entity, Hsla, - InteractiveElement as _, IntoElement, MouseButton, ParentElement as _, Pixels, RenderOnce, - StyleRefinement, Styled, TextAlign, Window, div, px, relative, + AnyElement, App, Background, BoxShadow, DefiniteLength, Edges, EdgesRefinement, ElementId, + Entity, Hsla, InteractiveElement as _, IntoElement, MouseButton, ParentElement as _, Pixels, + RenderOnce, StyleRefinement, Styled, TextAlign, Window, div, px, relative, }; use crate::Sizable; use crate::button::{Button, ButtonVariants as _}; use crate::input::clear_button; -use crate::moon::{MoonPalette, MoonTheme, MoonTone, rgba_from}; +use crate::moon::{ + MoonFacet, MoonFacetKind, MoonPalette, MoonTheme, MoonThemeTokens, MoonTone, facet, facet_glow, + rgba_from, +}; use crate::native_menu::NativeMenu; use crate::spinner::Spinner; use crate::{ActiveTheme, v_flex}; @@ -20,15 +23,142 @@ use crate::{Selectable, StyledExt, h_flex}; use super::{InputState, element::EditorScrollbar}; /// Returns `(background, foreground)` colors for input-like components. +/// +/// Under the Facet skin an enabled field is sunk into its surface, so its background is the +/// floor of the skin's well, the palette's `gutter`. A field that can paint the whole well — an +/// input, a select — lays it over this colour. One drawn by mirrored donor code, such as the +/// date field, has only this pair to go by, and still reads as sunk beside them. pub(crate) fn input_style(disabled: bool, cx: &App) -> (Hsla, Hsla) { - let p = MoonPalette::active(cx); + input_style_for(disabled, &MoonTheme::active_tokens(cx)) +} + +/// Resolves [`input_style`] for the theme side `tokens`. +pub(crate) fn input_style_for(disabled: bool, tokens: &MoonThemeTokens) -> (Hsla, Hsla) { + let p = tokens.palette; if disabled { (rgba_from(p.panel, 0.55), rgba_from(p.text_muted, 0.45)) + } else if tokens.skin.is_facet() { + (rgba_from(p.gutter, 1.0), rgba_from(p.text_soft, 1.0)) } else { (rgba_from(p.shell_high, 1.0), rgba_from(p.text_soft, 1.0)) } } +/// The frame an enabled input paints under the Facet skin, in place of its flat fill and border: +/// the skin's well, sunk into the surface the input sits on. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct InputFacet { + /// The well's fill, which the input keeps at rest, under the pointer and in focus. + pub(crate) fill: Background, + /// The colour of the one-pixel edge. + pub(crate) edge: Hsla, + /// The colour of that edge under the pointer. + pub(crate) hover_edge: Hsla, + /// The well's shadows, followed by the focus glow while the input shows its focus. + pub(crate) shadows: Vec, +} + +impl InputFacet { + /// Resolves the frame of an input under the Facet skin. + /// + /// At rest the frame is the skin's well. The edge keeps the colours the flat input gives it + /// where they carry meaning: `accent` at 78% on a selected input, 82% under the pointer, and + /// 92% in focus, where a glow in the same colour joins the well's shadows. An input with + /// neither takes the well's own edge, lit to the skin's hover edge under the pointer. An + /// input in focus keeps its focus edge under the pointer, so the edge never contradicts the + /// glow around it. + /// + /// A disabled input keeps the flat disabled look, so it resolves no frame. + /// + /// Args: + /// tokens: The active theme side; supplies the skin, the palette and the UI scale. + /// accent: The input's tone as `0xRRGGBB`: the selected and focused edge colour. + /// selected: Whether the input is marked selected. + /// focused: Whether the input shows its focus: it has focus and draws a focus edge. + /// disabled: Whether the input is disabled. + /// + /// Returns: + /// The frame to paint, or `None` where the flat frame stands: under the Flat skin and on + /// a disabled input. + pub(crate) fn resolve( + tokens: &MoonThemeTokens, + accent: u32, + selected: bool, + focused: bool, + disabled: bool, + ) -> Option { + if !tokens.skin.is_facet() || disabled { + return None; + } + let well = facet(MoonFacetKind::Well, tokens); + let mut shadows = well.shadows; + let (edge, hover_edge) = if focused { + shadows.push(facet_glow(accent, 0.28, 8.0, tokens)); + let focus = rgba_from(accent, 0.92); + (focus, focus) + } else if selected { + (rgba_from(accent, 0.78), rgba_from(accent, 0.82)) + } else { + // The skin's hover edge is the highlight ink at 20% on a dark palette and the + // palette's own hover border on a light one. + (well.edge, facet(MoonFacetKind::RaisedHover, tokens).edge) + }; + Some(Self { + fill: well.fill, + edge, + hover_edge, + shadows, + }) + } +} + +/// Resolves the frame of a select-like trigger under the Facet skin: at rest, under the +/// pointer and pressed. +/// +/// A trigger styled as a button is a raised control, as the button beside it is. One left with +/// the input look is a well, as the input beside it is, lit along its edge under the pointer +/// and glowing in the Info tone while it shows its focus or holds its menu open. +/// +/// Args: +/// tokens: The active theme side; supplies the skin, the palette and the UI scale. +/// variant: The button variant to preserve, or `None` for the input look. +/// focused: Whether the trigger shows its focus or holds its menu open. +/// disabled: Whether the trigger is disabled. +/// +/// Returns: +/// The three surfaces, or `None` where the flat frame stands: under the Flat skin and on a +/// disabled trigger. +pub(crate) fn trigger_facets( + tokens: &MoonThemeTokens, + variant: Option, + focused: bool, + disabled: bool, +) -> Option<[MoonFacet; 3]> { + if !tokens.skin.is_facet() || disabled { + return None; + } + if let Some(variant) = variant { + use crate::button::ButtonVisualState::{Active, Hovered, Normal}; + return Some([ + variant.resolve_facet(tokens, false, Normal)?, + variant.resolve_facet(tokens, false, Hovered)?, + variant.resolve_facet(tokens, false, Active)?, + ]); + } + let accent = MoonTone::Info.color(tokens.palette); + let frame = InputFacet::resolve(tokens, accent, false, focused, disabled)?; + let surface = |edge: Hsla| MoonFacet { + fill: frame.fill, + edge, + shadows: frame.shadows.clone(), + }; + Some([ + surface(frame.edge), + surface(frame.hover_edge), + surface(frame.hover_edge), + ]) +} + #[derive(Clone, Copy)] struct MoonInputMetrics { height: Pixels, @@ -190,13 +320,13 @@ impl Input { self } - /// Set full height of the input (Multi-line only). + /// Fill the available height for either single-line or multi-line input. pub fn h_full(mut self) -> Self { self.height = Some(relative(1.)); self } - /// Set height of the input (Multi-line only). + /// Override the input height, preserving auto-height only when no height is supplied. pub fn h(mut self, height: impl Into) -> Self { self.height = Some(height.into()); self @@ -361,6 +491,16 @@ impl RenderOnce for Input { } else { bg }; + // Under the Facet skin an enabled input is a well. A code editor keeps the flat frame: + // its background is the editor's own colour, which a well's fill would replace. + let frame = InputFacet::resolve( + &MoonTheme::active_tokens(cx), + accent, + self.selected, + focused && self.focus_bordered && self.bordered, + state.disabled, + ) + .filter(|_| self.appearance && !state.mode.is_code_editor()); let prefix = self.prefix; let suffix = self.suffix; @@ -475,11 +615,9 @@ impl RenderOnce for Input { )) .when(!self.disabled, |this| this.cursor_text()) .items_center() - .when(state.mode.is_multi_line(), |this| { - this.h_auto() - .when_some(self.height, |this, height| this.h(height)) - }) - .when(self.appearance, |this| { + .when(state.mode.is_multi_line(), |this| this.h_auto()) + .when_some(self.height, |this, height| this.h(height)) + .when(self.appearance && frame.is_none(), |this| { this.bg(bg) .rounded(metrics.radius) .when(self.bordered, |this| { @@ -505,6 +643,16 @@ impl RenderOnce for Input { }) }) }) + .when_some(frame, |this, frame| { + let hover_edge = frame.hover_edge; + this.bg(frame.fill) + .rounded(metrics.radius) + .shadow(frame.shadows) + .when(self.bordered, |this| { + this.border_color(frame.edge).border_1() + }) + .hover(move |this| this.border_color(hover_edge)) + }) .items_center() .gap(gap_x) .refine_style(&self.style) diff --git a/crates/moon-ui-components/src/input/state.rs b/crates/moon-ui-components/src/input/state.rs index 872dacf..2d11194 100644 --- a/crates/moon-ui-components/src/input/state.rs +++ b/crates/moon-ui-components/src/input/state.rs @@ -802,20 +802,19 @@ impl InputState { self.mode.diagnostics_mut() } - /// Set placeholder + /// Update the placeholder, invalidating only when the visible hint changes. pub fn set_placeholder( &mut self, placeholder: impl Into, _: &mut Window, cx: &mut Context, ) { - self.placeholder = placeholder.into(); - cx.notify(); + self.sync_placeholder(placeholder, cx); } /// Set the placeholder only when it differs, so a caller that re-applies the same text on /// every render — a translated hint following a live locale switch — does not schedule a - /// redraw each frame. Unlike [`Self::set_placeholder`] it needs no window. + /// redraw each frame. This window-free form also backs [`Self::set_placeholder`]. pub fn sync_placeholder( &mut self, placeholder: impl Into, @@ -1243,13 +1242,15 @@ impl InputState { self.number_max = max; } - /// Set true to show spinner at the input right. + /// Set true to show the spinner at the input right; unchanged values do not invalidate. /// /// Only for [`InputMode::SingleLine`] mode. pub fn set_loading(&mut self, loading: bool, _: &mut Window, cx: &mut Context) { debug_assert!(self.mode.is_single_line()); - self.loading = loading; - cx.notify(); + if self.loading != loading { + self.loading = loading; + cx.notify(); + } } /// Set the default value of the input field. @@ -1753,7 +1754,8 @@ impl InputState { cx.propagate(); } - /// Show the right-click context menu as a native OS menu. + /// Show the right-click menu for this input. Other open popovers do not own this input's + /// menu policy; nested deferred overlays are supported by the window renderer. pub(crate) fn handle_right_click_menu( &mut self, event: &MouseDownEvent, @@ -1761,10 +1763,6 @@ impl InputState { window: &mut Window, cx: &mut Context, ) { - if crate::global_state::GlobalState::global(cx).is_in_deferred_context() { - return; - } - if !self.selected_range.contains(offset) { self.move_to(offset, None, cx); } @@ -1969,6 +1967,7 @@ impl InputState { self.diagnostic_popover = None; } + /// Clamp scrolling to the current content and request a draw only if the offset changes. pub(super) fn update_scroll_offset( &mut self, offset: Option>, @@ -1993,8 +1992,10 @@ impl InputState { offset.y.clamp(safe_y_range.start, safe_y_range.end) }; offset.x = offset.x.clamp(safe_x_range.start, safe_x_range.end); - self.scroll_handle.set_offset(offset); - cx.notify(); + if self.scroll_handle.offset() != offset { + self.scroll_handle.set_offset(offset); + cx.notify(); + } } /// Scroll to make the given offset visible. diff --git a/crates/moon-ui-components/src/list/list.rs b/crates/moon-ui-components/src/list/list.rs index b0380ee..b52d56a 100644 --- a/crates/moon-ui-components/src/list/list.rs +++ b/crates/moon-ui-components/src/list/list.rs @@ -127,9 +127,12 @@ where self } + /// Show or hide search, without invalidating a list whose mode is unchanged. pub fn set_searchable(&mut self, searchable: bool, cx: &mut Context) { - self.searchable = searchable; - cx.notify(); + if self.searchable != searchable { + self.searchable = searchable; + cx.notify(); + } } /// Sets whether the list is selectable, default is true. diff --git a/crates/moon-ui-components/src/list/list/tests.rs b/crates/moon-ui-components/src/list/list/tests.rs index 5768930..a49f6c7 100644 --- a/crates/moon-ui-components/src/list/list/tests.rs +++ b/crates/moon-ui-components/src/list/list/tests.rs @@ -16,6 +16,42 @@ use crate::{ list::{ListDelegate, ListItem, cache::RowEntry}, }; +/// Reapplying Select/ComboBox search mode must not publish another list change. Switching +/// the mode must still publish, otherwise the search field remains stale until another event. +#[gpui::test] +fn search_mode_publishes_only_transitions(cx: &mut gpui::TestAppContext) { + cx.update(crate::init); + let slot = Rc::new(RefCell::new(None)); + cx.add_window({ + let slot = slot.clone(); + move |window, cx| { + let list = cx.new(|cx| { + ListState::new( + OptionalChromeDelegate { + item_renders: Rc::new(Cell::new(0)), + }, + window, + cx, + ) + }); + slot.replace(Some(list.clone())); + OptionalChromeHarness { list } + } + }); + let list = slot.borrow().clone().expect("list was created"); + let notifications = Rc::new(Cell::new(0)); + let _subscription = cx.update(|cx| { + cx.observe(&list, { + let notifications = notifications.clone(); + move |_, _| notifications.set(notifications.get() + 1) + }) + }); + for (searchable, expected) in [(false, 0), (true, 1), (true, 1), (false, 2), (false, 2)] { + cx.update(|cx| list.update(cx, |state, cx| state.set_searchable(searchable, cx))); + assert_eq!(notifications.get(), expected); + } +} + /// Delegate with a headerless global section, one headed section, and a large trailing section. struct OptionalChromeDelegate { /// Number of item elements constructed across measurement and visible rendering. diff --git a/crates/moon-ui-components/src/moon/badge.rs b/crates/moon-ui-components/src/moon/badge.rs index 22c83a5..184ed99 100644 --- a/crates/moon-ui-components/src/moon/badge.rs +++ b/crates/moon-ui-components/src/moon/badge.rs @@ -6,6 +6,7 @@ use gpui::*; use super::{ foundation::MoonSize, icons::moon_icon, + tag::facet_outline_wash, theme::{MoonTheme, MoonThemeTokens}, tokens::{MoonPalette, MoonRect, MoonTone, rgba_from}, }; @@ -236,11 +237,15 @@ impl MoonBadge { } /// Render with the supplied palette and tokens, scaling text and chrome exactly once. + /// + /// Under the Facet skin of `tokens`, an outline badge carries the tone wash of + /// [`Self::outline_wash`] in place of its flat fill. pub fn render_with_theme(self, p: MoonPalette, tokens: MoonThemeTokens) -> impl IntoElement { let metrics = self.metrics(&tokens); let style = self.style(p); let disabled_scale = if self.disabled { 0.5 } else { 1.0 }; let border_alpha = style.border_alpha * disabled_scale; + let wash = self.outline_wash(style, disabled_scale, &tokens); let is_dot = matches!(self.content, MoonBadgeContent::Dot); let mut badge = div() @@ -258,6 +263,7 @@ impl MoonBadge { .justify_center() .rounded(px(metrics.radius)) .bg(rgba_from(style.bg, style.bg_alpha * disabled_scale)) + .when_some(wash, |this, wash| this.bg(wash)) .whitespace_nowrap() .when(border_alpha > 0.0, |this| { this.border(px(1.0)) @@ -353,6 +359,32 @@ impl MoonBadge { } } + /// Resolve the wash an outline badge paints in place of its flat fill. + /// + /// The wash takes the colour of the resolved fill, so a caller's fill colour still tints + /// it. A caller who set the fill opacity asked for that exact fill and gets no wash. + /// + /// Args: + /// style: This badge's resolved style. + /// strength: The share both ends of the wash are scaled by: the same factor a disabled + /// badge fades its flat fill by, 1.0 otherwise. + /// tokens: The theme side to resolve for; supplies the skin. + /// + /// Returns: + /// The wash for an outline badge under the Facet skin. `None` under the Flat skin, for + /// the solid and soft variants, and when the fill opacity was set explicitly. + fn outline_wash( + &self, + style: BadgeStyle, + strength: f32, + tokens: &MoonThemeTokens, + ) -> Option { + let washed = tokens.skin.is_facet() + && self.variant == MoonBadgeVariant::Outline + && self.bg_alpha.is_none(); + washed.then(|| facet_outline_wash(style.bg, strength)) + } + fn style(&self, p: MoonPalette) -> BadgeStyle { let tone = self.tone.color(p); let defaults = match self.variant { diff --git a/crates/moon-ui-components/src/moon/badge/tests.rs b/crates/moon-ui-components/src/moon/badge/tests.rs index c3a7cac..8f3d64e 100644 --- a/crates/moon-ui-components/src/moon/badge/tests.rs +++ b/crates/moon-ui-components/src/moon/badge/tests.rs @@ -1,6 +1,9 @@ -//! Badge density, tier and custom-scaling regression coverage. -use super::super::{foundation::MoonSize, theme::MoonThemeTokens}; -use super::{MoonBadge, MoonBadgeSize}; +//! Badge density, tier, custom-scaling and Facet-skin regression coverage. +use super::super::{ + MoonPalette, MoonSkin, MoonTone, foundation::MoonSize, rgba_from, theme::MoonThemeTokens, +}; +use super::{MoonBadge, MoonBadgeSize, MoonBadgeVariant}; +use gpui::{linear_color_stop, linear_gradient}; /// Catches selecting control height or legacy font scaling: badges must stay text-height at every density. #[test] @@ -63,3 +66,170 @@ fn badge_custom_preserves_legacy_scaling_and_fit() { (28.0, 20.0, 25.0, 6.0) ); } + +/// Theme tokens painting `palette` with `skin` at the reference scale. +fn tokens_with(palette: MoonPalette, skin: MoonSkin) -> MoonThemeTokens { + MoonThemeTokens { + skin, + palette, + ..MoonThemeTokens::default() + } +} + +/// The wash of `color` from `top` opacity along the top edge to `bottom` along the bottom +/// edge, written out here rather than taken from the code under test. +fn wash(color: u32, top: f32, bottom: f32) -> gpui::Background { + linear_gradient( + 180.0, + linear_color_stop(rgba_from(color, top), 0.0), + linear_color_stop(rgba_from(color, bottom), 1.0), + ) +} + +/// Catches `badge.rs:outline_wash` losing the wash of an outline badge on the faceted theme, +/// tinting it with the wrong colour, or ignoring the strength a faded badge passes. The oracle +/// is the depth study's outlined chip: the tone at 14% along the top down to 4%. +#[test] +fn an_outline_badge_under_the_facet_skin_is_washed_with_its_tone() { + for palette in [MoonPalette::OBSIDIAN, MoonPalette::LIGHT] { + let tokens = tokens_with(palette, MoonSkin::Facet); + let tone = MoonTone::Warning.color(palette); + let badge = || { + MoonBadge::new("3") + .variant(MoonBadgeVariant::Outline) + .tone(MoonTone::Warning) + }; + + let plain = badge(); + assert_eq!( + plain.outline_wash(plain.style(palette), 1.0, &tokens), + Some(wash(tone, 0.14, 0.04)) + ); + + // Rendering passes the factor a disabled badge fades its flat fill by; both ends of + // the wash follow it. + assert_eq!( + plain.outline_wash(plain.style(palette), 0.5, &tokens), + Some(wash(tone, 0.07, 0.02)) + ); + + // A caller's fill colour still tints the badge. + let recoloured = badge().bg_color(palette.blue); + assert_eq!( + recoloured.outline_wash(recoloured.style(palette), 1.0, &tokens), + Some(wash(palette.blue, 0.14, 0.04)) + ); + } +} + +/// Catches `badge.rs:MoonBadge::render_with_theme` painting a wash on a flat theme, not +/// painting the wash it resolves on the faceted one, leaving a disabled badge's wash at full +/// strength, or washing over a soft badge or a fill the caller set. The flat colours are the +/// flat design's own: the tone at 8% under a 27% border, both halved when disabled. +#[test] +fn only_the_outline_badge_of_the_faceted_theme_paints_a_wash() { + use super::super::MoonThemeConfig; + use super::super::button::scene_probe::{Painted, Pointer, painted}; + + let p = MoonPalette::OBSIDIAN; + let tone = MoonTone::Warning.color(p); + let obsidian_with = |skin: MoonSkin| { + let mut config = MoonThemeConfig::moon_obsidian(); + config.dark.skin = skin; + config + }; + let outline = || { + MoonBadge::new("3") + .variant(MoonBadgeVariant::Outline) + .tone(MoonTone::Warning) + }; + let flat_outline = Painted::flat(rgba_from(tone, 0.08), rgba_from(tone, 0.27)); + + assert_eq!( + painted(obsidian_with(MoonSkin::Flat), Pointer::Away, outline), + flat_outline + ); + assert_eq!( + painted(obsidian_with(MoonSkin::Flat), Pointer::Away, move || { + outline().disabled(true) + }), + Painted::flat(rgba_from(tone, 0.04), rgba_from(tone, 0.135)) + ); + + assert_eq!( + painted(obsidian_with(MoonSkin::Facet), Pointer::Away, outline), + Painted { + fill: Some(wash(tone, 0.14, 0.04)), + ..flat_outline.clone() + } + ); + assert_eq!( + painted(obsidian_with(MoonSkin::Facet), Pointer::Away, move || { + outline().disabled(true) + }), + Painted { + fill: Some(wash(tone, 0.07, 0.02)), + border: Some(rgba_from(tone, 0.135)), + shadows: Vec::new(), + }, + "a disabled badge halves the wash with the rest of its paint" + ); + + assert_eq!( + painted(obsidian_with(MoonSkin::Facet), Pointer::Away, move || { + outline().bg_alpha(0.3) + }), + Painted::flat(rgba_from(tone, 0.3), rgba_from(tone, 0.27)), + "an explicit fill opacity is painted as given" + ); + assert_eq!( + painted(obsidian_with(MoonSkin::Facet), Pointer::Away, || { + MoonBadge::new("3").tone(MoonTone::Warning) + }), + Painted::flat(rgba_from(tone, 0.14), rgba_from(tone, 0.0)), + "a soft badge keeps its own fill" + ); +} + +/// Catches `badge.rs:outline_wash` answering for the Flat skin, which would repaint the +/// outline badges of every existing theme, for a solid or soft badge, whose own fill it would +/// replace, or for a badge whose caller set the fill opacity and must get exactly that fill. +#[test] +fn flat_solid_soft_and_explicitly_filled_badges_get_no_wash() { + for palette in [MoonPalette::OBSIDIAN, MoonPalette::LIGHT] { + let faceted = tokens_with(palette, MoonSkin::Facet); + let flat = tokens_with(palette, MoonSkin::Flat); + let wash_of = |badge: MoonBadge, tokens: &MoonThemeTokens| { + badge.outline_wash(badge.style(palette), 1.0, tokens) + }; + + for variant in [ + MoonBadgeVariant::Solid, + MoonBadgeVariant::Soft, + MoonBadgeVariant::Outline, + ] { + assert_eq!( + wash_of(MoonBadge::new("3").variant(variant), &flat), + None, + "{variant:?} is washed under the Flat skin" + ); + } + for variant in [MoonBadgeVariant::Solid, MoonBadgeVariant::Soft] { + assert_eq!( + wash_of(MoonBadge::new("3").variant(variant), &faceted), + None, + "{variant:?} keeps its own fill under the Facet skin" + ); + } + assert_eq!( + wash_of( + MoonBadge::new("3") + .variant(MoonBadgeVariant::Outline) + .bg_alpha(0.3), + &faceted + ), + None, + "an explicit fill opacity is kept as given" + ); + } +} diff --git a/crates/moon-ui-components/src/moon/button.rs b/crates/moon-ui-components/src/moon/button.rs index 71a751e..cc22090 100644 --- a/crates/moon-ui-components/src/moon/button.rs +++ b/crates/moon-ui-components/src/moon/button.rs @@ -123,6 +123,10 @@ pub(super) fn native_variant(variant: MoonButtonVariant, cx: &App) -> ButtonVari /// Build the native custom variant that paints a [`MoonButtonVariant::Solid`] button. /// +/// The variant also names the fill as its facet tone, so under the Facet skin the native button +/// paints it as a solid facet of the accent instead of the flat colours; the label is the same +/// under both skins. +/// /// Args: /// p: Palette the button paints with. /// cx: Application context the native custom variant is seeded from. @@ -136,7 +140,38 @@ fn solid_variant(p: MoonPalette, cx: &App) -> ButtonVariant { .color(rgba_from(colors.fill, 1.0)) .hover(rgba_from(colors.fill, colors.hover_alpha)) .active(rgba_from(colors.fill, colors.active_alpha)) - .foreground(rgba_from(colors.label, 1.0)), + .foreground(rgba_from(colors.label, 1.0)) + .facet_solid(colors.fill), + ) +} + +/// Resolve the ink for text or a glyph a caller coloured `color`, on a raised faceted control. +/// +/// A raised facet is lit to `panel_high` along its top, where `text_muted` falls under the AA +/// contrast floor; `text_soft` is the quiet ink of a raised control. Under the Flat skin, and +/// for every other colour, the caller's colour is kept. +/// +/// Args: +/// color: The caller's colour as `0xRRGGBB`. +/// tokens: The active theme side; supplies the skin and the palette. +/// +/// Returns: +/// `text_soft` in place of `text_muted` under the Facet skin, `color` otherwise. +pub(super) fn raised_ink(color: u32, tokens: &MoonThemeTokens) -> u32 { + let p = tokens.palette; + if tokens.skin.is_facet() && color == p.text_muted { + p.text_soft + } else { + color + } +} + +/// Return whether `variant` is painted as a raised facet under the Facet skin, the surface +/// [`raised_ink`] applies to. +fn is_raised_variant(variant: MoonButtonVariant) -> bool { + matches!( + variant, + MoonButtonVariant::Neutral | MoonButtonVariant::Panel | MoonButtonVariant::Soft ) } @@ -377,15 +412,22 @@ impl MoonButtonIconSlot { /// Args: /// cx: Application context used to resolve the active Moon scale. /// fallback: Unscaled glyph edge used when [`Self::size`] was not set. + /// raised: Whether the glyph sits on a raised faceted button, where [`raised_ink`] + /// picks the ink for the slot's colour; `false` keeps the colour as given. /// /// Returns: /// The configured icon with scaling, colour, alpha, and any non-zero rotation applied. - fn icon(self, cx: &App, fallback: f32) -> Icon { + fn icon(self, cx: &App, fallback: f32, raised: bool) -> Icon { let tokens = MoonTheme::active_tokens(cx); let mut icon = Icon::default() .path(self.path) .size(px(tokens.ui(self.size.unwrap_or(fallback)))); if let Some(color) = self.color { + let color = if raised { + raised_ink(color, &tokens) + } else { + color + }; icon = icon.text_color(rgba_from_u32(color, self.alpha)); } if self.rotation != 0.0 { @@ -676,6 +718,9 @@ impl RenderOnce for MoonButton { MoonButtonSize::Tier(tier) => MoonButtonSize::tier_icon_size(tier), MoonButtonSize::Custom { .. } => 12.0, }; + // Whether caller-coloured segments and icons go through `raised_ink`: a disabled button + // keeps the flat disabled look on every skin, and with it the caller's colours. + let raised = is_raised_variant(self.variant) && !self.disabled; let mut button = Button::new(self.id) .with_variant(native_variant(self.variant, cx)) .selected(self.selected) @@ -730,10 +775,10 @@ impl RenderOnce for MoonButton { pair => pair, }; if let Some(icon) = leading_icon { - button = button.icon(icon.icon(cx, icon_fallback)); + button = button.icon(icon.icon(cx, icon_fallback, raised)); } if let Some(icon) = self.loading_icon { - button = button.loading_icon(icon.icon(cx, icon_fallback)); + button = button.loading_icon(icon.icon(cx, icon_fallback, raised)); } if let Some(tooltip) = self.tooltip { button = button.tooltip(tooltip); @@ -791,6 +836,11 @@ impl RenderOnce for MoonButton { .when(mono, |this| this.font_family(tokens.font_family(true))) .child(segment.text); if let Some(color) = segment.color { + let color = if raised { + raised_ink(color, &tokens) + } else { + color + }; text = text.text_color(rgba_from_u32(color, segment.alpha)); } text.into_any_element() @@ -801,7 +851,7 @@ impl RenderOnce for MoonButton { // segments, or a leading icon beside it. Only the trailing-ONLY case is absent here, // because the promotion above moved that icon into the leading slot. .when_some(trailing_icon, |this, icon| { - this.child(icon.icon(cx, icon_fallback)) + this.child(icon.icon(cx, icon_fallback, raised)) }) } } @@ -929,5 +979,9 @@ fn rgba_from_u32(color: u32, alpha: f32) -> Hsla { color } +// Test support shared with the other controls the Facet skin restyles (selector pill, tag, +// badge, kbd): it reads what a control painted, which their sibling tests need as well. +#[cfg(test)] +pub(super) mod scene_probe; #[cfg(test)] mod tests; diff --git a/crates/moon-ui-components/src/moon/button/scene_probe.rs b/crates/moon-ui-components/src/moon/button/scene_probe.rs new file mode 100644 index 0000000..2a4157f --- /dev/null +++ b/crates/moon-ui-components/src/moon/button/scene_probe.rs @@ -0,0 +1,345 @@ +//! Test support for the controls the Facet skin restyles: draws one control in a headless +//! window and reports what was painted for it. +//! +//! A unit test can resolve the surface a control should have, but only the scene shows whether +//! `render` painted it, and whether a flat theme still paints what it always has. The probe +//! swaps the headless renderer for a recorder, draws the control with the pointer away from +//! it, over it or pressing it, and reads the control's own fill, border and shadows back out +//! of the recorded scene. +//! +//! Nothing here is compiled outside tests, and nothing is public: the items are visible to the +//! Moon modules only. + +use std::{ + borrow::Cow, + cell::{Cell, RefCell}, + rc::Rc, + sync::Arc, +}; + +use gpui::{ + AnyElement, AnyWindowHandle, AppContext as _, AtlasKey, AtlasTile, Background, Bounds, + BoxShadow, Context, DevicePixels, HeadlessAppContext, Hsla, IntoElement, Modifiers, + MouseButton, MouseDownEvent, MouseMoveEvent, NoopTextSystem, ParentElement as _, Pixels, + PlatformAtlas, PlatformHeadlessRenderer, PlatformInput, Quad, Render, ScaledPixels, Scene, + Shadow, Size, Styled as _, Window, div, point, px, size, +}; + +use crate::moon::{MoonFacet, MoonTheme, MoonThemeConfig}; + +/// Distance from the window's top-left corner to the probed control, in pixels: room for the +/// control's shadows and glow, so that none of them is clipped by the window. +const MARGIN: f32 = 60.0; + +/// How far a recorded edge may sit from the laid-out one and still be the same edge, in device +/// pixels. Painting snaps bounds to whole device pixels; layout does not. +const SNAP_TOLERANCE: f32 = 1.0; + +/// Frames drawn after the window opens and after each pointer event, so that state a frame +/// hands to the next one, such as a hover or a press, has reached the scene that is read. +const SETTLE_FRAMES: usize = 3; + +/// Where the pointer is while the probed control is drawn. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(in crate::moon) enum Pointer { + /// Outside the control: its resting look. + Away, + /// Over the middle of the control, with no button held. + Over, + /// Over the middle of the control, with the left button held down. + Pressed, +} + +/// What a control painted for itself, as opposed to what its children painted. +#[derive(Clone, Debug, PartialEq)] +pub(in crate::moon) struct Painted { + /// The control's fill. `None` when it painted no fill, or a fully transparent one. + pub(in crate::moon) fill: Option, + /// The colour of the control's border. `None` when it painted no border. + pub(in crate::moon) border: Option, + /// The control's own shadows in reference pixels: the ones inside it first, then the ones + /// outside it, each group in the order it was painted. This is the order a + /// [`MoonFacet`](crate::moon::MoonFacet) lists its shadow stack in. + pub(in crate::moon) shadows: Vec, +} + +impl Painted { + /// What a control paints for a faceted surface: its fill, its edge as the border and its + /// whole shadow stack. + pub(in crate::moon) fn facet(surface: MoonFacet) -> Self { + Self { + fill: Some(surface.fill), + border: Some(surface.edge), + shadows: surface.shadows, + } + } + + /// What a control paints for a flat fill and border with no shadow. A fully transparent + /// colour is read back as nothing painted. + pub(in crate::moon) fn flat(fill: Hsla, border: Hsla) -> Self { + Self { + fill: (!fill.is_transparent()).then(|| Background::from(fill)), + border: (!border.is_transparent()).then_some(border), + shadows: Vec::new(), + } + } +} + +/// Draw the control `build` returns under the theme `config` and report what it painted. +/// +/// The control is the only child of a window-sized row and keeps its own size. Each call opens +/// its own headless application, so calls do not share a theme or a pointer. +/// +/// Args: +/// config: The theme to install before the control is drawn. +/// pointer: Where the pointer is while the control is drawn. +/// build: Builds the control; called once per frame. +/// +/// Returns: +/// The fill, border colour and shadows of the control's own box. +/// +/// Panics: +/// When the headless window cannot be opened or drawn, or the control was never laid out. +pub(in crate::moon) fn painted( + config: MoonThemeConfig, + pointer: Pointer, + build: impl Fn() -> E + 'static, +) -> Painted { + let scene = Rc::new(RefCell::new(RecordedScene::default())); + let recorded = scene.clone(); + let mut cx = HeadlessAppContext::with_platform( + Arc::new(NoopTextSystem::new()), + Arc::new(()), + move || Some(Box::new(SceneRecorder(recorded.clone())) as Box), + ); + cx.update(crate::init); + cx.update(|cx| MoonTheme::install_config(config, cx)); + + let bounds = Rc::new(Cell::new(Bounds::default())); + let window: AnyWindowHandle = cx + .open_window(size(px(480.0), px(240.0)), { + let bounds = bounds.clone(); + move |_, cx| { + cx.new(|_| ProbeHost { + build: Box::new(move || build().into_any_element()), + bounds, + }) + } + }) + .expect("headless window must open") + .into(); + settle(&mut cx, window); + + let control = bounds.get(); + assert!( + control.size.width > px(0.0) && control.size.height > px(0.0), + "the probed control was never laid out: {control:?}" + ); + let middle = control.center(); + if pointer != Pointer::Away { + send( + &mut cx, + window, + PlatformInput::MouseMove(MouseMoveEvent { + position: middle, + pressed_button: None, + modifiers: Modifiers::default(), + }), + ); + } + if pointer == Pointer::Pressed { + send( + &mut cx, + window, + PlatformInput::MouseDown(MouseDownEvent { + button: MouseButton::Left, + position: middle, + modifiers: Modifiers::default(), + click_count: 1, + first_mouse: false, + }), + ); + } + + let scale = cx + .update_window(window, |_, window, _| window.scale_factor()) + .expect("headless window must report its scale"); + // The recorder only fires on a render pass, and its "image" is an error by design. + let _ = cx.capture_screenshot(window); + scene.borrow().painted_for(control, scale) +} + +/// Dispatch `event` to the window and let the frames that follow it settle. +fn send(cx: &mut HeadlessAppContext, window: AnyWindowHandle, event: PlatformInput) { + cx.update_window(window, |_, window, cx| { + window.dispatch_event(event, cx); + }) + .expect("headless window must take input"); + settle(cx, window); +} + +/// Draw [`SETTLE_FRAMES`] frames. +fn settle(cx: &mut HeadlessAppContext, window: AnyWindowHandle) { + for _ in 0..SETTLE_FRAMES { + cx.update_window(window, |_, window, cx| { + window.refresh(); + window.draw(cx).clear(); + }) + .expect("headless frame must draw"); + cx.run_until_parked(); + } +} + +/// The root view of a probe window: one control, start-aligned so the row does not stretch it, +/// and the bounds it was laid out at. +struct ProbeHost { + build: Box AnyElement>, + bounds: Rc>>, +} + +impl Render for ProbeHost { + /// Render the control [`MARGIN`] in from the window's top-left corner and record its bounds. + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + let sink = self.bounds.clone(); + div() + .size_full() + .flex() + .flex_row() + .items_start() + .justify_start() + .p(px(MARGIN)) + .on_children_prepainted(move |bounds, _, _| { + if let Some(control) = bounds.first() { + sink.set(*control); + } + }) + .child((self.build)()) + } +} + +/// The quads and shadows of the last rendered scene. +#[derive(Default)] +struct RecordedScene { + quads: Vec, + shadows: Vec, +} + +impl RecordedScene { + /// Pick out what was painted for the box laid out at `control`. + /// + /// A fill and a border are separate quads over the same box. A child that fills its parent + /// exactly would be read as the parent; none of the probed controls has one. + /// + /// Args: + /// control: The control's laid-out bounds. + /// scale: The window's scale factor, which the recorded primitives are multiplied by. + /// + /// Returns: + /// The fill, border colour and shadows painted over exactly that box. + fn painted_for(&self, control: Bounds, scale: f32) -> Painted { + let target = control.scale(scale); + let own_quads = || { + self.quads + .iter() + .filter(|quad| same_box(quad.bounds, target)) + }; + let fill = own_quads() + .map(|quad| quad.background) + .find(|background| !background.is_transparent()); + let border = own_quads() + .find(|quad| { + let widths = quad.border_widths; + widths.top.0 + widths.right.0 + widths.bottom.0 + widths.left.0 > 0.0 + && !quad.border_color.is_transparent() + }) + .map(|quad| quad.border_color); + + let own_shadows = |inset: bool| { + self.shadows + .iter() + .filter(move |shadow| { + (shadow.inset != 0) == inset && same_box(shadow.element_bounds, target) + }) + .map(move |shadow| box_shadow_of(shadow, scale)) + }; + Painted { + fill, + border, + shadows: own_shadows(true).chain(own_shadows(false)).collect(), + } + } +} + +/// Return whether two recorded boxes are the same box, to within [`SNAP_TOLERANCE`]. +fn same_box(a: Bounds, b: Bounds) -> bool { + let near = |x: ScaledPixels, y: ScaledPixels| (x.0 - y.0).abs() <= SNAP_TOLERANCE; + near(a.origin.x, b.origin.x) + && near(a.origin.y, b.origin.y) + && near(a.size.width, b.size.width) + && near(a.size.height, b.size.height) +} + +/// Turn a recorded shadow back into the `BoxShadow` it was painted from, in reference pixels. +/// +/// A recorded shadow keeps its own rectangle and its element's: the offset is how far the one +/// was moved from the other, and the spread is how far it was grown, or for a shadow inside +/// its element how far its hole was shrunk. +fn box_shadow_of(shadow: &Shadow, scale: f32) -> BoxShadow { + let grown = (shadow.bounds.size.width.0 - shadow.element_bounds.size.width.0) / 2.0; + let spread = if shadow.inset != 0 { -grown } else { grown }; + let middle = |bounds: Bounds| bounds.center(); + let moved = middle(shadow.bounds) - middle(shadow.element_bounds); + BoxShadow { + color: shadow.color, + offset: point(px(moved.x.0 / scale), px(moved.y.0 / scale)), + blur_radius: px(shadow.blur_radius.0 / scale), + spread_radius: px(spread / scale), + inset: shadow.inset != 0, + } +} + +/// Records the primitives a headless window submits, without rasterizing them. +struct SceneRecorder(Rc>); + +impl PlatformHeadlessRenderer for SceneRecorder { + /// Record the scene; image output is intentionally unsupported. + fn render_scene_to_image( + &mut self, + scene: &Scene, + size: Size, + ) -> anyhow::Result { + self.render_scene(scene, size)?; + anyhow::bail!("the scene recorder does not rasterize images") + } + + /// Replace the preceding frame, so a probe reads the final submission only. + fn render_scene(&mut self, scene: &Scene, _size: Size) -> anyhow::Result<()> { + self.0.replace(RecordedScene { + quads: scene.quads.clone(), + shadows: scene.shadows.clone(), + }); + Ok(()) + } + + /// Fills and shadows do not depend on glyph or icon textures. + fn sprite_atlas(&self) -> Arc { + Arc::new(NoSpriteAtlas) + } +} + +/// Omits sprites from the scene: the probe reads quads and shadows only. +struct NoSpriteAtlas; + +impl PlatformAtlas for NoSpriteAtlas { + /// Decline every sprite. + fn get_or_insert_with<'a>( + &self, + _key: &AtlasKey, + _build: &mut dyn FnMut() -> anyhow::Result, Cow<'a, [u8]>)>>, + ) -> anyhow::Result> { + Ok(None) + } + + /// Nothing was stored, so there is nothing to remove. + fn remove(&self, _key: &AtlasKey) {} +} diff --git a/crates/moon-ui-components/src/moon/button/tests.rs b/crates/moon-ui-components/src/moon/button/tests.rs index eb9fa43..20250c9 100644 --- a/crates/moon-ui-components/src/moon/button/tests.rs +++ b/crates/moon-ui-components/src/moon/button/tests.rs @@ -1,12 +1,16 @@ //! Regression coverage for Moon button sizing and icon-slot rendering. -use super::super::{MoonDisclosureDirection, MoonPalette, MoonSize}; +use super::super::{ + MoonDisclosureDirection, MoonFacetKind, MoonFacetTint, MoonPalette, MoonSize, MoonSkin, + MoonThemeConfig, facet, facet_tinted, rgba_from, +}; +use super::scene_probe::{Painted, Pointer, painted}; use super::{ MoonButton, MoonButtonIconSlot, MoonButtonSize, MoonButtonVariant, MoonTheme, MoonThemeTokens, - SOLID_LABEL_CONTRAST_FLOOR, button_text_metrics, contrast_ratio, solid_colors, - tier_button_metrics, + SOLID_LABEL_CONTRAST_FLOOR, button_text_metrics, contrast_ratio, native_variant, raised_ink, + solid_colors, tier_button_metrics, }; -use crate::button::ButtonVariant; +use crate::button::{ButtonVariant, ButtonVisualState}; /// Catches ignoring `button.rs:MoonButton::width` or `full_width` during rendering, which would /// make fixed controls use content width or prevent full-row actions from filling their parent. @@ -546,3 +550,363 @@ fn existing_variants_keep_their_native_mapping() { ButtonVariant::Primary ); } + +// --- Facet skin ----------------------------------------------------------------------------- + +/// The Obsidian theme with its dark side drawn in `skin`: the same palette and scale on both +/// skins, so two renders differ by the skin alone. +fn obsidian_with(skin: MoonSkin) -> MoonThemeConfig { + let mut config = MoonThemeConfig::moon_obsidian(); + config.dark.skin = skin; + config +} + +/// Catches `button.rs:solid_variant` dropping its facet tone, which would leave the primary +/// button a flat accent slab among the lit controls of the faceted theme, and catches that +/// tone being honoured on a flat theme, where the solid button must not change. +#[gpui::test] +fn solid_button_is_a_solid_accent_facet_on_the_faceted_theme_only(cx: &mut gpui::TestAppContext) { + cx.update(crate::init); + + cx.update(|cx| MoonTheme::install_config(MoonThemeConfig::moon_obsidian(), cx)); + cx.update(|cx| { + let tokens = MoonTheme::active_tokens(cx); + let variant = native_variant(MoonButtonVariant::Solid, cx); + assert_eq!( + variant.resolve_facet(&tokens, false, ButtonVisualState::Normal), + Some(facet_tinted( + tokens.palette.accent, + MoonFacetTint::Solid, + &tokens + )) + ); + }); + + for flat in [ + MoonThemeConfig::moon_terminal(), + MoonThemeConfig::moon_light(), + obsidian_with(MoonSkin::Flat), + ] { + cx.update(|cx| MoonTheme::install_config(flat, cx)); + cx.update(|cx| { + let tokens = MoonTheme::active_tokens(cx); + let variant = native_variant(MoonButtonVariant::Solid, cx); + assert_eq!( + variant.resolve_facet(&tokens, false, ButtonVisualState::Normal), + None + ); + }); + } +} + +/// Catches the solid label, which is the same ink under both skins, dropping under the AA +/// floor on the faceted fill. That fill runs from the accent lifted a quarter of the way to +/// white down to the accent sunk by 6%; both ends are recomposited here. +#[test] +fn solid_label_clears_the_contrast_floor_on_both_ends_of_the_faceted_fill() { + let palette = MoonPalette::OBSIDIAN; + let solid = solid_colors(palette); + let top = blend(0xFFFFFF, palette.accent, 0.25); + let bottom = blend(0x000000, palette.accent, 0.06); + + for fill in [top, bottom] { + let ratio = contrast_ratio(solid.label, fill); + assert!( + ratio >= SOLID_LABEL_CONTRAST_FLOOR, + "solid label {:06X} on faceted {fill:06X}: {ratio:.2}", + solid.label + ); + } +} + +/// Catches `button.rs:raised_ink` keeping muted ink on a raised facet, where it falls under +/// the AA floor on the lit top of the control, and catches it rewriting any other colour or +/// touching a flat theme, where callers must get exactly the colour they passed. +#[test] +fn muted_ink_on_a_raised_facet_is_lifted_to_the_soft_ink() { + let palette = MoonPalette::OBSIDIAN; + let tokens = |skin: MoonSkin| MoonThemeTokens { + skin, + palette, + ..MoonThemeTokens::default() + }; + let faceted = tokens(MoonSkin::Facet); + + let lifted = raised_ink(palette.text_muted, &faceted); + assert_eq!(lifted, palette.text_soft); + // The reason for the rule, measured on the lit top of a raised facet. + assert!(contrast_ratio(palette.text_muted, palette.panel_high) < 4.5); + assert!(contrast_ratio(lifted, palette.panel_high) >= 4.5); + + for color in [palette.text, palette.text_soft, palette.accent, palette.red] { + assert_eq!(raised_ink(color, &faceted), color); + } + assert_eq!( + raised_ink(palette.text_muted, &tokens(MoonSkin::Flat)), + palette.text_muted + ); +} + +/// Builds one button for a probe or a layout harness. +type Build = fn() -> MoonButton; + +/// The neutral button every skin test starts from. +fn neutral() -> MoonButton { + MoonButton::new("b").label("Order") +} + +/// Catches `button.rs:Button::render` painting anything new on a flat theme: a facet fill, a +/// facet edge or a shadow where the flat button has none. The Obsidian palette is drawn here +/// with the Flat skin, and every colour is the flat design's own: the dark neutral fill, the +/// palette roles and the alphas, written out rather than read back from the button. +#[test] +fn a_flat_theme_paints_every_button_state_as_it_did_before_the_facet_skin() { + /// The neutral button's flat fill on a dark palette, which is not a palette entry. + const NEUTRAL_DARK_FILL: u32 = 0x1F2126; + let p = MoonPalette::OBSIDIAN; + let solid = solid_colors(p); + let flat = |fill: (u32, f32), border: (u32, f32)| { + Painted::flat(rgba_from(fill.0, fill.1), rgba_from(border.0, border.1)) + }; + let cases: [(&str, Pointer, Build, Painted); 9] = [ + ( + "resting neutral", + Pointer::Away, + neutral, + flat((NEUTRAL_DARK_FILL, 1.0), (p.border, 1.0)), + ), + ( + "hovered neutral", + Pointer::Over, + neutral, + flat((p.overlay, 0.10), (p.border_hover, 1.0)), + ), + ( + "pressed neutral", + Pointer::Pressed, + neutral, + flat((p.overlay, 0.06), (p.border_hover, 1.0)), + ), + ( + "selected neutral", + Pointer::Away, + || neutral().selected(true), + flat((p.accent, p.accent_tint_a), (p.accent, 1.0)), + ), + ( + "disabled neutral", + Pointer::Away, + || neutral().disabled(true), + flat((p.panel, 0.32), (p.border, 0.42)), + ), + ( + "resting blue", + Pointer::Away, + || neutral().primary(), + flat((p.blue, 0.10), (p.blue, 0.22)), + ), + ( + "hovered blue", + Pointer::Over, + || neutral().primary(), + flat((p.blue, 0.18), (p.blue, 0.42)), + ), + ( + "resting solid", + Pointer::Away, + || neutral().variant(MoonButtonVariant::Solid), + flat((solid.fill, 1.0), (solid.fill, 1.0)), + ), + ( + "hovered solid", + Pointer::Over, + || neutral().variant(MoonButtonVariant::Solid), + flat((solid.fill, solid.hover_alpha), (solid.fill, 1.0)), + ), + ]; + + let mismatches = paint_mismatches(MoonSkin::Flat, cases); + assert!( + mismatches.is_empty(), + "under the Flat skin:\n{}", + mismatches.join("\n") + ); +} + +/// Draw each case's button under the Obsidian palette with `skin` and describe every one that +/// did not paint what was expected, so one run names all the states that drifted. +fn paint_mismatches( + skin: MoonSkin, + cases: [(&str, Pointer, Build, Painted); N], +) -> Vec { + cases + .into_iter() + .filter_map(|(name, pointer, build, expected)| { + let got = painted(obsidian_with(skin), pointer, build); + (got != expected) + .then(|| format!("the {name} button painted {got:?}, not {expected:?}")) + }) + .collect() +} + +/// Catches `button.rs:Button::render` resolving a state's facet and then not painting it, or +/// painting the facet of another state: a faceted button that stays flat at rest, does not +/// relight under the pointer, or keeps its drop shadow while pressed. Also catches a disabled +/// or ghost button picking up a surface, and a loading one losing its fade. +#[test] +fn the_faceted_theme_paints_each_button_state_with_its_own_facet() { + let p = MoonPalette::OBSIDIAN; + let tokens = MoonThemeTokens { + skin: MoonSkin::Facet, + palette: p, + ..MoonThemeTokens::default() + }; + let raised = facet(MoonFacetKind::Raised, &tokens); + let top_to_bottom = |top: u32, bottom: u32| { + gpui::linear_gradient( + 180.0, + gpui::linear_color_stop(rgba_from(top, 1.0), 0.0), + gpui::linear_color_stop(rgba_from(bottom, 1.0), 1.0), + ) + }; + assert_eq!( + raised.fill, + top_to_bottom(p.panel_high, p.panel), + "the resting surface the cases below start from is a gradient" + ); + let soft_blue = facet_tinted(p.blue, MoonFacetTint::Soft, &tokens); + + let cases: [(&str, Pointer, Build, Painted); 11] = [ + ( + "resting neutral", + Pointer::Away, + neutral, + Painted::facet(raised.clone()), + ), + ( + "hovered neutral", + Pointer::Over, + neutral, + Painted::facet(facet(MoonFacetKind::RaisedHover, &tokens)), + ), + ( + "pressed neutral", + Pointer::Pressed, + neutral, + Painted { + fill: Some(top_to_bottom(p.panel, p.panel_high)), + border: Some(raised.edge), + shadows: raised + .shadows + .iter() + .filter(|shadow| shadow.inset) + .cloned() + .collect(), + }, + ), + ( + "selected neutral", + Pointer::Away, + || neutral().selected(true), + Painted::facet(facet_tinted(p.accent, MoonFacetTint::Selected, &tokens)), + ), + ( + "disabled neutral", + Pointer::Away, + || neutral().disabled(true), + Painted::flat(rgba_from(p.panel, 0.32), rgba_from(p.border, 0.42)), + ), + ( + "resting blue", + Pointer::Away, + || neutral().primary(), + Painted::facet(soft_blue.clone()), + ), + ( + "hovered blue", + Pointer::Over, + || neutral().primary(), + Painted::facet(facet_tinted(p.blue, MoonFacetTint::Selected, &tokens)), + ), + ( + "loading blue", + Pointer::Away, + || neutral().primary().loading(true), + Painted { + fill: Some(soft_blue.fill.opacity(0.8)), + border: Some(soft_blue.edge.opacity(0.8)), + shadows: soft_blue.shadows.clone(), + }, + ), + ( + "resting danger", + Pointer::Away, + || neutral().danger(), + Painted::facet(facet_tinted(p.red, MoonFacetTint::Soft, &tokens)), + ), + ( + "resting solid", + Pointer::Away, + || neutral().variant(MoonButtonVariant::Solid), + Painted::facet(facet_tinted(p.accent, MoonFacetTint::Solid, &tokens)), + ), + ( + "hovered ghost", + Pointer::Over, + || neutral().ghost(), + Painted::flat(rgba_from(p.overlay, 0.10), rgba_from(p.border_hover, 1.0)), + ), + ]; + + let mismatches = paint_mismatches(MoonSkin::Facet, cases); + assert!( + mismatches.is_empty(), + "under the Facet skin:\n{}", + mismatches.join("\n") + ); +} + +/// Catches the Facet skin changing a button's box, by adding a border, padding or a second +/// element, and catches a render path that only the faceted theme takes panicking: the same +/// button must occupy the same pixels on a flat theme and on the faceted one. +#[gpui::test] +fn the_facet_skin_leaves_every_button_box_unchanged(cx: &mut gpui::TestAppContext) { + let builds: [(&str, Build); 9] = [ + ("neutral", || MoonButton::new("b").label("Order")), + ("panel", || { + MoonButton::new("b") + .label("Order") + .variant(MoonButtonVariant::Panel) + }), + ("blue", || MoonButton::new("b").label("Order").primary()), + ("danger", || MoonButton::new("b").label("Order").danger()), + ("solid", || { + MoonButton::new("b") + .label("Order") + .variant(MoonButtonVariant::Solid) + }), + ("ghost", || MoonButton::new("b").label("Order").ghost()), + ("selected neutral", || { + MoonButton::new("b").label("Order").selected(true) + }), + ("disabled neutral", || { + MoonButton::new("b").label("Order").disabled(true) + }), + ("loading blue", || { + MoonButton::new("b").label("Order").primary().loading(true) + }), + ]; + + cx.update(crate::init); + for (name, build) in builds { + cx.update(|cx| MoonTheme::install_config(obsidian_with(MoonSkin::Flat), cx)); + let flat = laid_out_bounds(cx, build); + cx.update(|cx| MoonTheme::install_config(obsidian_with(MoonSkin::Facet), cx)); + let faceted = laid_out_bounds(cx, build); + + assert_eq!( + faceted, flat, + "the {name} button moved under the Facet skin" + ); + } +} diff --git a/crates/moon-ui-components/src/moon/checkbox/tests.rs b/crates/moon-ui-components/src/moon/checkbox/tests.rs index 8774597..07f1aba 100644 --- a/crates/moon-ui-components/src/moon/checkbox/tests.rs +++ b/crates/moon-ui-components/src/moon/checkbox/tests.rs @@ -99,3 +99,151 @@ fn compact_checkbox_metrics_are_strictly_below_standard() { assert!(xs.mark_size < sm.mark_size); assert!(xs.mark_stroke < sm.mark_stroke); } + +/// An opaque gradient from `top` at the top edge to `bottom` at the bottom edge, written out +/// rather than taken from the skin. +fn top_to_bottom(top: u32, bottom: u32) -> gpui::Background { + use crate::moon::rgba_from; + use gpui::{linear_color_stop, linear_gradient}; + + linear_gradient( + 180.0, + linear_color_stop(rgba_from(top, 1.0), 0.0), + linear_color_stop(rgba_from(bottom, 1.0), 1.0), + ) +} + +/// Catches `checkbox.rs:ChoiceColors::box_facet` ignoring the skin or the disabled flag: every +/// flat theme's checkboxes and radios would be repainted as wells and lit tones, or a disabled +/// box on the faceted theme would lose the flat fill it dims to half. With no facet resolved, +/// a box paints `ChoiceColors::resolve` alone, exactly as it did before skins existed. +#[test] +fn flat_themes_and_disabled_boxes_resolve_no_facet() { + use crate::checkbox::ChoiceColors; + use crate::moon::{MoonThemeConfig, MoonThemeTokens, MoonTone}; + + let flat_sides = [ + MoonThemeConfig::moon_terminal().dark, + MoonThemeConfig::moon_terminal().light, + MoonThemeConfig::moon_graphite().dark, + MoonThemeConfig::moon_obsidian().light, + MoonThemeTokens::default(), + ]; + for tokens in &flat_sides { + for tone in [None, Some(MoonTone::Warning)] { + for checked in [false, true] { + for disabled in [false, true] { + assert_eq!( + ChoiceColors::box_facet(tokens, tone, checked, disabled), + None, + "a flat theme never resolves a facet" + ); + } + } + } + } + + let faceted = MoonThemeConfig::moon_obsidian().dark; + for checked in [false, true] { + assert_eq!( + ChoiceColors::box_facet(&faceted, None, checked, true), + None, + "a disabled box keeps the flat colours" + ); + } +} + +/// Catches a faceted box drifting from its spec: unchecked, it is the skin's well, darkest at +/// its top and edged in the highlight ink at 6%; checked, it is the accent lit from above — +/// lifted 25% at the top, sunk 6% at the bottom, edged in the accent sunk 20% — unless a tone is +/// set, which then replaces the accent. Reading a soft tint for the checked box, or the accent +/// for a toned one, would leave the mark's ink on a fill it was not chosen for. +#[test] +fn a_faceted_box_is_a_well_until_checked_and_then_its_tone_lit_from_above() { + use crate::checkbox::ChoiceColors; + use crate::moon::{ + MoonFacetKind, MoonFacetTint, MoonThemeConfig, MoonTone, facet, facet_tinted, rgba_from, + }; + + let tokens = MoonThemeConfig::moon_obsidian().dark; + let p = tokens.palette; + // `gutter` 0x08090B sunk by 15%. + const WELL_TOP: u32 = 0x070809; + // The accent 0xFFB347 lifted by 25%, sunk by 6% and sunk by 20%. + const ACCENT_TOP: u32 = 0xFFC675; + const ACCENT_BOTTOM: u32 = 0xF0A843; + const ACCENT_EDGE: u32 = 0xCC8F39; + // The Info tone, `blue` 0x7FC9FF, sunk by 20%. + const BLUE_EDGE: u32 = 0x66A1CC; + + let unchecked = ChoiceColors::box_facet(&tokens, None, false, false).expect("faceted"); + assert_eq!(unchecked, facet(MoonFacetKind::Socket, &tokens)); + assert_eq!(unchecked.fill, top_to_bottom(WELL_TOP, p.gutter)); + assert_eq!(unchecked.edge, rgba_from(p.overlay, 0.17)); + assert_eq!( + ChoiceColors::box_facet(&tokens, Some(MoonTone::Info), false, false), + Some(unchecked), + "a tone colours only a checked box" + ); + + let checked = ChoiceColors::box_facet(&tokens, None, true, false).expect("faceted"); + assert_eq!( + checked, + facet_tinted(p.accent, MoonFacetTint::Solid, &tokens) + ); + assert_eq!(checked.fill, top_to_bottom(ACCENT_TOP, ACCENT_BOTTOM)); + assert_eq!(checked.edge, rgba_from(ACCENT_EDGE, 1.0)); + + let toned = + ChoiceColors::box_facet(&tokens, Some(MoonTone::Info), true, false).expect("faceted"); + assert_eq!(toned, facet_tinted(p.blue, MoonFacetTint::Solid, &tokens)); + assert_eq!(toned.edge, rgba_from(BLUE_EDGE, 1.0)); +} + +/// A checked small checkbox, shrunk to its content so the probes measure the control. +struct FacetedCheckboxHarness; + +impl gpui::Render for FacetedCheckboxHarness { + fn render( + &mut self, + _: &mut gpui::Window, + _: &mut gpui::Context, + ) -> impl gpui::IntoElement { + use gpui::ParentElement as _; + + crate::h_flex().child( + super::MoonCheckbox::new("faceted") + .size(MoonSize::Sm) + .checked(true), + ) + } +} + +/// Catches the faceted box changing the checkbox's geometry — a facet applied with a border of +/// another width, or the box resized to the design mock-up's: the mark is placed from the box's +/// 1px border, so under the faceted theme the box must keep its 16px and the 12px mark must stay +/// centred in it, exactly as under a flat theme. +#[gpui::test] +fn a_faceted_checkbox_keeps_its_box_and_centred_mark(cx: &mut gpui::TestAppContext) { + use crate::moon::{MoonTheme, MoonThemeConfig}; + use gpui::{px, size}; + + cx.update(crate::init); + for (config, faceted) in [ + (MoonThemeConfig::moon_terminal(), false), + (MoonThemeConfig::moon_obsidian(), true), + ] { + cx.update(|cx| MoonTheme::install_config(config, cx)); + cx.update(|cx| assert_eq!(MoonTheme::active_tokens(cx).skin.is_facet(), faceted)); + + let window = cx.add_window(|_, _| FacetedCheckboxHarness); + let mut cx = gpui::VisualTestContext::from_window(window.into(), cx); + cx.run_until_parked(); + + let box_bounds = cx.debug_bounds("faceted:box").expect("box must render"); + let mark = cx.debug_bounds("faceted:mark").expect("mark must render"); + assert_eq!(box_bounds.size, size(px(16.), px(16.)), "faceted={faceted}"); + assert_eq!(mark.size, size(px(12.), px(12.)), "faceted={faceted}"); + assert_eq!(mark.center(), box_bounds.center(), "faceted={faceted}"); + } +} diff --git a/crates/moon-ui-components/src/moon/collapsible.rs b/crates/moon-ui-components/src/moon/collapsible.rs index ed495b3..9db752c 100644 --- a/crates/moon-ui-components/src/moon/collapsible.rs +++ b/crates/moon-ui-components/src/moon/collapsible.rs @@ -3,8 +3,9 @@ use gpui::*; use super::{ disclosure::MoonDisclosure, + skin::{MoonFacet, MoonFacetKind, facet}, text::MoonText, - theme::MoonTheme, + theme::{MoonTheme, MoonThemeTokens}, tokens::{MoonRect, rgba_from}, }; @@ -25,6 +26,26 @@ fn moon_collapsible_header_click_next( } } +/// Resolve the surfaces a collapsible header paints under the Facet skin. +/// +/// A faceted header is a raised control whether the section is closed or open: the caret alone +/// says which. Under the pointer it relights as any raised control does. +/// +/// Args: +/// tokens: Active theme tokens; supply the skin, the palette and the UI scale. +/// +/// Returns: +/// The header's surface at rest and under the pointer, or `None` under the Flat skin, where +/// the header keeps its flat fill and its open-dependent border. +fn collapsible_header_facet(tokens: &MoonThemeTokens) -> Option<(MoonFacet, MoonFacet)> { + tokens.skin.is_facet().then(|| { + ( + facet(MoonFacetKind::Raised, tokens), + facet(MoonFacetKind::RaisedHover, tokens), + ) + }) +} + #[derive(IntoElement)] pub struct MoonCollapsible { id: SharedString, @@ -156,16 +177,28 @@ impl RenderOnce for MoonCollapsible { .h(px(tokens.ui(self.header_height))) .rounded(px(tokens.ui(4.0))) .border(px(tokens.ui(1.0))) - .border_color(rgba_from(p.border, if open { 1.0 } else { 0.74 })) - .bg(rgba_from(p.shell_high, if open { 0.98 } else { 0.72 })) .flex() .items_center() .gap(px(tokens.ui(8.0))) .px(px(tokens.ui(8.0))) .cursor_default() - .when(!disabled, |this| { - this.hover(|this| this.bg(rgba_from(p.overlay, 0.055))) - .active(|this| this.bg(rgba_from(p.overlay, 0.035))) + // `None` is the Flat skin. A faceted header is a raised control in both states. + .map(|this| match collapsible_header_facet(&tokens) { + Some((raised, hover)) => this + .border_color(raised.edge) + .bg(raised.fill) + .shadow(raised.shadows) + .when(!disabled, |this| { + this.hover(move |this| this.bg(hover.fill).border_color(hover.edge)) + .active(move |this| this.bg(raised.fill).border_color(raised.edge)) + }), + None => this + .border_color(rgba_from(p.border, if open { 1.0 } else { 0.74 })) + .bg(rgba_from(p.shell_high, if open { 0.98 } else { 0.72 })) + .when(!disabled, |this| { + this.hover(|this| this.bg(rgba_from(p.overlay, 0.055))) + .active(|this| this.bg(rgba_from(p.overlay, 0.035))) + }), }) .on_mouse_down(MouseButton::Left, { let state = state.clone(); diff --git a/crates/moon-ui-components/src/moon/collapsible/tests.rs b/crates/moon-ui-components/src/moon/collapsible/tests.rs index 3017fb3..09a3e6c 100644 --- a/crates/moon-ui-components/src/moon/collapsible/tests.rs +++ b/crates/moon-ui-components/src/moon/collapsible/tests.rs @@ -73,3 +73,50 @@ fn the_header_caret_is_wired_to_the_live_open_value() { "the header caret must never be built from a literal pose" ); } + +// ---- Theme skin: the surface of the header ---- + +use super::collapsible_header_facet; +use crate::moon::{MoonFacetKind, MoonPalette, MoonSkin, MoonThemeTokens, facet}; + +/// Theme tokens for `palette` drawn with `skin`, at the default scale. +fn skin_tokens(palette: MoonPalette, skin: MoonSkin) -> MoonThemeTokens { + MoonThemeTokens { + palette, + skin, + ..MoonThemeTokens::default() + } +} + +/// Catches `collapsible.rs:collapsible_header_facet` ignoring the skin: every flat theme's +/// section header would trade its `shell_high` fill and open-dependent border for a raised +/// gradient control. +#[test] +fn a_flat_collapsible_header_keeps_its_flat_fill() { + for palette in [ + MoonPalette::TERMINAL, + MoonPalette::LIGHT, + MoonPalette::OBSIDIAN, + ] { + assert_eq!( + collapsible_header_facet(&skin_tokens(palette, MoonSkin::Flat)), + None + ); + } +} + +/// Catches `collapsible.rs:collapsible_header_facet` swapping the two surfaces, or resolving a +/// sunken one: the header would be lit at rest and go dark under the pointer, or read as an +/// input field instead of something to click. +#[test] +fn a_faceted_collapsible_header_is_raised_and_relights_under_the_pointer() { + let tokens = skin_tokens(MoonPalette::OBSIDIAN, MoonSkin::Facet); + + assert_eq!( + collapsible_header_facet(&tokens), + Some(( + facet(MoonFacetKind::Raised, &tokens), + facet(MoonFacetKind::RaisedHover, &tokens), + )) + ); +} diff --git a/crates/moon-ui-components/src/moon/color_picker.rs b/crates/moon-ui-components/src/moon/color_picker.rs index 964651d..bc17b20 100644 --- a/crates/moon-ui-components/src/moon/color_picker.rs +++ b/crates/moon-ui-components/src/moon/color_picker.rs @@ -10,6 +10,7 @@ use super::{ MOON_SCROLLBAR_TRACK, MoonScrollAxis, MoonScrollbarVisibility, moon_scrollbar_overlay_with_palette, }, + skin::{MoonFacet, MoonFacetKind, facet}, text::MoonText, theme::{MoonTheme, MoonThemeTokens}, tokens::{MoonPalette, MoonTone, rgba_from}, @@ -345,6 +346,29 @@ impl MoonColorPicker { } } +/// Resolves the faceted frame of the [`MoonColorPickerTrigger::Field`] trigger. +/// +/// Under the Facet skin an enabled field is the skin's well, as a text input is, and its edge +/// takes the skin's hover edge under the pointer. A disabled field keeps the flat frame, dimmed +/// as before, so it resolves nothing. +/// +/// Args: +/// tokens: The active theme side; supplies the skin, the palette and the UI scale. +/// disabled: Whether the picker is disabled. +/// +/// Returns: +/// The well to paint and the colour of its edge under the pointer, or `None` where the flat +/// frame stands: under the Flat skin and on a disabled picker. +fn field_facet(tokens: &MoonThemeTokens, disabled: bool) -> Option<(MoonFacet, Hsla)> { + if !tokens.skin.is_facet() || disabled { + return None; + } + Some(( + facet(MoonFacetKind::Well, tokens), + facet(MoonFacetKind::RaisedHover, tokens).edge, + )) +} + /// Render a colour as the `#RRGGBB` text the hex field shows and [`parse_hex_rgb`] accepts back. fn hex_label(color: Hsla) -> SharedString { let [r, g, b] = rgb8_of(color); @@ -408,6 +432,7 @@ impl RenderOnce for MoonColorPicker { .uppercase(false) .render(); let trigger_id = ElementId::from(SharedString::from(format!("{}:trigger", self.id))); + let field_well = field_facet(&tokens, self.disabled); let trigger = match chrome.field_height { Some(height) => div() .id(trigger_id) @@ -425,9 +450,17 @@ impl RenderOnce for MoonColorPicker { .gap(px(tokens.ui(8.0))) .px(px(tokens.ui(7.0))) .cursor_default() - .when(!self.disabled, |this| { + .when(!self.disabled && field_well.is_none(), |this| { this.hover(move |this| this.border_color(rgba_from(p.border_hover, 1.0))) }) + // Under the Facet skin an enabled field is a well in place of the flat frame + // above; the swatch inside it is left as it is. + .when_some(field_well, |this, (well, hover_edge)| { + this.bg(well.fill) + .border_color(well.edge) + .shadow(well.shadows) + .hover(move |this| this.border_color(hover_edge)) + }) .child( div() .w(px(chrome.swatch_w)) diff --git a/crates/moon-ui-components/src/moon/color_picker/tests.rs b/crates/moon-ui-components/src/moon/color_picker/tests.rs index be9a983..885f856 100644 --- a/crates/moon-ui-components/src/moon/color_picker/tests.rs +++ b/crates/moon-ui-components/src/moon/color_picker/tests.rs @@ -1,10 +1,10 @@ //! Regression coverage for the Moon colour-picker hex field and reuse palette. use super::{ - MAX_CUSTOM_COLORS, MoonColorPickerTrigger, TriggerChrome, hex_label, parse_hex_rgb, - push_all_custom, push_custom, rgb8_of, trigger_chrome, + MAX_CUSTOM_COLORS, MoonColorPickerTrigger, TriggerChrome, field_facet, hex_label, + parse_hex_rgb, push_all_custom, push_custom, rgb8_of, trigger_chrome, }; -use crate::moon::{MoonScale, MoonThemeTokens}; +use crate::moon::{MoonFacetKind, MoonScale, MoonThemeConfig, MoonThemeTokens, facet, rgba_from}; use gpui::{Hsla, hsla, rgb}; /// Builds an opaque colour from its independently chosen six-digit RGB value. @@ -177,3 +177,40 @@ fn swatch_trigger_follows_the_ui_scale() { let swatch = trigger_chrome(&tokens, MoonColorPickerTrigger::Swatch, None); assert_eq!((swatch.swatch_w, swatch.swatch_h), (42.0, 30.0)); } + +/// Catches `color_picker.rs:field_facet` ignoring the skin or the disabled flag: the field of +/// every flat theme's picker would be repainted as a well, or a disabled field on the faceted +/// theme would lose its dimmed flat frame. With nothing resolved, the field paints the flat +/// border and fill it always had. +#[test] +fn flat_themes_and_disabled_fields_resolve_no_well() { + for tokens in [ + MoonThemeConfig::moon_terminal().dark, + MoonThemeConfig::moon_terminal().light, + MoonThemeConfig::moon_obsidian().light, + MoonThemeTokens::default(), + ] { + for disabled in [false, true] { + assert_eq!(field_facet(&tokens, disabled), None); + } + } + assert_eq!( + field_facet(&MoonThemeConfig::moon_obsidian().dark, true), + None, + "a disabled field keeps the flat frame" + ); +} + +/// Catches the faceted field drifting from the text input it sits beside: enabled, it is the +/// skin's well — edged in the highlight ink at 6%, not the 9% of a raised control — and its edge +/// rises to that ink at 20% under the pointer. +#[test] +fn a_faceted_field_is_a_well_that_lights_its_edge_under_the_pointer() { + let tokens = MoonThemeConfig::moon_obsidian().dark; + let p = tokens.palette; + + let (well, hover_edge) = field_facet(&tokens, false).expect("faceted"); + assert_eq!(well, facet(MoonFacetKind::Well, &tokens)); + assert_eq!(well.edge, rgba_from(p.overlay, 0.06)); + assert_eq!(hover_edge, rgba_from(p.overlay, 0.2)); +} diff --git a/crates/moon-ui-components/src/moon/data_table.rs b/crates/moon-ui-components/src/moon/data_table.rs index 3fb60bc..352f9c7 100644 --- a/crates/moon-ui-components/src/moon/data_table.rs +++ b/crates/moon-ui-components/src/moon/data_table.rs @@ -15,7 +15,7 @@ use super::{ moon_horizontal_track_is_drawn, moon_scrollbar_overlay_with_palette, }, table::{MoonTableAlign, MoonTableCell, MoonTableColumn, MoonTableRow, MoonTableStyle}, - theme::MoonTheme, + theme::{MoonTheme, MoonThemeTokens}, tokens::{MoonPalette, MoonRect, MoonTone, rgba_from}, virtual_list::{MoonVirtualList, MoonVirtualListScrollHandle}, }; @@ -61,6 +61,29 @@ type MoonDataContextMenuBuilder = /// Minimum logical column width used by drag resizing and render-time fitting. const MIN_COLUMN_WIDTH: f32 = 40.0; +/// Alpha of the `overlay` wash a faceted table row shows under the pointer. +const FACET_ROW_HOVER_ALPHA: f32 = 0.04; + +/// Resolve the fill a table row shows under the pointer under the Facet skin. +/// +/// A faceted table lets the surface under it show through, so its hover is a translucent wash +/// over that surface rather than an opaque row colour. +/// +/// # Arguments +/// +/// * `tokens` - Active theme tokens; supply the skin and the palette. +/// +/// # Returns +/// +/// The hover fill, or `None` under the Flat skin, where the row keeps the theme's `table_hover` +/// role. +fn facet_row_hover(tokens: &MoonThemeTokens) -> Option { + tokens + .skin + .is_facet() + .then(|| rgba_from(tokens.palette.overlay, FACET_ROW_HOVER_ALPHA)) +} + /// Return whether one keystroke is the platform's exact Select All shortcut. /// /// # Arguments @@ -1356,7 +1379,11 @@ impl RenderOnce for MoonDataTable { .w_full() .h(px(row_height)) .cursor_pointer() - .hover(|this| this.bg(cx.theme().table_hover)) + // `None` is the Flat skin, which keeps the theme's opaque hover role. + .map(|this| match facet_row_hover(&tokens) { + Some(hover) => this.hover(move |this| this.bg(hover)), + None => this.hover(|this| this.bg(cx.theme().table_hover)), + }) .on_click({ let on_select_row = on_select_row.clone(); move |event, window, cx| { @@ -1432,6 +1459,10 @@ impl RenderOnce for MoonDataTable { style.header_separator, style.header_separator_alpha, )) + // The gutter's edge continues the header's rule under the Facet skin. + .when_some(style.header_facet(p, &tokens), |this, facet| { + this.border_color(facet.separator) + }) .bg(row_header_bg) .child((ix + 1).to_string()) .on_click(move |_, window, cx| { @@ -1502,13 +1533,22 @@ impl RenderOnce for MoonDataTable { .top(px(0.0)) .w(px(row_header_width)) .h(px(header_height)) - .bg(rgba_from(style.header_bg, 1.0)) - .border_b(px(1.0)) - .border_r(px(1.0)) - .border_color(rgba_from( - style.header_separator, - style.header_separator_alpha, - )), + // The corner is a piece of the header and follows it under the Facet skin. + .map(|this| match style.header_facet(p, &tokens) { + Some(facet) => this + .when_some(facet.fill, |this, fill| this.bg(fill)) + .border_b(px(1.0)) + .border_r(px(1.0)) + .border_color(facet.separator), + None => this + .bg(rgba_from(style.header_bg, 1.0)) + .border_b(px(1.0)) + .border_r(px(1.0)) + .border_color(rgba_from( + style.header_separator, + style.header_separator_alpha, + )), + }), ); } diff --git a/crates/moon-ui-components/src/moon/data_table/header.rs b/crates/moon-ui-components/src/moon/data_table/header.rs index a471af1..f76e344 100644 --- a/crates/moon-ui-components/src/moon/data_table/header.rs +++ b/crates/moon-ui-components/src/moon/data_table/header.rs @@ -67,12 +67,20 @@ pub(super) fn render_header( .h(px(height)) .flex() .items_center() - .bg(rgba_from(style.header_bg, 1.0)) - .border_b(px(1.0)) - .border_color(rgba_from( - style.header_separator, - style.header_separator_alpha, - )); + // `None` is the Flat skin; see `MoonTableStyle::header_facet` for the faceted header. + .map(|this| match style.header_facet(p, &tokens) { + Some(facet) => this + .when_some(facet.fill, |this, fill| this.bg(fill)) + .border_b(px(1.0)) + .border_color(facet.separator), + None => this + .bg(rgba_from(style.header_bg, 1.0)) + .border_b(px(1.0)) + .border_color(rgba_from( + style.header_separator, + style.header_separator_alpha, + )), + }); // Which column's resize strip the pointer is over. GPUI applies hover styles at paint time // only, so the mark's hover form (taller and wider) cannot come from `hover`/`group_hover`; diff --git a/crates/moon-ui-components/src/moon/data_table/tests.rs b/crates/moon-ui-components/src/moon/data_table/tests.rs index 481e716..39cab14 100644 --- a/crates/moon-ui-components/src/moon/data_table/tests.rs +++ b/crates/moon-ui-components/src/moon/data_table/tests.rs @@ -624,3 +624,87 @@ fn resize_mark_alpha_is_denser_only_on_the_near_black_header() { assert_eq!(resize_mark_alpha(MoonPalette::GRAPHITE.table_head), 0.55); assert_eq!(resize_mark_alpha(MoonPalette::LIGHT.table_head), 0.55); } + +// ---- Theme skin: what the header and a hovered row paint ---- + +use super::facet_row_hover; +use crate::moon::{MoonSkin, MoonTableStyle, MoonThemeTokens, rgba_from}; + +/// Theme tokens for `palette` drawn with `skin`, at the default scale. +fn skin_tokens(palette: MoonPalette, skin: MoonSkin) -> MoonThemeTokens { + MoonThemeTokens { + palette, + skin, + ..MoonThemeTokens::default() + } +} + +/// Catches `table.rs:MoonTableStyle::header_facet` or `data_table.rs:facet_row_hover` ignoring +/// the skin: every flat theme's table would lose its header fill and its opaque `table_hover` +/// row, which the render keeps only while these answer `None`. +#[test] +fn a_flat_table_keeps_its_header_fill_and_theme_row_hover() { + for palette in [ + MoonPalette::TERMINAL, + MoonPalette::LIGHT, + MoonPalette::GRAPHITE, + MoonPalette::OBSIDIAN, + ] { + let tokens = skin_tokens(palette, MoonSkin::Flat); + assert_eq!( + MoonTableStyle::for_palette(palette).header_facet(palette, &tokens), + None + ); + assert_eq!(facet_row_hover(&tokens), None); + } +} + +/// Catches `table.rs:MoonTableStyle::header_facet` keeping the palette's header fill or its +/// solid separator under the Facet skin, and `data_table.rs:facet_row_hover` keeping an opaque +/// hover: the table would sit on its panel as a flat block that hides the slab under it. +#[test] +fn a_faceted_table_drops_the_header_fill_and_hovers_with_a_wash() { + let p = MoonPalette::OBSIDIAN; + let tokens = skin_tokens(p, MoonSkin::Facet); + + // The route `MoonDataTable::render` takes: a style built for the terminal palette, themed. + let style = MoonTableStyle::default().themed(p); + let header = style + .header_facet(p, &tokens) + .expect("the Facet skin resolves a header facet"); + assert_eq!(header.fill, None, "the header paints no fill of its own"); + assert_eq!(header.separator, rgba_from(p.overlay, 0.07)); + + assert_eq!(facet_row_hover(&tokens), Some(rgba_from(p.overlay, 0.04))); +} + +/// Catches `table.rs:MoonTableStyle::header_facet` discarding a colour the caller chose: a +/// table styled with its own header fill or separator would silently lose it under one theme. +#[test] +fn a_faceted_table_keeps_a_header_colour_the_caller_chose() { + let p = MoonPalette::OBSIDIAN; + let tokens = skin_tokens(p, MoonSkin::Facet); + const OWN_FILL: u32 = 0x123456; + const OWN_SEPARATOR: u32 = 0x654321; + + let own_fill = MoonTableStyle { + header_bg: OWN_FILL, + ..MoonTableStyle::for_palette(p) + }; + let header = own_fill + .header_facet(p, &tokens) + .expect("the Facet skin resolves a header facet"); + assert_eq!(header.fill, Some(rgba_from(OWN_FILL, 1.0))); + assert_eq!(header.separator, rgba_from(p.overlay, 0.07)); + + let own_separator = MoonTableStyle { + header_separator: OWN_SEPARATOR, + header_separator_alpha: 0.5, + ..MoonTableStyle::for_palette(p) + }; + let header = own_separator + .header_facet(p, &tokens) + .expect("the Facet skin resolves a header facet"); + assert_eq!(header.fill, None); + assert_eq!(header.separator, rgba_from(OWN_SEPARATOR, 0.5)); +} diff --git a/crates/moon-ui-components/src/moon/dock.rs b/crates/moon-ui-components/src/moon/dock.rs index 7082f6d..18a77da 100644 --- a/crates/moon-ui-components/src/moon/dock.rs +++ b/crates/moon-ui-components/src/moon/dock.rs @@ -15,6 +15,7 @@ use serde::{Deserialize, Serialize}; use super::{ background::MoonBackgroundPolicy, + skin::{MoonFacetKind, MoonSkin, facet}, text::MoonText, theme::{MoonTheme, MoonThemeTokens}, tokens::{MoonPalette, rgba_from}, @@ -39,7 +40,56 @@ pub use state::{ }; use tab_panel::tab_header_height; +/// What a floating tile paints under the Facet skin: the surface of the tab group inside it. +/// +/// A tile whose panel is opaque is a popup: gradient fill, edge and the shadow that lifts it off +/// the dock. A tile whose panel is see-through keeps the edge and the highlight along its top +/// only — GPUI paints a shadow that falls outside an element under the whole of it, so on an +/// unfilled tile it would darken the canvas the panel exists to show. +#[derive(Clone, Debug, PartialEq)] +struct TileFacet { + /// Corner radius of the tile, in rendered pixels. + radius: f32, + /// Fill of the tile; `None` leaves it unpainted. + fill: Option, + /// Colour of the tile's one-pixel edge. + edge: Hsla, + /// Shadows of the tile. + shadows: Vec, +} + +impl TileFacet { + /// Resolve the faceted look of a floating tile. + /// + /// Args: + /// tokens: Active theme tokens; supply the skin, the palette and the UI scale. + /// policy: Background policy of the panel the tile hosts. + /// + /// Returns: + /// The faceted look, or `None` under the Flat skin, where the tile renders as it always + /// has. + fn resolve(tokens: &MoonThemeTokens, policy: MoonBackgroundPolicy) -> Option { + if !tokens.skin.is_facet() { + return None; + } + let popup = facet(MoonFacetKind::Popup, tokens); + let opaque = matches!(policy, MoonBackgroundPolicy::Opaque); + Some(Self { + radius: tokens.ui(tokens.metrics.container_radius), + fill: opaque.then_some(popup.fill), + edge: popup.edge, + shadows: popup + .shadows + .into_iter() + .filter(|shadow| opaque || shadow.inset) + .collect(), + }) + } +} + const DOCK_RESIZE_HIT_SIZE: f32 = 6.0; +/// Alpha of the `overlay` wash a faceted resize strip shows under the pointer. +const FACET_RESIZE_HOVER_ALPHA: f32 = 0.04; /// Smallest gap between two repaints while a dock handle is being dragged. /// @@ -183,6 +233,9 @@ pub struct TabPanel { close_allowed: bool, pinned_leading_panels: Vec, panel_control_tooltips: DockPanelControlTooltips, + /// Whether a floating tile hosts this tab group and draws its surface. Read only under the + /// Facet skin, where the tile carries the fill, the edge and the shadow of the group. + floating: bool, } impl TabPanel { @@ -205,6 +258,7 @@ impl TabPanel { close_allowed: true, pinned_leading_panels: Vec::new(), panel_control_tooltips: DockPanelControlTooltips::default(), + floating: false, } } @@ -268,6 +322,12 @@ impl TabPanel { self } + /// Mark this tab group as the content of a floating tile, which draws the group's surface. + fn floating(mut self, floating: bool) -> Self { + self.floating = floating; + self + } + fn dock_context( mut self, dock_area: WeakEntity, @@ -1484,17 +1544,19 @@ impl DockArea { Some(canonical) } + /// Change a side root's visibility and synchronize panel activity before the hidden tree stops rendering. pub fn set_dock_open( &mut self, placement: DockPlacement, open: bool, + window: &mut Window, cx: &mut Context, ) -> bool { - let slot = match placement { + let (root, slot) = match placement { DockPlacement::Center => return false, - DockPlacement::Left => self.left.as_mut(), - DockPlacement::Right => self.right.as_mut(), - DockPlacement::Bottom => self.bottom.as_mut(), + DockPlacement::Left => (DockRoot::Left, self.left.as_mut()), + DockPlacement::Right => (DockRoot::Right, self.right.as_mut()), + DockPlacement::Bottom => (DockRoot::Bottom, self.bottom.as_mut()), }; let Some((_, _, current_open)) = slot else { return false; @@ -1503,19 +1565,39 @@ impl DockArea { return false; } *current_open = open; + // A zoomed panel remains the sole visible surface regardless of normal side-root flags. + if self + .zoomed_panel + .as_ref() + .and_then(|name| self.find_panel_named(name.as_ref(), cx)) + .is_none() + && let Some(item) = self.root_item(root) + { + if open { + self.sync_item_active(root, &[], item, window, cx); + } else { + self.sync_item_inactive(root, &[], item, window, cx); + } + } cx.emit(DockEvent::LayoutChanged); cx.notify(); true } - pub fn toggle_dock(&mut self, placement: DockPlacement, cx: &mut Context) -> bool { + /// Toggle a side root through the same visibility and activity transition as an explicit change. + pub fn toggle_dock( + &mut self, + placement: DockPlacement, + window: &mut Window, + cx: &mut Context, + ) -> bool { let open = match placement { DockPlacement::Center => return false, DockPlacement::Left => self.left.as_ref().map(|(_, _, open)| !*open), DockPlacement::Right => self.right.as_ref().map(|(_, _, open)| !*open), DockPlacement::Bottom => self.bottom.as_ref().map(|(_, _, open)| !*open), }; - open.map(|open| self.set_dock_open(placement, open, cx)) + open.map(|open| self.set_dock_open(placement, open, window, cx)) .unwrap_or(false) } @@ -1607,6 +1689,9 @@ impl DockArea { } /// Render a pointer resize handle, or no element while structural editing is locked. + /// + /// Under the Flat skin the handle is a filled strip with a line along its middle. Under the + /// Facet skin it is the unpainted gap between two slabs, of the same size and grab area. fn resize_handle( &self, id: SharedString, @@ -1618,6 +1703,10 @@ impl DockArea { return Empty.into_any_element(); } let p = MoonPalette::active(cx); + // Faceted panels are slabs with a gap between them: the strip keeps its size and its + // grab area, and paints neither a fill nor a line, so the gap shows what lies behind + // the dock. Only a faint wash under the pointer says the gap can be dragged. + let faceted = MoonSkin::active(cx).is_facet(); let grab_cursor = if horizontal_split { CursorStyle::ResizeColumn } else { @@ -1628,7 +1717,7 @@ impl DockArea { .id(ElementId::from(id)) .flex_none() .relative() - .bg(rgba_from(p.shell, 1.0)) + .when(!faceted, |this| this.bg(rgba_from(p.shell, 1.0))) .occlude() .cursor(if horizontal_split { CursorStyle::ResizeColumn @@ -1640,16 +1729,25 @@ impl DockArea { .h_full() .items_center() .justify_center() - .child(div().w(px(1.0)).h_full().bg(rgba_from(p.border, 1.0))) + .when(!faceted, |this| { + this.child(div().w(px(1.0)).h_full().bg(rgba_from(p.border, 1.0))) + }) }) .when(!horizontal_split, |this| { this.h(px(DOCK_RESIZE_HIT_SIZE)) .w_full() .items_center() .justify_center() - .child(div().h(px(1.0)).w_full().bg(rgba_from(p.border, 1.0))) + .when(!faceted, |this| { + this.child(div().h(px(1.0)).w_full().bg(rgba_from(p.border, 1.0))) + }) + }) + .when(!faceted, |this| { + this.hover(|style| style.bg(rgba_from(p.shell_high, 1.0))) + }) + .when(faceted, |this| { + this.hover(|style| style.bg(rgba_from(p.overlay, FACET_RESIZE_HOVER_ALPHA))) }) - .hover(|style| style.bg(rgba_from(p.shell_high, 1.0))) // A press ARMS the gesture; the pointer is then followed by window-level // listeners (`resize_pointer_hook`). Deliberately NOT `on_drag`: GPUI's drag // machinery exists to float a preview under the cursor, and pays for it with a @@ -3145,6 +3243,17 @@ impl DockArea { .border(px(1.0)) .border_color(rgba_from(p.border, 1.0)); tile = panel.background_policy(cx).apply(tile, p.shell_high, 1.0); + if let Some(tile_facet) = + TileFacet::resolve(&tokens, panel.background_policy(cx)) + { + tile = tile + .rounded(px(tile_facet.radius)) + .border_color(tile_facet.edge) + .shadow(tile_facet.shadows); + if let Some(fill) = tile_facet.fill { + tile = tile.bg(fill); + } + } tiles = tiles.child( tile.child( TabPanel::new( @@ -3161,6 +3270,7 @@ impl DockArea { .close_allowed(self.close_allowed) .pinned_leading_panels(self.pinned_leading_panels.clone()) .panel_control_tooltips(self.panel_control_tooltips.clone()) + .floating(true) .background_policy(MoonBackgroundPolicy::NoFill) .content_background_policy(panel.background_policy(cx)), ) @@ -3309,6 +3419,7 @@ impl DockArea { sizes, } => { let p = MoonPalette::active(cx); + let faceted = MoonSkin::active(cx).is_facet(); let id_text = id.to_string(); let split_key = Self::split_key(root, &path); let dock = cx.entity(); @@ -3365,7 +3476,11 @@ impl DockArea { .min_h(px(0.0)) .when(*horizontal, |this| this.h_full()) .when(!*horizontal, |this| this.w_full()); - slot = child.background_policy(cx).apply(slot, p.shell, 1.0); + // A faceted slab has rounded corners and a gap beside it; both show what + // lies behind the dock, so the slot does not paint `shell` under them. + if !faceted { + slot = child.background_policy(cx).apply(slot, p.shell, 1.0); + } let slot_size = if first_child_flexes && ix == 0 { None } else { diff --git a/crates/moon-ui-components/src/moon/dock/tab_panel.rs b/crates/moon-ui-components/src/moon/dock/tab_panel.rs index d6cc60a..f7edb3a 100644 --- a/crates/moon-ui-components/src/moon/dock/tab_panel.rs +++ b/crates/moon-ui-components/src/moon/dock/tab_panel.rs @@ -2,9 +2,10 @@ use gpui::prelude::FluentBuilder as _; use gpui::{ - App, AppContext as _, ElementId, InteractiveElement as _, IntoElement, MouseButton, - MouseDownEvent, ParentElement as _, RenderOnce, ScrollHandle, SharedString, - StatefulInteractiveElement as _, Styled as _, WeakEntity, Window, div, px, + App, AppContext as _, Background, BoxShadow, ElementId, Hsla, InteractiveElement as _, + IntoElement, MouseButton, MouseDownEvent, ParentElement as _, RenderOnce, ScrollHandle, + SharedString, StatefulInteractiveElement as _, Styled as _, WeakEntity, Window, div, + linear_color_stop, linear_gradient, px, transparent_black, }; use super::{ @@ -18,12 +19,19 @@ use crate::{ background::MoonBackgroundPolicy, button::{MoonButton, MoonButtonSize, MoonButtonVariant}, h_flex, + skin::{MoonFacetKind, facet, fading_rule_h}, text::MoonText, theme::{MoonTheme, MoonThemeTokens}, tokens::{MoonPalette, rgba_from}, }, }; +/// Alpha of the `overlay` ink at the top of a faceted header's sheen; it fades to nothing at the +/// header's bottom edge. +const FACET_HEADER_SHEEN_ALPHA: f32 = 0.05; +/// Alpha of the `overlay` ink at the middle of the fading rule under a faceted header. +const FACET_HEADER_RULE_ALPHA: f32 = 0.11; + /// Height of a dock tab header at the active token scale. /// /// Args: @@ -35,6 +43,135 @@ pub(super) fn tab_header_height(tokens: &MoonThemeTokens) -> f32 { tokens.fit_band(tokens.tier_band_base(29.0, |m| m.line_height), 13.0) } +/// Where a tab panel sits, which decides how much of a surface it paints under the Facet skin. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum PanelPlacement { + /// On its own, outside any dock: the whole slab, the shadows it casts included. + Standalone, + /// In a slot of a dock, beside other panels: the slab without the shadows that fall outside + /// it. A neighbouring slot may hold a see-through panel, and a shadow cast over that slot + /// would darken the canvas it shows. + Docked, + /// Inside a floating tile, which draws the surface around the panel. + Floating, +} + +/// What a tab panel paints under the Facet skin, in place of its flat fills and solid borders. +/// +/// A panel whose body is opaque is one slab: the root takes the slab fill and edge, the header +/// adds a sheen and the body paints nothing, so the slab shows through it. +/// +/// A panel whose body is see-through — neither the root nor the content paints a fill, which is +/// how a chart host lets the GPU canvas under the scene show — keeps that body untouched. Only +/// its header takes the slab fill, and the root gets no shadow that falls outside itself: GPUI +/// paints such a shadow under the whole element, so it would darken the canvas through the +/// unfilled body. +/// +/// A floating tile draws the surface itself, around the panel, so the panel inside paints +/// neither a fill nor an edge of its own. +#[derive(Clone, Debug, PartialEq)] +pub(super) struct PanelFacet { + /// Corner radius of the panel's edge, in rendered pixels. + pub(super) radius: f32, + /// Fill of the panel root; `None` leaves the root unpainted. + pub(super) root_fill: Option, + /// Colour of the root's one-pixel edge; transparent when the host tile draws the edge. + pub(super) edge: Hsla, + /// Shadows of the panel root. + pub(super) root_shadows: Vec, + /// Background of the header; `None` leaves the header unpainted. + pub(super) header_fill: Option, + /// A layer painted over `header_fill`: the sheen of a header that spends its own background + /// on the surface fill. + pub(super) header_overlay: Option, + /// Radius of the header's top corners, which keeps its paint inside the rounded edge. + pub(super) header_radius: f32, + /// Colour at the middle of the fading rule under the header. + pub(super) header_rule: Hsla, + /// Whether the body keeps the fill its background policy asks for. + pub(super) body_keeps_policy_fill: bool, +} + +impl PanelFacet { + /// Resolve the faceted look of a tab panel. + /// + /// Args: + /// tokens: Active theme tokens; supply the skin, the palette and the UI scale. + /// placement: Where the panel sits: on its own, in a dock slot or in a floating tile. + /// root: Background policy of the panel root. + /// header: Background policy of the header. + /// content: Background policy of the body, as resolved for the active panel. + /// + /// Returns: + /// The faceted look, or `None` under the Flat skin, where the panel renders as it + /// always has. + pub(super) fn resolve( + tokens: &MoonThemeTokens, + placement: PanelPlacement, + root: MoonBackgroundPolicy, + header: MoonBackgroundPolicy, + content: MoonBackgroundPolicy, + ) -> Option { + if !tokens.skin.is_facet() { + return None; + } + let p = tokens.palette; + let floating = placement == PanelPlacement::Floating; + let surface = facet( + if floating { + MoonFacetKind::Popup + } else { + MoonFacetKind::Slab + }, + tokens, + ); + let opaque = |policy| matches!(policy, MoonBackgroundPolicy::Opaque); + // `Transparent` paints a fill of zero alpha, so it shows what lies behind exactly as + // `NoFill` does. + let see_through = !opaque(root) && !opaque(content); + let sheen = linear_gradient( + 180.0, + linear_color_stop(rgba_from(p.overlay, FACET_HEADER_SHEEN_ALPHA), 0.0), + linear_color_stop(rgba_from(p.overlay, 0.0), 1.0), + ); + let radius = tokens.ui(tokens.metrics.container_radius); + + let (header_fill, header_overlay) = if !see_through { + (Some(sheen), None) + } else if opaque(header) { + (Some(surface.fill), Some(sheen)) + } else { + (None, None) + }; + let (root_fill, edge, root_shadows) = if floating { + (None, transparent_black(), Vec::new()) + } else { + // Only a panel on its own, with an opaque body, keeps the shadows it casts. + let casts = placement == PanelPlacement::Standalone && !see_through; + let shadows = surface.shadows.into_iter(); + ( + (!see_through).then_some(surface.fill), + surface.edge, + shadows.filter(|shadow| casts || shadow.inset).collect(), + ) + }; + + Some(Self { + radius, + root_fill, + edge, + root_shadows, + header_fill, + header_overlay, + // The header starts inside the root's one-pixel border, and a floating tile adds + // its own border outside that. + header_radius: (radius - if floating { 2.0 } else { 1.0 }).max(0.0), + header_rule: rgba_from(p.overlay, FACET_HEADER_RULE_ALPHA), + body_keeps_policy_fill: see_through, + }) + } +} + /// Glyph and matching host tooltip for the dock zoom control. /// /// One function owns both so a restore label cannot sit on the maximize glyph. @@ -59,6 +196,9 @@ pub(super) fn zoom_control( impl RenderOnce for TabPanel { /// Render the tab group and synchronize edge-triggered panel activation notifications. + /// + /// Under the Flat skin the group is a bordered box with a filled header and a filled body. + /// Under the Facet skin it is drawn as [`PanelFacet`] resolves it. fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let p = MoonPalette::active(cx); let tokens = MoonTheme::active_tokens(cx); @@ -128,6 +268,30 @@ impl RenderOnce for TabPanel { .border(px(1.0)) .border_color(rgba_from(p.border, 1.0)); root = self.background_policy.apply(root, p.shell_high, 0.98); + // `None` under the Flat skin: every faceted override below is skipped and the panel + // renders exactly what the lines around them describe. + let panel_facet = PanelFacet::resolve( + &tokens, + if self.floating { + PanelPlacement::Floating + } else if self.dock_area.is_some() { + PanelPlacement::Docked + } else { + PanelPlacement::Standalone + }, + self.background_policy, + self.header_background_policy, + content_policy, + ); + if let Some(panel_facet) = &panel_facet { + root = root + .rounded(px(panel_facet.radius)) + .border_color(panel_facet.edge) + .shadow(panel_facet.root_shadows.clone()); + if let Some(fill) = panel_facet.root_fill { + root = root.bg(fill); + } + } if self.show_header { let mut header = div() @@ -144,6 +308,42 @@ impl RenderOnce for TabPanel { .border_b(px(1.0)) .border_color(rgba_from(p.border, 1.0)); header = self.header_background_policy.apply(header, p.panel, 1.0); + if let Some(panel_facet) = &panel_facet { + // The bottom border keeps its pixel of height, so the tabs sit where they do + // under the Flat skin; it only stops painting, and the fading rule takes the row + // it occupied. An absolute child is placed inside its parent's border, hence the + // negative inset. + let header_radius = px(panel_facet.header_radius); + header = header + .border_color(transparent_black()) + .rounded_t(header_radius); + if let Some(fill) = panel_facet.header_fill { + header = header.bg(fill); + } + // Both layers go in ahead of the tabs, so a tab's hover fill and the active + // underline paint over them. + if let Some(overlay) = panel_facet.header_overlay { + header = header.child( + div() + .absolute() + .top_0() + .right_0() + .bottom_0() + .left_0() + .rounded_t(header_radius) + .bg(overlay), + ); + } + header = header.child( + div() + .absolute() + .left_0() + .right_0() + .bottom(px(-1.0)) + .h(px(1.0)) + .child(fading_rule_h(panel_facet.header_rule)), + ); + } let tabs_scroll_handle = window .use_keyed_state( @@ -549,11 +749,20 @@ impl RenderOnce for TabPanel { .w_full() .min_h(px(0.0)) .overflow_hidden(); - content = content_policy.apply(content, p.shell, 1.0); + // A faceted slab shows through its body, so the body drops its own fill there. A + // see-through body is left to its policy, which paints nothing over the canvas behind. + let body_keeps_policy_fill = panel_facet + .as_ref() + .is_none_or(|panel_facet| panel_facet.body_keeps_policy_fill); + if body_keeps_policy_fill { + content = content_policy.apply(content, p.shell, 1.0); + } if let Some(panel) = active_panel { let mut panel_host = div().absolute().top_0().right_0().bottom_0().left_0(); - panel_host = content_policy.apply(panel_host, p.shell, 1.0); + if body_keeps_policy_fill { + panel_host = content_policy.apply(panel_host, p.shell, 1.0); + } content = content.child(panel_host.child(panel.render_panel(window, cx))); } diff --git a/crates/moon-ui-components/src/moon/dock/tests.rs b/crates/moon-ui-components/src/moon/dock/tests.rs index 5c32ed4..3e6e223 100644 --- a/crates/moon-ui-components/src/moon/dock/tests.rs +++ b/crates/moon-ui-components/src/moon/dock/tests.rs @@ -710,6 +710,77 @@ fn layout_replacement_clears_zoom_before_removing_the_panel(cx: &mut gpui::TestA .unwrap(); } +/// Closing a side root must stop its selected panel without waiting for another hidden render. +#[gpui::test] +fn side_root_open_state_drives_activity_and_preserves_zoom(cx: &mut gpui::TestAppContext) { + let window = cx.add_window(|_, _| DockTestHarness); + cx.update_window(window.into(), |_, window, cx| { + let assets = Rc::new(PanelProbe::default()); + let log = Rc::new(PanelProbe::default()); + let chart = Rc::new(PanelProbe::default()); + let items = vec![ + tracking_panel("assets", assets.clone(), cx), + tracking_panel("log", log.clone(), cx), + ]; + let runtime = cx.new(|_| MoonTabPanelRuntimeState::default()); + let center = tracking_panel("chart", chart.clone(), cx); + let dock = cx.new(|_| DockArea::test_with_center(DockItem::Panel(center))); + dock.update(cx, |dock, cx| { + dock.right = Some(( + DockItem::Tabs { + items, + active_ix: 1, + }, + 240., + true, + )); + dock.tab_runtime_states + .insert("test-dock:right".into(), runtime.downgrade()); + dock.sync_layout_active(window, cx); + assert!(!assets.active.get()); + assert!(log.active.get()); + + assert!(dock.set_dock_open(super::DockPlacement::Right, false, window, cx)); + assert!( + !log.active.get(), + "a closed side root cannot keep its panel active" + ); + assert_eq!(runtime.read(cx).notified_active, Some((None, 2))); + let calls = log.active_calls.get(); + assert!(!dock.set_dock_open(super::DockPlacement::Right, false, window, cx)); + assert_eq!( + log.active_calls.get(), + calls, + "unchanged state has no lifecycle edge" + ); + + assert!(dock.toggle_dock(super::DockPlacement::Right, window, cx)); + assert!(log.active.get()); + assert!(!assets.active.get()); + assert_eq!(runtime.read(cx).active_ix, 1); + + dock.toggle_zoom_panel("chart", window, cx); + assert!(!log.active.get()); + let chart_calls = chart.active_calls.get(); + assert!(dock.set_dock_open(super::DockPlacement::Right, false, window, cx)); + assert!(dock.toggle_dock(super::DockPlacement::Right, window, cx)); + assert_eq!( + chart.active_calls.get(), + chart_calls, + "unchanged zoom has no activity edges" + ); + assert!( + !log.active.get(), + "reopening must not activate behind another panel's zoom" + ); + dock.clear_zoom(window, cx); + assert!(log.active.get()); + assert!(!assets.active.get()); + }); + }) + .unwrap(); +} + /// Catches making `dock.rs:DockArea::sync_layout_active` ignore `zoomed_panel`, which would keep /// the hidden normal tab active while the visible zoom surface reports no active panel. #[gpui::test] @@ -1488,3 +1559,576 @@ fn split_drop_header_inset_clears_the_tab_header() { ); assert_eq!(DockArea::split_drop_header_inset(false, &tokens), 0.0); } + +// ---- Theme skin: what a panel, a tile and the gap between panels paint ---- + +use super::{ + TileFacet, + tab_panel::{PanelFacet, PanelPlacement}, +}; +use crate::{ + ElementExt as _, Root, + moon::{MoonFacetKind, MoonPalette, MoonSkin, MoonTheme, MoonThemeConfig, facet, rgba_from}, +}; +use gpui::{ + AnyWindowHandle, AtlasKey, AtlasTile, Background, BoxShadow, DevicePixels, HeadlessAppContext, + NoopTextSystem, Pixels, PlatformAtlas, PlatformHeadlessRenderer, Point, Quad, ScaledPixels, + Scene, Shadow, Size, linear_color_stop, linear_gradient, transparent_black, +}; +use std::{borrow::Cow, sync::Arc}; + +const ALL_POLICIES: [MoonBackgroundPolicy; 3] = [ + MoonBackgroundPolicy::Opaque, + MoonBackgroundPolicy::Transparent, + MoonBackgroundPolicy::NoFill, +]; + +const ALL_PLACEMENTS: [PanelPlacement; 3] = [ + PanelPlacement::Standalone, + PanelPlacement::Docked, + PanelPlacement::Floating, +]; + +/// The highlight a slab carries along its top edge on a dark palette, written out rather than +/// taken from `skin.rs`: one pixel down, no blur, inside the slab. +fn slab_top_highlight(p: MoonPalette) -> BoxShadow { + BoxShadow { + color: rgba_from(p.overlay, 0.12), + offset: point(px(0.0), px(1.0)), + blur_radius: px(0.0), + spread_radius: px(0.0), + inset: true, + } +} + +/// Theme tokens for `palette` drawn with `skin`, at the default scale. +fn skin_tokens(palette: MoonPalette, skin: MoonSkin) -> MoonThemeTokens { + MoonThemeTokens { + palette, + skin, + ..MoonThemeTokens::default() + } +} + +/// The sheen a faceted header lays over its surface, written out rather than taken from +/// `tab_panel.rs`. +fn header_sheen(p: MoonPalette) -> Background { + linear_gradient( + 180.0, + linear_color_stop(rgba_from(p.overlay, 0.05), 0.0), + linear_color_stop(rgba_from(p.overlay, 0.0), 1.0), + ) +} + +/// Catches `tab_panel.rs:PanelFacet::resolve` or `dock.rs:TileFacet::resolve` ignoring the skin: +/// every flat theme would get gradient panels, rounded corners and shadows it never asked for. +#[test] +fn the_flat_skin_resolves_no_panel_or_tile_facet() { + for palette in [ + MoonPalette::TERMINAL, + MoonPalette::LIGHT, + MoonPalette::GRAPHITE, + MoonPalette::OBSIDIAN, + ] { + let tokens = skin_tokens(palette, MoonSkin::Flat); + for root in ALL_POLICIES { + for content in ALL_POLICIES { + for placement in ALL_PLACEMENTS { + assert_eq!( + PanelFacet::resolve( + &tokens, + placement, + root, + MoonBackgroundPolicy::Opaque, + content + ), + None + ); + } + } + assert_eq!(TileFacet::resolve(&tokens, root), None); + } + } +} + +/// Catches `tab_panel.rs:PanelFacet::resolve` keying the slab on the root policy alone. A host +/// that leaves the dock unfilled so a chart can show — every panel root is `NoFill` there — +/// would get no slab on its ordinary, opaque panels, which is the whole look of the skin. +/// +/// Also catches a docked slab keeping the shadows it casts: the slot beside it may hold a +/// see-through panel, and a shadow cast over that slot would darken the chart it shows. +#[test] +fn a_panel_with_an_opaque_body_is_one_slab_and_casts_a_shadow_only_on_its_own() { + let p = MoonPalette::OBSIDIAN; + let tokens = skin_tokens(p, MoonSkin::Facet); + let slab = facet(MoonFacetKind::Slab, &tokens); + + for (root, content) in [ + (MoonBackgroundPolicy::NoFill, MoonBackgroundPolicy::Opaque), + (MoonBackgroundPolicy::Opaque, MoonBackgroundPolicy::Opaque), + (MoonBackgroundPolicy::Opaque, MoonBackgroundPolicy::NoFill), + ] { + let alone = PanelFacet::resolve( + &tokens, + PanelPlacement::Standalone, + root, + MoonBackgroundPolicy::Opaque, + content, + ) + .expect("the Facet skin resolves a panel facet"); + assert_eq!(alone.root_fill, Some(slab.fill), "{root:?}/{content:?}"); + assert_eq!( + alone.root_shadows, slab.shadows, + "a panel on its own keeps the whole slab" + ); + + let panel = PanelFacet::resolve( + &tokens, + PanelPlacement::Docked, + root, + MoonBackgroundPolicy::Opaque, + content, + ) + .expect("the Facet skin resolves a panel facet"); + + assert_eq!(panel.root_fill, Some(slab.fill), "{root:?}/{content:?}"); + assert_eq!(panel.edge, slab.edge); + assert_eq!( + panel.root_shadows, + vec![slab_top_highlight(p)], + "a docked slab keeps its top highlight and casts nothing outside itself" + ); + assert_eq!(panel.radius, 8.0, "the container radius of the theme"); + assert_eq!( + panel.header_fill, + Some(header_sheen(p)), + "the header is a sheen over the slab, not the flat `panel` fill" + ); + assert_eq!(panel.header_overlay, None); + assert_eq!( + panel.header_radius, 7.0, + "the header starts inside the one-pixel edge" + ); + assert_eq!(panel.header_rule, rgba_from(p.overlay, 0.11)); + assert!( + !panel.body_keeps_policy_fill, + "the body paints nothing, so the slab shows through it" + ); + } +} + +/// Catches `tab_panel.rs:PanelFacet::resolve` giving a see-through panel the slab fill or the +/// slab's outer shadows. GPUI paints a shadow under the whole of its element, so either one +/// would cover the GPU chart canvas that such a panel exists to show. +#[test] +fn a_see_through_panel_takes_the_slab_on_its_header_only() { + let p = MoonPalette::OBSIDIAN; + let tokens = skin_tokens(p, MoonSkin::Facet); + let slab = facet(MoonFacetKind::Slab, &tokens); + + for policy in [ + MoonBackgroundPolicy::NoFill, + MoonBackgroundPolicy::Transparent, + ] { + for placement in [PanelPlacement::Docked, PanelPlacement::Standalone] { + let panel = PanelFacet::resolve( + &tokens, + placement, + policy, + MoonBackgroundPolicy::Opaque, + policy, + ) + .expect("the Facet skin resolves a panel facet"); + + assert_eq!( + panel.root_fill, None, + "{placement:?} {policy:?} body must stay unpainted" + ); + assert!( + panel.body_keeps_policy_fill, + "the body is left to its policy, which paints nothing" + ); + assert_eq!( + panel.root_shadows, + vec![slab_top_highlight(p)], + "only the inset highlight: no shadow that falls outside the panel" + ); + assert_eq!(panel.edge, slab.edge); + assert_eq!( + panel.header_fill, + Some(slab.fill), + "the header alone carries the slab" + ); + assert_eq!(panel.header_overlay, Some(header_sheen(p))); + } + + let bare_header = PanelFacet::resolve( + &tokens, + PanelPlacement::Docked, + policy, + MoonBackgroundPolicy::NoFill, + policy, + ) + .expect("the Facet skin resolves a panel facet"); + assert_eq!( + (bare_header.header_fill, bare_header.header_overlay), + (None, None), + "a header that asked for no fill gets none" + ); + } +} + +/// Catches `dock.rs:TileFacet::resolve` and `tab_panel.rs:PanelFacet::resolve` both painting a +/// surface for a floating tile — a slab inside a popup — or neither, and catches a see-through +/// tile taking the popup's fill or its outer shadows over the canvas behind it. +#[test] +fn a_floating_tile_is_a_popup_and_the_panel_inside_paints_no_surface() { + let p = MoonPalette::OBSIDIAN; + let tokens = skin_tokens(p, MoonSkin::Facet); + let popup = facet(MoonFacetKind::Popup, &tokens); + + let tile = TileFacet::resolve(&tokens, MoonBackgroundPolicy::Opaque) + .expect("the Facet skin resolves a tile facet"); + assert_eq!(tile.fill, Some(popup.fill)); + assert_eq!(tile.edge, popup.edge); + assert_eq!(tile.shadows, popup.shadows); + assert_eq!(tile.radius, 8.0); + + let inside = PanelFacet::resolve( + &tokens, + PanelPlacement::Floating, + MoonBackgroundPolicy::NoFill, + MoonBackgroundPolicy::Opaque, + MoonBackgroundPolicy::Opaque, + ) + .expect("the Facet skin resolves a panel facet"); + assert_eq!(inside.root_fill, None, "the tile carries the fill"); + assert_eq!( + inside.edge, + transparent_black(), + "the tile carries the edge" + ); + assert_eq!(inside.root_shadows, Vec::new()); + assert_eq!(inside.header_fill, Some(header_sheen(p))); + assert_eq!( + inside.header_radius, 6.0, + "inside the tile's edge and the panel's own unpainted one" + ); + assert!(!inside.body_keeps_policy_fill); + + for policy in [ + MoonBackgroundPolicy::NoFill, + MoonBackgroundPolicy::Transparent, + ] { + let tile = TileFacet::resolve(&tokens, policy).expect("the Facet skin resolves a tile"); + assert_eq!(tile.fill, None, "{policy:?} tile must stay unpainted"); + assert_eq!(tile.edge, popup.edge); + assert!( + !tile.shadows.is_empty() && tile.shadows.iter().all(|shadow| shadow.inset), + "only the inset highlight: {:?}", + tile.shadows + ); + + let inside = PanelFacet::resolve( + &tokens, + PanelPlacement::Floating, + MoonBackgroundPolicy::NoFill, + MoonBackgroundPolicy::Opaque, + policy, + ) + .expect("the Facet skin resolves a panel facet"); + assert_eq!(inside.root_fill, None); + assert_eq!( + inside.header_fill, + Some(popup.fill), + "the header of a see-through tile carries the popup surface" + ); + assert!(inside.body_keeps_policy_fill); + } +} + +/// The quads and shadows of the last rendered scene. +#[derive(Default)] +struct RecordedScene { + quads: Vec, + shadows: Vec, +} + +impl RecordedScene { + /// Return the fills painted at `probe`, bottom to top: every quad that covers the point + /// with a background that is not fully transparent. + fn fills_at(&self, probe: Point) -> Vec { + self.quads + .iter() + .filter(|quad| { + !quad.background.is_transparent() + && quad.bounds.contains(&probe) + && quad.content_mask.bounds.contains(&probe) + }) + .map(|quad| quad.background) + .collect() + } + + /// Return the shadows that can reach `probe`. + /// + /// A shadow outside its element reaches three blur radii past its own rectangle and is + /// painted under the whole of it. A shadow inside its element covers the element except the + /// hole, which is that rectangle shrunk by the same margin. + fn shadows_at(&self, probe: Point) -> Vec<&Shadow> { + self.shadows + .iter() + .filter(|shadow| { + let reach = 3.0 * shadow.blur_radius.0; + shadow.content_mask.bounds.contains(&probe) + && if shadow.inset == 0 { + shadow.bounds.dilate(ScaledPixels(reach)).contains(&probe) + } else { + shadow.element_bounds.contains(&probe) + && !shadow.bounds.dilate(ScaledPixels(-reach)).contains(&probe) + } + }) + .collect() + } +} + +/// Records the primitives a headless window submits, without rasterizing them. +struct SkinSceneRecorder(Rc>); + +impl PlatformHeadlessRenderer for SkinSceneRecorder { + /// Record the scene; image output is intentionally unsupported. + fn render_scene_to_image( + &mut self, + scene: &Scene, + size: Size, + ) -> anyhow::Result { + self.render_scene(scene, size)?; + anyhow::bail!("scene recorder does not rasterize images") + } + + /// Replace the preceding frame, so assertions inspect the final submission only. + fn render_scene(&mut self, scene: &Scene, _size: Size) -> anyhow::Result<()> { + self.0.replace(RecordedScene { + quads: scene.quads.clone(), + shadows: scene.shadows.clone(), + }); + Ok(()) + } + + /// Fills and shadows do not depend on glyph or icon textures. + fn sprite_atlas(&self) -> Arc { + Arc::new(NoSpriteAtlas) + } +} + +/// Omits sprites from the fill-and-shadow scene probe. +struct NoSpriteAtlas; + +impl PlatformAtlas for NoSpriteAtlas { + /// Decline every sprite: the probe reads quads and shadows only. + fn get_or_insert_with<'a>( + &self, + _key: &AtlasKey, + _build: &mut dyn FnMut() -> anyhow::Result, Cow<'a, [u8]>)>>, + ) -> anyhow::Result> { + Ok(None) + } + + /// Nothing was stored, so there is nothing to remove. + fn remove(&self, _key: &AtlasKey) {} +} + +/// Build a headed panel that reports where its body was painted. +fn probed_panel( + name: &'static str, + policy: MoonBackgroundPolicy, + body: Rc>>, +) -> Rc { + Rc::new( + MoonDockPanel::new(name, name, move |_, _| { + let body = body.clone(); + div() + .size_full() + .on_prepaint(move |bounds, _, _| body.set(bounds)) + .into_any_element() + }) + .background_policy(policy) + .show_dock_header(true), + ) +} + +/// Draw `frames` frames, so prepaint captures settle before the scene is read. +fn pump_frames(cx: &mut HeadlessAppContext, window: AnyWindowHandle, frames: usize) { + for _ in 0..frames { + cx.update_window(window, |_, window, cx| { + window.refresh(); + window.draw(cx).clear(); + }) + .expect("headless frame must draw"); + cx.run_until_parked(); + } +} + +/// Catches `tab_panel.rs:TabPanel::render` or `dock.rs:DockArea::render_item` painting over the +/// body of a see-through panel under the Facet skin — a slab fill, a body fill or a shadow — +/// which would hide the GPU chart canvas a chart host shows through it. Also catches the Flat +/// skin losing a fill it has always painted: the panel body, the header or the strip between +/// two panels. +/// +/// The dock is set up as a chart host sets it up: the window root, the dock and every panel +/// root are `NoFill`, and only a panel's own policy decides whether its body is painted. +#[test] +fn a_see_through_panel_body_stays_unpainted_and_the_gap_opens_only_under_the_facet_skin() { + for (name, config) in [ + ("flat", MoonThemeConfig::moon_terminal()), + ("facet", MoonThemeConfig::moon_obsidian()), + ] { + let scene = Rc::new(RefCell::new(RecordedScene::default())); + let recorded = scene.clone(); + let mut cx = HeadlessAppContext::with_platform( + Arc::new(NoopTextSystem::new()), + Arc::new(()), + move || Some(Box::new(SkinSceneRecorder(recorded.clone()))), + ); + cx.update(crate::init); + cx.update(|cx| MoonTheme::install_config(config.clone(), cx)); + let tokens = cx.update(|cx| MoonTheme::active_tokens(cx)); + let p = tokens.palette; + let faceted = tokens.skin.is_facet(); + assert_eq!(faceted, name == "facet", "{name}: the theme under test"); + + let opaque_body = Rc::new(Cell::new(Bounds::default())); + let chart_body = Rc::new(Cell::new(Bounds::default())); + let window = cx + .open_window(size(px(800.0), px(600.0)), { + let opaque_body = opaque_body.clone(); + let chart_body = chart_body.clone(); + move |window, cx| { + let dock = cx.new(|cx| { + DockArea::new("skin-dock", None, window, cx) + .background_policy(MoonBackgroundPolicy::NoFill) + .tab_background_policy(MoonBackgroundPolicy::NoFill) + }); + dock.update(cx, |dock, cx| { + dock.set_center( + DockItem::Split { + horizontal: true, + items: vec![ + DockItem::Panel(probed_panel( + "orders", + MoonBackgroundPolicy::Opaque, + opaque_body, + )), + DockItem::Panel(probed_panel( + "chart", + MoonBackgroundPolicy::NoFill, + chart_body, + )), + ], + sizes: vec![Some(300.0), None], + }, + window, + cx, + ); + }); + let view = cx.new(|_| DockEntityHarness { dock }); + cx.new(|cx| { + Root::new(view, window, cx) + .bordered(false) + .background_policy(MoonBackgroundPolicy::NoFill) + }) + } + }) + .expect("headless window must open"); + + pump_frames(&mut cx, window.into(), 4); + let scale = cx + .update_window(window.into(), |_, window, _| window.scale_factor()) + .expect("headless window must report its scale"); + // The recorder only fires on a render pass; a plain draw never reaches it. + let _ = cx.capture_screenshot(window.into()); + let scene = scene.borrow(); + + let opaque = opaque_body.get(); + let chart = chart_body.get(); + assert!( + opaque.size.width > px(100.0) && chart.size.width > px(100.0), + "{name}: both panel bodies must have been painted: {opaque:?} {chart:?}" + ); + assert!( + chart.left() > opaque.right(), + "{name}: the chart sits right of the gap" + ); + let header_height = px(tab_header_height(&tokens)); + let chart_center = chart.center().scale(scale); + let chart_header = point(chart.center().x, chart.top() - header_height / 2.0).scale(scale); + let opaque_center = opaque.center().scale(scale); + let gap = point((opaque.right() + chart.left()) / 2.0, opaque.center().y).scale(scale); + + assert_eq!( + scene.fills_at(chart_center), + Vec::new(), + "{name}: nothing may be filled over the body of a see-through panel" + ); + let over_chart = scene.shadows_at(chart_center); + assert!( + over_chart.is_empty(), + "{name}: no shadow may fall over the body of a see-through panel: {over_chart:?}" + ); + + let slab = facet(MoonFacetKind::Slab, &tokens); + let flat = |color: u32, alpha: f32| Background::from(rgba_from(color, alpha)); + // Shadows cast by something the size of a panel, as (inside, outside) counts. The glow + // under an active tab is a shadow too, under either skin, and is not one of these. + let panel_side = 100.0 * scale; + let panel_shadows = scene.shadows.iter().filter(|shadow| { + shadow.element_bounds.size.width.0 > panel_side + && shadow.element_bounds.size.height.0 > panel_side + }); + let (inside, outside): (Vec<&Shadow>, Vec<&Shadow>) = + panel_shadows.partition(|shadow| shadow.inset != 0); + if faceted { + assert_eq!( + (inside.len(), outside.len()), + (2, 0), + "{name}: each panel is lit along its top, and a docked panel casts no shadow" + ); + assert_eq!( + scene.fills_at(opaque_center), + vec![slab.fill], + "{name}: an opaque panel is one slab, and its body adds no fill over it" + ); + assert_eq!( + scene.fills_at(chart_header).first(), + Some(&slab.fill), + "{name}: the header of a see-through panel carries the slab" + ); + assert_eq!( + scene.fills_at(gap), + Vec::new(), + "{name}: the gap between two slabs shows what lies behind the dock" + ); + } else { + let over_opaque = scene.fills_at(opaque_center); + assert_eq!( + over_opaque.last(), + Some(&flat(p.shell, 1.0)), + "{name}: the body paints `shell`: {over_opaque:?}" + ); + assert_eq!( + scene.fills_at(chart_header), + vec![flat(p.panel, 1.0)], + "{name}: the header paints `panel`" + ); + assert_eq!( + scene.fills_at(gap).first(), + Some(&flat(p.shell, 1.0)), + "{name}: the strip between two panels paints `shell`" + ); + assert_eq!( + (inside.len(), outside.len()), + (0, 0), + "{name}: a flat panel casts no shadow" + ); + } + } +} diff --git a/crates/moon-ui-components/src/moon/dropdown.rs b/crates/moon-ui-components/src/moon/dropdown.rs index 6e978c3..4bdc11a 100644 --- a/crates/moon-ui-components/src/moon/dropdown.rs +++ b/crates/moon-ui-components/src/moon/dropdown.rs @@ -75,7 +75,6 @@ use selection::{ MoonDropdownSelectionContext, menu_item_click_handler, moon_menu_item_accepts_click, }; pub use trigger::MoonDropdown; -use trigger::MoonDropdownState; #[cfg(test)] use trigger::fit_dropdown_trigger_label; diff --git a/crates/moon-ui-components/src/moon/dropdown/popup.rs b/crates/moon-ui-components/src/moon/dropdown/popup.rs index 77ab92a..682d1f7 100644 --- a/crates/moon-ui-components/src/moon/dropdown/popup.rs +++ b/crates/moon-ui-components/src/moon/dropdown/popup.rs @@ -1,5 +1,6 @@ //! Popup-menu rendering, virtualization state, and nested-menu composition. +use super::super::skin::{MoonFacet, MoonFacetKind, facet}; use super::super::theme::MoonTextMetrics; use super::*; @@ -104,6 +105,24 @@ fn menu_trailing_label( .into_any_element() } +/// Design-reference corner radius of a menu under the Facet skin. +const FACET_MENU_RADIUS: f32 = 8.0; + +/// Resolve the surface a menu level paints under the Facet skin. +/// +/// Args: +/// tokens: Tokens the menu renders with; supply the skin, the palette and the UI scale. +/// +/// Returns: +/// The popup surface, or `None` under the Flat skin, where the menu keeps its solid fill, +/// its palette border and its single soft shadow. +fn menu_facet(tokens: &MoonThemeTokens) -> Option { + tokens + .skin + .is_facet() + .then(|| facet(MoonFacetKind::Popup, tokens)) +} + /// Reset and return the last palette shell observed by a probe submenu row. /// /// Returns: @@ -544,11 +563,22 @@ impl MoonPopupMenu { .relative() .w(px(width)) .p(px(tokens.ui(MENU_PADDING))) - .rounded(px(tokens.ui(5.0))) - .border(px(1.0)) - .border_color(rgba_from(p.border, 1.0)) - .bg(rgba_from(p.shell_high, 0.98)) - .shadow(vec![shadow]) + // `None` is the Flat skin. A faceted menu is a popup surface: gradient fill, lit + // edge and the deeper shadow of something floating above the window. + .map(|this| match menu_facet(&tokens) { + Some(surface) => this + .rounded(px(tokens.ui(FACET_MENU_RADIUS))) + .border(px(1.0)) + .border_color(surface.edge) + .bg(surface.fill) + .shadow(surface.shadows), + None => this + .rounded(px(tokens.ui(5.0))) + .border(px(1.0)) + .border_color(rgba_from(p.border, 1.0)) + .bg(rgba_from(p.shell_high, 0.98)) + .shadow(vec![shadow]), + }) .occlude() .flex() .flex_col() diff --git a/crates/moon-ui-components/src/moon/dropdown/popup/tests.rs b/crates/moon-ui-components/src/moon/dropdown/popup/tests.rs index 92537d2..b84d9c9 100644 --- a/crates/moon-ui-components/src/moon/dropdown/popup/tests.rs +++ b/crates/moon-ui-components/src/moon/dropdown/popup/tests.rs @@ -199,3 +199,50 @@ fn submenu_flips_left_and_caps_height_at_viewport_edges(cx: &mut gpui::TestAppCo } } } + +// ---- Theme skin: the surface a menu level paints ---- + +use super::menu_facet; +use crate::moon::{MoonFacetKind, MoonPalette, MoonSkin, MoonThemeTokens, facet}; + +/// Theme tokens for `palette` drawn with `skin`, at the default scale. +fn skin_tokens(palette: MoonPalette, skin: MoonSkin) -> MoonThemeTokens { + MoonThemeTokens { + palette, + skin, + ..MoonThemeTokens::default() + } +} + +/// Catches `popup.rs:menu_facet` ignoring the skin: every flat theme's menus, dropdowns and +/// context menus would trade their solid fill and palette border for a gradient popup, which +/// `render_with_metrics` keeps only while this answers `None`. +#[test] +fn a_flat_menu_keeps_its_solid_surface() { + for palette in [ + MoonPalette::TERMINAL, + MoonPalette::LIGHT, + MoonPalette::GRAPHITE, + MoonPalette::OBSIDIAN, + ] { + assert_eq!(menu_facet(&skin_tokens(palette, MoonSkin::Flat)), None); + } +} + +/// Catches `popup.rs:menu_facet` resolving a surface that rests on a panel instead of one that +/// floats above the window: a faceted menu would lose the deep shadow that lifts it off the +/// panels it opens over. +#[test] +fn a_faceted_menu_is_a_popup_surface() { + let tokens = skin_tokens(MoonPalette::OBSIDIAN, MoonSkin::Facet); + + assert_eq!( + menu_facet(&tokens), + Some(facet(MoonFacetKind::Popup, &tokens)) + ); + assert_ne!( + menu_facet(&tokens), + Some(facet(MoonFacetKind::Card, &tokens)), + "a card is not a popup" + ); +} diff --git a/crates/moon-ui-components/src/moon/dropdown/selection.rs b/crates/moon-ui-components/src/moon/dropdown/selection.rs index 0f72d1d..1d66604 100644 --- a/crates/moon-ui-components/src/moon/dropdown/selection.rs +++ b/crates/moon-ui-components/src/moon/dropdown/selection.rs @@ -1,9 +1,10 @@ //! Private selection and click-dispatch policy for dropdown menu rows. use crate::moon::foundation::{MoonClickHandler, MoonSelectHandler}; -use gpui::{App, Entity, EntityId, Window}; +use crate::popover::PopoverState; +use gpui::Entity; -use super::{MoonDropdownState, MoonMenuItem, MoonMenuItemKind}; +use super::{MoonMenuItem, MoonMenuItemKind}; #[cfg(test)] use std::sync::atomic::{AtomicUsize, Ordering}; @@ -37,82 +38,32 @@ pub(super) fn moon_menu_item_accepts_click( } } -/// Planned menu-close effects for one resolved selection. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct MoonDropdownSelectPlan { - close_menu: bool, - update_internal_open: bool, -} - -/// Shared dropdown behavior applied lazily only when a menu row is rendered. +/// Shared selection callbacks and the actual popover whose focus must close before dispatch. pub(super) struct MoonDropdownSelectionContext { close_on_select: bool, on_select: Option, - state: Entity, - controlled_open: Option, - on_open_change: Option>, - parent_view: EntityId, + popover: Entity, } impl MoonDropdownSelectionContext { - /// Retain dropdown-level selection behavior for lazily rendered rows. + /// Retain the visible menu's callbacks and lifecycle owner for lazy row handlers. /// - /// Args: - /// close_on_select: Whole-menu close policy. - /// on_select: Optional callback receiving the selected row key. - /// state: Keyed internal open state for an uncontrolled dropdown. - /// controlled_open: Caller-owned open state, when present. - /// on_open_change: Optional callback receiving requested open-state changes. - /// parent_view: View notified after internal state changes. - /// - /// Returns: - /// A retained selection context shared by visible row handlers. + /// `close_on_select` supplies the default when a row has no override; `on_select` receives its + /// key after dismissal. `popover` owns focus restoration and controlled-state notifications. + /// Returns a context shared by the rows of this visible menu. pub(super) fn new( close_on_select: bool, on_select: Option, - state: Entity, - controlled_open: Option, - on_open_change: Option>, - parent_view: EntityId, + popover: Entity, ) -> Self { Self { close_on_select, on_select, - state, - controlled_open, - on_open_change, - parent_view, + popover, } } } -/// Decide what one row's click does to the menu around it. -/// -/// `close_on_select` is a whole-menu policy, but a single menu can legitimately hold both kinds of -/// row: checkbox rows that must leave a multi-select menu standing, and a row that opens a dialog -/// and must also take the menu down during that click — an open popup is deferred ABOVE the dialog -/// layer, so a menu left standing paints over the modal it just opened. `item_closes_menu` is that -/// row's own answer, and it wins; `None` follows the menu. -/// -/// Args: -/// close_on_select: The dropdown's whole-menu policy. -/// item_closes_menu: The clicked row's override, if it declared one. -/// controlled_open: `Some` while the consumer owns the open state. -/// -/// Returns: -/// Whether to close, and whether this dropdown owns the state that records it. -fn moon_dropdown_select_plan( - close_on_select: bool, - item_closes_menu: Option, - controlled_open: Option, -) -> MoonDropdownSelectPlan { - let close_menu = item_closes_menu.unwrap_or(close_on_select); - MoonDropdownSelectPlan { - close_menu, - update_internal_open: close_menu && controlled_open.is_none(), - } -} - /// Resolve one rendered row's native and dropdown-level click behavior. /// /// Args: @@ -136,28 +87,19 @@ pub(super) fn menu_item_click_handler( let key = item.key.clone(); let closes_menu = item.closes_menu; Some(std::rc::Rc::new(move |event, window, cx| { - let plan = moon_dropdown_select_plan( - dropdown.close_on_select, - closes_menu, - dropdown.controlled_open, - ); + // Restore the trigger's focus BEFORE a callback opens a modal/native dialog. Root then + // remembers a live return target rather than the menu handle this click is removing. + if closes_menu.unwrap_or(dropdown.close_on_select) { + dropdown + .popover + .update(cx, |state, cx| state.dismiss(window, cx)); + } if let Some(existing_handler) = existing_handler.as_ref() { existing_handler(event, window, cx); } if let Some(on_select) = dropdown.on_select.as_ref() { on_select(&key, window, cx); } - if plan.close_menu { - if let Some(on_open_change) = dropdown.on_open_change.as_ref() { - on_open_change(false, window, cx); - } - if plan.update_internal_open { - dropdown.state.update(cx, |state, _| { - state.open = false; - }); - cx.notify(dropdown.parent_view); - } - } })) } diff --git a/crates/moon-ui-components/src/moon/dropdown/selection/tests.rs b/crates/moon-ui-components/src/moon/dropdown/selection/tests.rs index 106b744..dbeffab 100644 --- a/crates/moon-ui-components/src/moon/dropdown/selection/tests.rs +++ b/crates/moon-ui-components/src/moon/dropdown/selection/tests.rs @@ -2,8 +2,8 @@ use super::super::{MoonDropdown, MoonMenuItem, MoonMenuItemKind}; use super::{ - MENU_DROPDOWN_HANDLER_PROBE_PREFIX, MoonDropdownSelectPlan, moon_dropdown_select_plan, - moon_menu_item_accepts_click, take_dropdown_handler_probe_count, + MENU_DROPDOWN_HANDLER_PROBE_PREFIX, moon_menu_item_accepts_click, + take_dropdown_handler_probe_count, }; use crate::moon::{MoonPalette, MoonTheme}; use std::{cell::RefCell, rc::Rc}; @@ -170,63 +170,6 @@ fn rendered_action_label_dispatches_while_static_label_stays_inert(cx: &mut gpui } } -/// Catches closing keep-open rows or mutating controlled state in -/// `selection.rs:moon_dropdown_select_plan`, which would dismiss persistent menus or desynchronize -/// a controlled dropdown from its owner. -#[test] -fn dropdown_select_plan_respects_close_and_controlled_state() { - assert_eq!( - moon_dropdown_select_plan(true, None, None), - MoonDropdownSelectPlan { - close_menu: true, - update_internal_open: true, - } - ); - assert_eq!( - moon_dropdown_select_plan(true, None, Some(true)), - MoonDropdownSelectPlan { - close_menu: true, - update_internal_open: false, - } - ); - assert_eq!( - moon_dropdown_select_plan(false, None, None), - MoonDropdownSelectPlan { - close_menu: false, - update_internal_open: false, - } - ); -} - -/// A row's own `closes_menu` override must win over the dropdown's whole-menu `close_on_select` -/// policy in `selection.rs:moon_dropdown_select_plan`, and `update_internal_open` must follow the -/// RESOLVED `close_menu` rather than the dropdown's raw policy. Folding the override back down to -/// `close_on_select` leaves a dialog-opening row unable to force its host menu shut, so the menu -/// paints over the modal it just opened; deriving `update_internal_open` from `close_on_select` -/// instead of `close_menu` leaves an uncontrolled dropdown's internal `open` flag out of sync with -/// a row-forced close, so the menu re-opens on the next repaint. -#[test] -fn dropdown_select_plan_row_override_wins_over_dropdown_policy() { - // Row forces a close on a dropdown configured to stay open (checkbox-menu policy). - assert_eq!( - moon_dropdown_select_plan(false, Some(true), None), - MoonDropdownSelectPlan { - close_menu: true, - update_internal_open: true, - }, - "closes_menu(true) must close a close_on_select(false) dropdown and clear its internal open state" - ); - // Row forces staying open on a dropdown configured to close. - assert_eq!( - moon_dropdown_select_plan(true, Some(false), None), - MoonDropdownSelectPlan { - close_menu: false, - update_internal_open: false, - }, - "closes_menu(false) must keep a close_on_select(true) dropdown open and leave its internal open state untouched" - ); -} - /// Open a large dropdown whose per-row dropdown handlers are counted when resolved. struct LargeHandlerProbeMenuHarness; diff --git a/crates/moon-ui-components/src/moon/dropdown/trigger.rs b/crates/moon-ui-components/src/moon/dropdown/trigger.rs index 69c35af..4ea8624 100644 --- a/crates/moon-ui-components/src/moon/dropdown/trigger.rs +++ b/crates/moon-ui-components/src/moon/dropdown/trigger.rs @@ -663,14 +663,8 @@ impl RenderOnce for MoonDropdown { let menu_header = self.menu_header.clone(); let menu_header_height = self.menu_header_height; let popup_level = MoonMenuLevel::from_parts(std::rc::Rc::new(items), menu_layout); - let dropdown_selection = std::rc::Rc::new(MoonDropdownSelectionContext::new( - self.close_on_select, - self.on_select, - state.clone(), - controlled_open, - self.on_open_change.clone(), - parent_view, - )); + let close_on_select = self.close_on_select; + let on_select = self.on_select; let mut popover = CorePopover::new(ElementId::from(self.id.clone())) .appearance(false) @@ -679,6 +673,11 @@ impl RenderOnce for MoonDropdown { .open(open) .trigger_any(trigger) .content(move |_, window, cx| { + let dropdown_selection = std::rc::Rc::new(MoonDropdownSelectionContext::new( + close_on_select, + on_select.clone(), + cx.entity(), + )); let tokens = MoonTheme::active_tokens(cx); let menu_size = menu_size.unwrap_or_else(|| MoonMenuSize::from_theme(&tokens)); let mut menu = MoonPopupMenu::new(menu_id.clone()) diff --git a/crates/moon-ui-components/src/moon/group_box.rs b/crates/moon-ui-components/src/moon/group_box.rs index f35be02..785fe46 100644 --- a/crates/moon-ui-components/src/moon/group_box.rs +++ b/crates/moon-ui-components/src/moon/group_box.rs @@ -2,11 +2,57 @@ use gpui::*; use super::{ background::MoonBackgroundPolicy, + skin::{MoonFacetKind, facet}, text::MoonText, - theme::MoonTheme, + theme::{MoonTheme, MoonThemeTokens}, tokens::{MoonRect, rgba_from}, }; +/// What a group box paints under the Facet skin. +#[derive(Clone, Debug, PartialEq)] +struct GroupBoxFacet { + /// Fill of the box; `None` leaves it as its background policy left it. + fill: Option, + /// Colour of the box's one-pixel edge. + edge: Hsla, + /// Shadows of the box. + shadows: Vec, +} + +/// Resolve the look of a group box under the Facet skin. +/// +/// A box that paints its own fill is a card resting on its panel. A box whose policy leaves it +/// see-through keeps the card's edge and the highlight along its top only — GPUI paints a +/// shadow that falls outside an element under the whole of it, so the card's shadow would show +/// through the box. +/// +/// Args: +/// tokens: Active theme tokens; supply the skin, the palette and the UI scale. +/// policy: Background policy of the box. +/// +/// Returns: +/// The faceted look, or `None` under the Flat skin, where the box keeps its flat fill and +/// its palette border. +fn group_box_facet( + tokens: &MoonThemeTokens, + policy: MoonBackgroundPolicy, +) -> Option { + if !tokens.skin.is_facet() { + return None; + } + let card = facet(MoonFacetKind::Card, tokens); + let opaque = matches!(policy, MoonBackgroundPolicy::Opaque); + Some(GroupBoxFacet { + fill: opaque.then_some(card.fill), + edge: card.edge, + shadows: card + .shadows + .into_iter() + .filter(|shadow| opaque || shadow.inset) + .collect(), + }) +} + #[derive(IntoElement)] pub struct MoonGroupBox { id: SharedString, @@ -69,6 +115,17 @@ impl ParentElement for MoonGroupBox { } impl RenderOnce for MoonGroupBox { + /// Render the bordered box, its optional title and its children. + /// + /// The box paints the flat Moon fill under the Flat skin and a card surface under the Facet + /// skin; see [`group_box_facet`]. + /// + /// Args: + /// _: Window the box renders in; unused. + /// cx: Application context used to resolve the active theme tokens. + /// + /// Returns: + /// The rendered group box. fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let tokens = MoonTheme::active_tokens(cx); let p = tokens.palette; @@ -83,6 +140,15 @@ impl RenderOnce for MoonGroupBox { .flex_col() .gap(px(tokens.ui(self.gap))); root = self.background_policy.apply(root, p.shell_high, 0.98); + // `None` under the Flat skin, which keeps the fill and the border set above. + if let Some(group_facet) = group_box_facet(&tokens, self.background_policy) { + root = root + .border_color(group_facet.edge) + .shadow(group_facet.shadows); + if let Some(fill) = group_facet.fill { + root = root.bg(fill); + } + } if let Some(bounds) = self.bounds { root = root @@ -109,3 +175,6 @@ impl RenderOnce for MoonGroupBox { root.children(self.children) } } + +#[cfg(test)] +mod tests; diff --git a/crates/moon-ui-components/src/moon/group_box/tests.rs b/crates/moon-ui-components/src/moon/group_box/tests.rs new file mode 100644 index 0000000..d82357d --- /dev/null +++ b/crates/moon-ui-components/src/moon/group_box/tests.rs @@ -0,0 +1,62 @@ +//! Guards what a group box paints under each theme skin. + +use super::group_box_facet; +use crate::moon::{ + MoonBackgroundPolicy, MoonFacetKind, MoonPalette, MoonSkin, MoonThemeTokens, facet, +}; + +/// Theme tokens for `palette` drawn with `skin`, at the default scale. +fn skin_tokens(palette: MoonPalette, skin: MoonSkin) -> MoonThemeTokens { + MoonThemeTokens { + palette, + skin, + ..MoonThemeTokens::default() + } +} + +/// Catches `group_box.rs:group_box_facet` ignoring the skin: every flat theme's group box would +/// trade its `shell_high` fill and palette border for a gradient card with a shadow. +#[test] +fn a_flat_group_box_keeps_its_fill_and_border() { + for palette in [MoonPalette::TERMINAL, MoonPalette::LIGHT] { + let tokens = skin_tokens(palette, MoonSkin::Flat); + for policy in [ + MoonBackgroundPolicy::Opaque, + MoonBackgroundPolicy::Transparent, + MoonBackgroundPolicy::NoFill, + ] { + assert_eq!(group_box_facet(&tokens, policy), None); + } + } +} + +/// Catches `group_box.rs:group_box_facet` filling a box that asked for no fill, or leaving the +/// card's outer shadow on it. GPUI paints a shadow under the whole of its element, so a frame +/// drawn inside a popup would smear a dark patch over the popup behind it. +#[test] +fn a_faceted_group_box_is_a_card_and_a_see_through_one_keeps_only_its_edge() { + let tokens = skin_tokens(MoonPalette::OBSIDIAN, MoonSkin::Facet); + let card = facet(MoonFacetKind::Card, &tokens); + + let filled = group_box_facet(&tokens, MoonBackgroundPolicy::Opaque) + .expect("the Facet skin resolves a group box facet"); + assert_eq!(filled.fill, Some(card.fill)); + assert_eq!(filled.edge, card.edge); + assert_eq!(filled.shadows, card.shadows); + + for policy in [ + MoonBackgroundPolicy::Transparent, + MoonBackgroundPolicy::NoFill, + ] { + let frame = + group_box_facet(&tokens, policy).expect("the Facet skin resolves a group box facet"); + assert_eq!(frame.fill, None, "{policy:?}"); + assert_eq!(frame.edge, card.edge); + assert_eq!( + frame.shadows, + vec![card.shadows[0].clone()], + "only the inset highlight along the top edge" + ); + assert!(frame.shadows[0].inset); + } +} diff --git a/crates/moon-ui-components/src/moon/input.rs b/crates/moon-ui-components/src/moon/input.rs index 290e909..1662f52 100644 --- a/crates/moon-ui-components/src/moon/input.rs +++ b/crates/moon-ui-components/src/moon/input.rs @@ -308,3 +308,6 @@ fn size_for(size: MoonInputSize) -> Size { MoonInputSize::Custom { height, .. } => Size::Size(px(height)), } } + +#[cfg(test)] +mod tests; diff --git a/crates/moon-ui-components/src/moon/input/tests.rs b/crates/moon-ui-components/src/moon/input/tests.rs new file mode 100644 index 0000000..48746cc --- /dev/null +++ b/crates/moon-ui-components/src/moon/input/tests.rs @@ -0,0 +1,330 @@ +//! Guards the frame a text input paints under each skin, and that the skin never changes the +//! input's size. + +use gpui::{ + AppContext as _, BoxShadow, Context, Entity, InteractiveElement as _, IntoElement, + ParentElement as _, Render, Styled as _, TestAppContext, VisualTestContext, Window, div, point, + px, +}; + +use super::{MoonInput, MoonInputSize, MoonInputState}; +use crate::input::{InputFacet, input_style_for, trigger_facets}; +use crate::moon::{ + MoonFacetKind, MoonTheme, MoonThemeConfig, MoonThemeTokens, MoonTone, ThemeMode, facet, + rgba_from, +}; +use crate::{Root, v_flex}; + +/// Catches `input.rs:InputFacet::resolve` ignoring the skin or the disabled flag: every flat +/// theme's inputs would be repainted as wells, or a disabled input on the faceted theme would +/// lose its flat dimmed frame. With no frame resolved, an input paints the flat fill, border and +/// hover it always had. +#[test] +fn flat_themes_and_disabled_inputs_resolve_no_frame() { + for tokens in [ + MoonThemeConfig::moon_terminal().dark, + MoonThemeConfig::moon_terminal().light, + MoonThemeConfig::moon_graphite().dark, + MoonThemeConfig::moon_obsidian().light, + MoonThemeTokens::default(), + ] { + let accent = MoonTone::Info.color(tokens.palette); + for selected in [false, true] { + for focused in [false, true] { + for disabled in [false, true] { + assert_eq!( + InputFacet::resolve(&tokens, accent, selected, focused, disabled), + None, + "a flat theme never resolves a frame" + ); + } + } + } + } + + let faceted = MoonThemeConfig::moon_obsidian().dark; + let accent = MoonTone::Info.color(faceted.palette); + for focused in [false, true] { + assert_eq!( + InputFacet::resolve(&faceted, accent, false, focused, true), + None, + "a disabled input keeps the flat frame" + ); + } +} + +/// Catches a faceted input drifting from its spec. At rest it is the skin's well, edged in the +/// highlight ink at 6%, and under the pointer the edge rises to that ink at 20% over the same +/// fill. A selected input keeps the flat input's tone edge, 78% at rest and 82% under the +/// pointer. In focus the edge is the tone at 92% — under the pointer too, and whether or not the +/// input is selected — and a glow in the tone at 28% joins the well's shadows: 8px of blur in the +/// design, so 4px at the renderer. A glow left on an unfocused input, or missing from a focused +/// one, misreports where typing will land. +#[test] +fn a_faceted_input_rests_in_a_well_and_glows_in_its_tone_in_focus() { + let tokens = MoonThemeConfig::moon_obsidian().dark; + let p = tokens.palette; + let well = facet(MoonFacetKind::Well, &tokens); + let tone = MoonTone::Danger.color(p); + assert_eq!(tone, p.red, "the Danger tone of a dark palette is its red"); + let edge = |alpha: f32| rgba_from(p.red, alpha); + + let resting = InputFacet::resolve(&tokens, tone, false, false, false).expect("faceted"); + assert_eq!(resting.fill, well.fill); + assert_eq!(resting.edge, rgba_from(p.overlay, 0.06)); + assert_eq!(resting.hover_edge, rgba_from(p.overlay, 0.2)); + assert_eq!(resting.shadows, well.shadows); + + let selected = InputFacet::resolve(&tokens, tone, true, false, false).expect("faceted"); + assert_eq!(selected.fill, well.fill); + assert_eq!( + (selected.edge, selected.hover_edge), + (edge(0.78), edge(0.82)) + ); + assert_eq!(selected.shadows, well.shadows); + + // The glow written out: centred on the input, no spread, the design's 8px blur halved into + // the renderer's blur radius. + let glow = BoxShadow { + color: edge(0.28), + offset: point(px(0.0), px(0.0)), + blur_radius: px(4.0), + spread_radius: px(0.0), + inset: false, + }; + let mut well_and_glow = well.shadows.clone(); + well_and_glow.push(glow); + for selected in [false, true] { + let focused = InputFacet::resolve(&tokens, tone, selected, true, false).expect("faceted"); + assert_eq!(focused.fill, well.fill); + assert_eq!((focused.edge, focused.hover_edge), (edge(0.92), edge(0.92))); + assert_eq!(focused.shadows, well_and_glow); + } +} + +/// Catches `input.rs:input_style_for` giving a faceted field the flat fill, or a flat theme the +/// sunk one. The date field is drawn by mirrored donor code that knows only this pair: with the +/// flat `shell_high` under the Facet skin it would stand out as a light box beside the wells +/// of the inputs around it, and with `gutter` under a flat theme every field would darken. +#[test] +fn an_input_like_field_takes_the_well_floor_only_under_the_facet_skin() { + let faceted = MoonThemeConfig::moon_obsidian().dark; + let p = faceted.palette; + assert_eq!( + input_style_for(false, &faceted), + (rgba_from(p.gutter, 1.0), rgba_from(p.text_soft, 1.0)) + ); + assert_eq!( + input_style_for(true, &faceted), + (rgba_from(p.panel, 0.55), rgba_from(p.text_muted, 0.45)) + ); + + for flat in [ + MoonThemeConfig::moon_terminal().dark, + MoonThemeConfig::moon_terminal().light, + MoonThemeConfig::moon_graphite().dark, + MoonThemeTokens::default(), + ] { + let p = flat.palette; + assert_eq!( + input_style_for(false, &flat), + (rgba_from(p.shell_high, 1.0), rgba_from(p.text_soft, 1.0)) + ); + assert_eq!( + input_style_for(true, &flat), + (rgba_from(p.panel, 0.55), rgba_from(p.text_muted, 0.45)) + ); + } +} + +/// Catches `input.rs:trigger_facets` ignoring the skin or the disabled flag, or handing a +/// button-styled trigger the input's well: the select beside a button would sink while the +/// button stands, and the one beside an input would stand while the input sinks. +#[test] +fn a_faceted_trigger_matches_the_control_it_is_styled_as() { + use crate::button::{ButtonVariant, ButtonVisualState, raised_pressed_facet}; + + for tokens in [ + MoonThemeConfig::moon_terminal().dark, + MoonThemeConfig::moon_terminal().light, + MoonThemeTokens::default(), + ] { + for variant in [None, Some(ButtonVariant::Panel), Some(ButtonVariant::Blue)] { + assert_eq!(trigger_facets(&tokens, variant, false, false), None); + } + } + + let tokens = MoonThemeConfig::moon_obsidian().dark; + let p = tokens.palette; + for variant in [None, Some(ButtonVariant::Panel), Some(ButtonVariant::Blue)] { + assert_eq!(trigger_facets(&tokens, variant, false, true), None); + } + + assert_eq!( + trigger_facets(&tokens, Some(ButtonVariant::Panel), false, false), + Some([ + facet(MoonFacetKind::Raised, &tokens), + facet(MoonFacetKind::RaisedHover, &tokens), + raised_pressed_facet(&tokens), + ]) + ); + + let well = facet(MoonFacetKind::Well, &tokens); + for variant in [ + ButtonVariant::Blue, + ButtonVariant::Amber, + ButtonVariant::Danger, + ] { + let surfaces = trigger_facets(&tokens, Some(variant), false, false).expect("tone surface"); + for (surface, state) in surfaces.into_iter().zip([ + ButtonVisualState::Normal, + ButtonVisualState::Hovered, + ButtonVisualState::Active, + ]) { + assert_eq!(Some(surface), variant.resolve_facet(&tokens, false, state)); + } + } + for variant in [ButtonVariant::Ghost, ButtonVariant::Bare] { + assert_eq!(trigger_facets(&tokens, Some(variant), false, false), None); + } + let [rest, hovered, pressed] = trigger_facets(&tokens, None, false, false).expect("faceted"); + assert_eq!(rest, well); + assert_eq!(hovered.fill, well.fill); + assert_eq!(hovered.edge, rgba_from(p.overlay, 0.2)); + assert_eq!(hovered.shadows, well.shadows); + assert_eq!(pressed, hovered); + + // In focus, or with its menu open, the field takes the input's focus frame in the Info tone. + let frame = + InputFacet::resolve(&tokens, MoonTone::Info.color(p), false, true, false).expect("faceted"); + let [rest, hovered, _] = trigger_facets(&tokens, None, true, false).expect("faceted"); + assert_eq!((rest.edge, hovered.edge), (frame.edge, frame.hover_edge)); + assert_eq!(rest.shadows, frame.shadows); + assert_ne!(rest.shadows, well.shadows); +} + +/// One normal-size input in a fixed-width column, inside a probe that measures it. +struct InputHarness; + +impl Render for InputHarness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div().w(px(180.)).child( + div() + .debug_selector(|| "input-probe".into()) + .child(MoonInput::new("field").normal().placeholder("Coin")), + ) + } +} + +/// Catches the faceted frame changing an input's size — a well that brings the design mock-up's +/// 22px height with it: under the faceted theme a normal input must measure the 180x28 it +/// measures under a flat one, so a form row never changes height with the theme. +#[gpui::test] +fn a_faceted_input_keeps_the_size_of_the_flat_one(cx: &mut TestAppContext) { + cx.update(crate::init); + for (config, faceted) in [ + (MoonThemeConfig::moon_terminal(), false), + (MoonThemeConfig::moon_obsidian(), true), + ] { + cx.update(|cx| MoonTheme::install_config(config, cx)); + cx.update(|cx| assert_eq!(MoonTheme::active_tokens(cx).skin.is_facet(), faceted)); + + let window = cx.add_window(|window, cx| { + let view = cx.new(|_| InputHarness); + crate::Root::new(view, window, cx).bordered(false) + }); + let mut cx = VisualTestContext::from_window(window.into(), cx); + cx.run_until_parked(); + + let probe = cx.debug_bounds("input-probe").expect("input must render"); + assert_eq!(probe.size.width, px(180.), "faceted={faceted}"); + assert_eq!(probe.size.height, px(28.), "faceted={faceted}"); + } +} + +/// The following sibling measures the input's real layout height rather than its builder fields. +struct HeightHarness { + input: Entity, + size: MoonInputSize, +} + +impl Render for HeightHarness { + /// Leave vertical size unconstrained so a wrong input height cannot be hidden by the host. + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + v_flex() + .w(px(240.)) + .debug_selector(|| "input-height-host".into()) + .child( + MoonInput::new("height-input") + .state(&self.input) + .size(self.size), + ) + .child( + div() + .w_full() + .h(px(1.)) + .debug_selector(|| "after-input".into()), + ) + } +} + +/// Restoring the multiline-only height override makes the Custom case 22px instead of 19px. +#[gpui::test] +fn custom_height_uses_its_own_text_metrics(cx: &mut TestAppContext) { + cx.update(crate::init); + for mode in [ThemeMode::Dark, ThemeMode::Light] { + cx.update(|cx| { + let mut config = MoonThemeConfig::default().with_font_delta(3.); + config.mode = mode; + MoonTheme::install_config(config, cx); + }); + for (size, expected) in [ + (MoonInputSize::Compact, 23.), + (MoonInputSize::Small, 25.), + (MoonInputSize::Normal, 31.), + ( + MoonInputSize::Custom { + height: 19., + radius: 2., + font_size: 10., + line_height: 16., + pad_x: 4., + pad_y: 0., + gap: 2., + }, + 19., + ), + ( + MoonInputSize::Custom { + height: 0., + radius: 2., + font_size: 10., + line_height: 16., + pad_x: 4., + pad_y: 0., + gap: 2., + }, + 19., + ), + ] { + let window = cx.add_window(move |window, cx| { + let input = cx.new(|cx| MoonInputState::new(window, cx).default_value("123")); + let view = cx.new(|_| HeightHarness { input, size }); + Root::new(view, window, cx).bordered(false) + }); + let mut visual = VisualTestContext::from_window(window.into(), cx); + visual.run_until_parked(); + let host = visual + .debug_bounds("input-height-host") + .expect("host rendered"); + let next = visual + .debug_bounds("after-input") + .expect("next sibling rendered"); + assert_eq!( + next.top() - host.top(), + px(expected), + "size={size:?}, mode={mode:?}" + ); + } + } +} diff --git a/crates/moon-ui-components/src/moon/kbd.rs b/crates/moon-ui-components/src/moon/kbd.rs index 64d2415..3b29d9a 100644 --- a/crates/moon-ui-components/src/moon/kbd.rs +++ b/crates/moon-ui-components/src/moon/kbd.rs @@ -1,9 +1,11 @@ //! Monospaced shortcut chips sized to the active density tier. +use gpui::prelude::FluentBuilder as _; use gpui::*; use super::{ foundation::MoonSize, + skin::{MoonFacet, MoonFacetKind, facet}, theme::{MoonTheme, MoonThemeTokens}, tokens::{MoonRect, rgba_from}, }; @@ -242,6 +244,19 @@ impl MoonKbd { }, } } + + /// Resolve the faceted surface the chip paints over its flat chrome. + /// + /// Args: + /// tokens: The theme side to resolve for; supplies the skin, the palette and the UI + /// scale. + /// + /// Returns: + /// The raised surface for a filled chip under the Facet skin. `None` under the Flat + /// skin, and for an outline chip, which has no fill to light and keeps its hairline. + fn raised_surface(&self, tokens: &MoonThemeTokens) -> Option { + (tokens.skin.is_facet() && !self.outline).then(|| facet(MoonFacetKind::Raised, tokens)) + } } impl From for MoonKbd { @@ -251,11 +266,13 @@ impl From for MoonKbd { } impl RenderOnce for MoonKbd { - /// Renders resolved text and chrome without double scaling. + /// Renders resolved text and chrome without double scaling, with the surface of + /// [`MoonKbd::raised_surface`] painted over the flat chrome where there is one. fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let tokens = MoonTheme::active_tokens(cx); let p = tokens.palette; let metrics = self.metrics(&tokens); + let raised = self.raised_surface(&tokens); let mut root = div() .relative() .h(px(metrics.height)) @@ -267,6 +284,11 @@ impl RenderOnce for MoonKbd { if self.outline { p.shell } else { p.panel }, if self.outline { 0.0 } else { 0.92 }, )) + .when_some(raised, |this, raised| { + this.bg(raised.fill) + .border_color(raised.edge) + .shadow(raised.shadows) + }) .flex() .items_center() .justify_center() diff --git a/crates/moon-ui-components/src/moon/kbd/tests.rs b/crates/moon-ui-components/src/moon/kbd/tests.rs index 743c4af..c3fa178 100644 --- a/crates/moon-ui-components/src/moon/kbd/tests.rs +++ b/crates/moon-ui-components/src/moon/kbd/tests.rs @@ -116,6 +116,95 @@ fn kbd_formats_keystrokes_like_longbridge() { } } +/// Catches `kbd.rs:MoonKbd::raised_surface` answering for the Flat skin, which would restyle +/// the shortcut chips of every existing theme, losing the raised surface of a filled chip on +/// the faceted theme, or lighting an outline chip, which has no fill. +#[test] +fn only_a_filled_chip_under_the_facet_skin_is_raised() { + use crate::moon::{MoonFacetKind, MoonPalette, MoonSkin, MoonThemeTokens, facet, rgba_from}; + use gpui::{linear_color_stop, linear_gradient}; + + for palette in [MoonPalette::OBSIDIAN, MoonPalette::LIGHT] { + let tokens = |skin: MoonSkin| MoonThemeTokens { + skin, + palette, + ..MoonThemeTokens::default() + }; + let faceted = tokens(MoonSkin::Facet); + + let raised = MoonKbd::new("Esc") + .raised_surface(&faceted) + .expect("a filled chip is raised under the Facet skin"); + assert_eq!( + raised.fill, + linear_gradient( + 180.0, + linear_color_stop(rgba_from(palette.panel_high, 1.0), 0.0), + linear_color_stop(rgba_from(palette.panel, 1.0), 1.0), + ), + "the chip is lit from above" + ); + assert!(!raised.shadows.is_empty()); + assert_eq!(raised, facet(MoonFacetKind::Raised, &faceted)); + + assert_eq!( + MoonKbd::new("Esc").outline(true).raised_surface(&faceted), + None, + "an outline chip keeps its hairline" + ); + for outline in [false, true] { + assert_eq!( + MoonKbd::new("Esc") + .outline(outline) + .raised_surface(&tokens(MoonSkin::Flat)), + None, + "a chip is raised under the Flat skin (outline={outline})" + ); + } + } +} + +/// Catches `kbd.rs:MoonKbd::render` painting anything new on a flat theme, not painting the +/// raised surface it resolves on the faceted one, or lighting an outline chip. The flat colours +/// are the flat design's own: the panel at 92% under a 72% border, and an unfilled chip with a +/// solid border for the outline. +#[test] +fn a_chip_paints_flat_on_a_flat_theme_and_raised_on_the_faceted_one() { + use super::super::button::scene_probe::{Painted, Pointer, painted}; + use crate::moon::{ + MoonFacetKind, MoonPalette, MoonSkin, MoonThemeConfig, MoonThemeTokens, facet, rgba_from, + }; + + let p = MoonPalette::OBSIDIAN; + let obsidian_with = |skin: MoonSkin| { + let mut config = MoonThemeConfig::moon_obsidian(); + config.dark.skin = skin; + config + }; + let chip = |skin: MoonSkin, outline: bool| { + painted(obsidian_with(skin), Pointer::Away, move || { + MoonKbd::new("Esc").outline(outline) + }) + }; + let flat_filled = Painted::flat(rgba_from(p.panel, 0.92), rgba_from(p.border, 0.72)); + let flat_outline = Painted::flat(rgba_from(p.shell, 0.0), rgba_from(p.border, 1.0)); + assert_eq!(flat_outline.fill, None, "an outline chip has no fill"); + + assert_eq!(chip(MoonSkin::Flat, false), flat_filled); + assert_eq!(chip(MoonSkin::Flat, true), flat_outline); + + let tokens = MoonThemeTokens { + skin: MoonSkin::Facet, + palette: p, + ..MoonThemeTokens::default() + }; + assert_eq!( + chip(MoonSkin::Facet, false), + Painted::facet(facet(MoonFacetKind::Raised, &tokens)) + ); + assert_eq!(chip(MoonSkin::Facet, true), flat_outline); +} + /// Catches dropping the modifier-only and Caps Lock labels from `kbd.rs:MoonKbd::format_keystroke`. /// /// A shortcut that is one modifier parses back under GPUI's own name for it, so without these the diff --git a/crates/moon-ui-components/src/moon/mod.rs b/crates/moon-ui-components/src/moon/mod.rs index 654b6ad..d8ec646 100644 --- a/crates/moon-ui-components/src/moon/mod.rs +++ b/crates/moon-ui-components/src/moon/mod.rs @@ -58,6 +58,7 @@ mod setting; mod sheet; mod sidebar; mod skeleton; +mod skin; mod slider; mod spinner; mod stacked_bar; @@ -195,6 +196,10 @@ pub use sidebar::{ MoonSidebarItem, MoonSidebarMenu, MoonSidebarMenuItem, MoonSidebarToggleButton, }; pub use skeleton::MoonSkeleton; +pub use skin::{ + MoonFacet, MoonFacetKind, MoonFacetTint, MoonSkin, facet, facet_glow, facet_tinted, + fading_rule_h, fading_rule_v, moon_ambient_light, moon_backdrop, +}; pub use slider::{MoonSlider, MoonSliderEvent, MoonSliderState}; pub use spinner::{MoonSpinner, MoonSpinnerSize}; pub use stacked_bar::{MoonBarSegment, MoonStackedBar}; @@ -210,7 +215,7 @@ pub use tag::MoonTag; // Exported on its own line so the recorded API item above stays byte-identical. pub use tag::{MoonTagRadius, MoonTagVariant}; pub use text::MoonText; -pub use text_area::{MoonTextArea, MoonTextAreaEvent, MoonTextAreaState}; +pub use text_area::{MoonTextArea, MoonTextAreaEvent, MoonTextAreaSize, MoonTextAreaState}; pub use theme::{ MoonScale, MoonTextMetrics, MoonTheme, MoonThemeConfig, MoonThemeConfigError, MoonThemeTokens, MoonTypography, diff --git a/crates/moon-ui-components/src/moon/popover.rs b/crates/moon-ui-components/src/moon/popover.rs index 2a7d1b8..d1a9549 100644 --- a/crates/moon-ui-components/src/moon/popover.rs +++ b/crates/moon-ui-components/src/moon/popover.rs @@ -3,7 +3,8 @@ use gpui::{prelude::FluentBuilder as _, *}; use super::{ background::MoonBackgroundPolicy, - theme::MoonTheme, + skin::{MoonFacetKind, facet}, + theme::{MoonTheme, MoonThemeTokens}, tokens::{MoonPalette, MoonRect, rgba_from}, }; @@ -43,6 +44,54 @@ impl MoonPopoverWidth { } } +/// Design-reference corner radius of the Moon chrome under the Facet skin. +const FACET_POPOVER_RADIUS: f32 = 8.0; + +/// What the Moon chrome of a popup box paints under the Facet skin. +#[derive(Clone, Debug, PartialEq)] +struct PopoverFacet { + /// Corner radius of the box, in rendered pixels. + radius: f32, + /// Fill of the box; `None` leaves it as its background policy left it. + fill: Option, + /// Colour of the box's one-pixel edge. + edge: Hsla, + /// Shadows of the box. + shadows: Vec, +} + +/// Resolve the popup surface the Moon chrome paints under the Facet skin. +/// +/// A popup that paints its own fill is a popup surface: gradient fill, lit edge and the deep +/// shadow of something floating above the window. A popup whose policy leaves it see-through +/// keeps the edge and the highlight along its top only — GPUI paints a shadow that falls +/// outside an element under the whole of it, so the deep shadow would show through the box. +/// +/// Args: +/// tokens: Active theme tokens; supply the skin, the palette and the UI scale. +/// policy: Background policy of the popup box. +/// +/// Returns: +/// The faceted look, or `None` under the Flat skin, where the box keeps its flat fill, its +/// palette border and its single soft shadow. +fn popover_facet(tokens: &MoonThemeTokens, policy: MoonBackgroundPolicy) -> Option { + if !tokens.skin.is_facet() { + return None; + } + let popup = facet(MoonFacetKind::Popup, tokens); + let opaque = matches!(policy, MoonBackgroundPolicy::Opaque); + Some(PopoverFacet { + radius: tokens.ui(FACET_POPOVER_RADIUS), + fill: opaque.then_some(popup.fill), + edge: popup.edge, + shadows: popup + .shadows + .into_iter() + .filter(|shadow| opaque || shadow.inset) + .collect(), + }) +} + /// Which surface the popup box paints. #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub enum MoonPopoverChrome { @@ -361,6 +410,17 @@ impl RenderOnce for MoonPopover { if matches!(chrome, MoonPopoverChrome::Moon) { popup = self.background_policy.apply(popup, p.shell_high, 0.98); + // `None` is the Flat skin, and the Picker chrome is never faceted: it exists to + // match the popup a Longbridge picker draws next to it. + if let Some(surface) = popover_facet(&tokens, self.background_policy) { + popup = popup + .rounded(px(surface.radius)) + .border_color(surface.edge) + .shadow(surface.shadows); + if let Some(fill) = surface.fill { + popup = popup.bg(fill); + } + } } let mut popover = CorePopover::new(ElementId::from(self.id.clone())) diff --git a/crates/moon-ui-components/src/moon/popover/tests.rs b/crates/moon-ui-components/src/moon/popover/tests.rs index 1a1701d..9dd90b2 100644 --- a/crates/moon-ui-components/src/moon/popover/tests.rs +++ b/crates/moon-ui-components/src/moon/popover/tests.rs @@ -319,3 +319,68 @@ fn popover_hosted_tooltip_paints_above_its_popover() { ); } } + +// ---- Theme skin: the surface the Moon chrome paints ---- + +use super::popover_facet; +use crate::moon::{MoonBackgroundPolicy, MoonFacetKind, MoonSkin, facet}; + +/// Theme tokens for `palette` drawn with `skin`, with every length doubled. +fn doubled_skin_tokens(palette: MoonPalette, skin: MoonSkin) -> MoonThemeTokens { + let mut tokens = MoonThemeTokens { + palette, + skin, + ..MoonThemeTokens::default() + }; + tokens.scale.ui = 2.0; + tokens +} + +/// Catches `popover.rs:popover_facet` ignoring the skin: every flat theme's popover would trade +/// its `shell_high` fill, palette border and single soft shadow for a gradient popup. +#[test] +fn a_flat_popover_keeps_its_moon_chrome() { + for palette in [MoonPalette::TERMINAL, MoonPalette::LIGHT] { + let tokens = doubled_skin_tokens(palette, MoonSkin::Flat); + for policy in [ + MoonBackgroundPolicy::Opaque, + MoonBackgroundPolicy::Transparent, + MoonBackgroundPolicy::NoFill, + ] { + assert_eq!(popover_facet(&tokens, policy), None); + } + } +} + +/// Catches `popover.rs:popover_facet` leaving the radius unscaled, or filling a popup whose +/// policy asked for no fill and casting the popup's deep shadow through it. +#[test] +fn a_faceted_popover_is_a_popup_and_a_see_through_one_keeps_only_its_edge() { + let tokens = doubled_skin_tokens(MoonPalette::OBSIDIAN, MoonSkin::Facet); + let popup = facet(MoonFacetKind::Popup, &tokens); + + let filled = popover_facet(&tokens, MoonBackgroundPolicy::Opaque) + .expect("the Facet skin resolves a popover facet"); + assert_eq!(filled.fill, Some(popup.fill)); + assert_eq!(filled.edge, popup.edge); + assert_eq!(filled.shadows, popup.shadows); + assert_eq!( + filled.radius, 16.0, + "eight design pixels at a doubled scale" + ); + + for policy in [ + MoonBackgroundPolicy::Transparent, + MoonBackgroundPolicy::NoFill, + ] { + let bare = popover_facet(&tokens, policy).expect("the Facet skin resolves a popover facet"); + assert_eq!(bare.fill, None, "{policy:?}"); + assert_eq!(bare.edge, popup.edge); + assert_eq!( + bare.shadows, + vec![popup.shadows[0].clone()], + "only the inset highlight along the top edge" + ); + assert!(bare.shadows[0].inset); + } +} diff --git a/crates/moon-ui-components/src/moon/progress.rs b/crates/moon-ui-components/src/moon/progress.rs index 1c95cc0..15aba80 100644 --- a/crates/moon-ui-components/src/moon/progress.rs +++ b/crates/moon-ui-components/src/moon/progress.rs @@ -1,9 +1,11 @@ +use gpui::prelude::FluentBuilder as _; use gpui::*; use crate::progress::Progress; use super::{ - theme::MoonTheme, + skin::{MoonFacet, MoonFacetKind, facet, facet_glow}, + theme::{MoonTheme, MoonThemeTokens}, tokens::{MoonTone, rgba_from}, }; @@ -76,6 +78,8 @@ impl MoonProgress { } impl RenderOnce for MoonProgress { + /// Renders the bar: the base progress bar in the tone's colour under the Flat skin, and + /// [`ProgressFacet`]'s groove and lit fill under the Facet skin, at the same height. fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { let tokens = MoonTheme::active_tokens(cx); let p = tokens.palette; @@ -92,5 +96,76 @@ impl RenderOnce for MoonProgress { .color(rgba_from(color, 1.0)) .h(px(height)) .rounded(px(radius)) + .when_some(ProgressFacet::resolve(&tokens, color), |bar, facet| { + facet.apply(bar) + }) + .into_any_element() } } + +/// How far the top edge of a faceted bar's fill is mixed toward white. +const FACET_FILL_LIFT: f32 = 0.2; +/// Opacity of the glow a faceted bar's fill casts in its own colour. +const FACET_FILL_GLOW_ALPHA: f32 = 0.4; +/// Blur of that glow as the design gives it, in design-reference pixels. +const FACET_FILL_GLOW_BLUR: f32 = 8.0; + +/// What a progress bar paints under the Facet skin. +/// +/// The shared progress engine owns motion and keyed state; the skin supplies only paint. +#[derive(Clone, Debug, PartialEq)] +struct ProgressFacet { + /// The groove the bar runs in: the skin's well, painted without its edge. + track: MoonFacet, + /// The fill: the colour at its top edge, then the one at its bottom edge. + fill: [Hsla; 2], + /// The glow the fill casts in its own colour. + glow: BoxShadow, +} + +impl ProgressFacet { + /// Resolves a progress bar's faceted surfaces. + /// + /// Args: + /// tokens: The active theme side; supplies the skin, the palette and the UI scale. + /// color: The bar's colour as `0xRRGGBB`: its tone, or the colour set on it. + /// + /// Returns: + /// The surfaces to paint, or `None` under the Flat skin, where the base bar stands. + fn resolve(tokens: &MoonThemeTokens, color: u32) -> Option { + if !tokens.skin.is_facet() { + return None; + } + let tone = rgba_from(color, 1.0); + Some(Self { + track: facet(MoonFacetKind::Well, tokens), + fill: [tone.blend(white().opacity(FACET_FILL_LIFT)), tone], + glow: facet_glow(color, FACET_FILL_GLOW_ALPHA, FACET_FILL_GLOW_BLUR, tokens), + }) + } + + /// Apply the groove and lit fill without replacing the donor's motion or geometry. + fn apply(self, bar: Progress) -> Progress { + let track = StyleRefinement { + background: Some(Fill::Color(self.track.fill)), + box_shadow: Some(self.track.shadows), + ..Default::default() + }; + let fill = StyleRefinement { + background: Some( + linear_gradient( + 180.0, + linear_color_stop(self.fill[0], 0.0), + linear_color_stop(self.fill[1], 1.0), + ) + .into(), + ), + box_shadow: Some(vec![self.glow]), + ..Default::default() + }; + bar.surface_styles(track, fill) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/moon-ui-components/src/moon/progress/tests.rs b/crates/moon-ui-components/src/moon/progress/tests.rs new file mode 100644 index 0000000..42b8417 --- /dev/null +++ b/crates/moon-ui-components/src/moon/progress/tests.rs @@ -0,0 +1,118 @@ +//! Guards what a MoonProgress bar paints under each skin, and that the skin never changes its +//! height. + +use gpui::{ + BoxShadow, Context, InteractiveElement as _, IntoElement, ParentElement as _, Render, Rgba, + Styled as _, TestAppContext, VisualTestContext, Window, div, point, px, +}; + +use super::{MoonProgress, ProgressFacet}; +use crate::moon::{ + MoonFacetKind, MoonTheme, MoonThemeConfig, MoonThemeTokens, MoonTone, facet, rgba_from, +}; + +/// Catches `progress.rs:ProgressFacet::resolve` ignoring the skin: every flat theme's bars would +/// leave the base progress bar — a tint of the tone under a solid fill — for a groove and a +/// gradient. With nothing resolved, `MoonProgress` renders the base bar exactly as it always has. +#[test] +fn flat_themes_resolve_no_progress_facet() { + for tokens in [ + MoonThemeConfig::moon_terminal().dark, + MoonThemeConfig::moon_terminal().light, + MoonThemeConfig::moon_graphite().dark, + MoonThemeConfig::moon_obsidian().light, + MoonThemeTokens::default(), + ] { + let color = MoonTone::Positive.color(tokens.palette); + assert_eq!(ProgressFacet::resolve(&tokens, color), None); + } +} + +/// Catches a faceted bar drifting from its spec: the track is the skin's well, and the fill runs +/// from its colour lifted a fifth of the way to white down to that colour, under a glow in the +/// colour at 40% — 8px of blur in the design, so 4px at the renderer. A fill lit from below, or +/// one painted in the accent whatever its tone, would make a warning bar read like a healthy one. +#[test] +fn a_faceted_bar_runs_a_lit_fill_of_its_tone_in_a_well() { + let tokens = MoonThemeConfig::moon_obsidian().dark; + let p = tokens.palette; + + // `green` 0x34C07A is (52, 192, 122) and `amber` 0xFFB347 is (255, 179, 71); a fifth of the + // way to 255 adds 40.6, 12.6, 26.6 and 0, 15.2, 36.8. + for (color, lit_top) in [ + (p.green, [92.6, 204.6, 148.6]), + (p.amber, [255.0, 194.2, 107.8]), + ] { + let bar = ProgressFacet::resolve(&tokens, color).expect("faceted"); + assert_eq!(bar.track, facet(MoonFacetKind::Well, &tokens)); + assert_eq!(bar.track.edge, rgba_from(p.overlay, 0.06)); + + let top = Rgba::from(bar.fill[0]); + for (level, expected) in [top.r, top.g, top.b].into_iter().zip(lit_top) { + assert!( + (level * 255.0 - expected).abs() < 0.1, + "fill top {top:?} is not {lit_top:?}" + ); + } + assert_eq!(bar.fill[1], rgba_from(color, 1.0)); + // The glow written out: centred on the fill, no spread, the design's 8px blur halved + // into the renderer's blur radius. + assert_eq!( + bar.glow, + BoxShadow { + color: rgba_from(color, 0.4), + offset: point(px(0.0), px(0.0)), + blur_radius: px(4.0), + spread_radius: px(0.0), + inset: false, + } + ); + } +} + +/// Two bars in a column, each in a probe that measures it: one at the default height, one taller. +struct ProgressHarness; + +impl Render for ProgressHarness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + .w(px(200.)) + .child( + div() + .debug_selector(|| "default-bar".into()) + .child(MoonProgress::new("default").value(40.0)), + ) + .child( + div() + .debug_selector(|| "tall-bar".into()) + .child(MoonProgress::new("tall").value(100.0).height(7.0)), + ) + } +} + +/// Catches the faceted bar changing a progress bar's size — a groove grown past the height the +/// caller set, or a bar that stops filling its parent's width: under the faceted theme each bar +/// must measure exactly what it measures under a flat one, 4px by default and the height asked +/// for otherwise, so a table row holding one never changes height with the theme. +#[gpui::test] +fn a_faceted_bar_keeps_the_height_and_width_of_the_flat_one(cx: &mut TestAppContext) { + cx.update(crate::init); + for (config, faceted) in [ + (MoonThemeConfig::moon_terminal(), false), + (MoonThemeConfig::moon_obsidian(), true), + ] { + cx.update(|cx| MoonTheme::install_config(config, cx)); + cx.update(|cx| assert_eq!(MoonTheme::active_tokens(cx).skin.is_facet(), faceted)); + + let window = cx.add_window(|_, _| ProgressHarness); + let mut cx = VisualTestContext::from_window(window.into(), cx); + cx.run_until_parked(); + + let default_bar = cx.debug_bounds("default-bar").expect("bar must render"); + let tall_bar = cx.debug_bounds("tall-bar").expect("bar must render"); + assert_eq!(default_bar.size.height, px(4.), "faceted={faceted}"); + assert_eq!(tall_bar.size.height, px(7.), "faceted={faceted}"); + assert_eq!(default_bar.size.width, px(200.), "faceted={faceted}"); + assert_eq!(tall_bar.top(), default_bar.bottom(), "faceted={faceted}"); + } +} diff --git a/crates/moon-ui-components/src/moon/radio.rs b/crates/moon-ui-components/src/moon/radio.rs index bfda052..a448417 100644 --- a/crates/moon-ui-components/src/moon/radio.rs +++ b/crates/moon-ui-components/src/moon/radio.rs @@ -234,6 +234,16 @@ impl RenderOnce for MoonRadio { .border_color(colors.border) .bg(colors.fill) .opacity(colors.box_opacity) + // Under the Facet skin an enabled circle takes the checkbox box's faceted surface in + // place of the flat fill and border above; the dot is left as it is. + .when_some( + ChoiceColors::box_facet(&tokens, self.tone, checked, disabled), + |this, facet| { + this.bg(facet.fill) + .border_color(facet.edge) + .shadow(facet.shadows) + }, + ) .when(is_focused, |this| { this.child(choice_focus_ring( &self.id, diff --git a/crates/moon-ui-components/src/moon/radio/tests.rs b/crates/moon-ui-components/src/moon/radio/tests.rs index 0642904..8d3b7ff 100644 --- a/crates/moon-ui-components/src/moon/radio/tests.rs +++ b/crates/moon-ui-components/src/moon/radio/tests.rs @@ -282,6 +282,38 @@ fn radio_without_text_is_its_circle_with_a_centred_dot(cx: &mut TestAppContext) } } +/// Catches the faceted circle changing the radio's geometry — a circle resized when its facet is +/// applied, or a facet painted in place of the circle's content: under the faceted theme a +/// checked radio must stay exactly its 16 or 20px circle around a centred 6 or 8px dot, as under +/// a flat theme, so a radio lines up with the checkbox beside it in either. +#[gpui::test] +fn a_faceted_radio_keeps_its_circle_and_centred_dot(cx: &mut TestAppContext) { + use super::super::MoonTheme; + + cx.update(crate::init); + cx.update(|cx| MoonTheme::install_config(MoonThemeConfig::moon_obsidian(), cx)); + cx.update(|cx| assert!(MoonTheme::active_tokens(cx).skin.is_facet())); + + for (tier, box_px, dot_px) in [(MoonSize::Sm, 16., 6.), (MoonSize::Md, 20., 8.)] { + let window = cx.add_window(move |_, _| ProbedRadioHarness { + size: tier, + label: None, + }); + let mut cx = VisualTestContext::from_window(window.into(), cx); + cx.run_until_parked(); + + let control = cx + .debug_bounds("probed-control") + .expect("probe must render"); + let circle = cx.debug_bounds("probed:box").expect("circle must render"); + let dot = cx.debug_bounds("probed:dot").expect("dot must render"); + assert_eq!(control.size, size(px(box_px), px(box_px)), "{tier:?}"); + assert_eq!(circle.size, size(px(box_px), px(box_px)), "{tier:?}"); + assert_eq!(dot.size, size(px(dot_px), px(dot_px)), "{tier:?}"); + assert_eq!(dot.center(), circle.center(), "{tier:?}"); + } +} + /// Catches `MoonRadio::render` sizing the dot on its own again instead of through /// `snap_centered`. Layout rounds the circle and the dot to device pixels separately, so at 110 % /// a 16px circle and a 6px dot land as 18 and 7: an odd pixel is left over and the dot sits off diff --git a/crates/moon-ui-components/src/moon/segment.rs b/crates/moon-ui-components/src/moon/segment.rs index 8eb6a13..a4934d4 100644 --- a/crates/moon-ui-components/src/moon/segment.rs +++ b/crates/moon-ui-components/src/moon/segment.rs @@ -2,8 +2,11 @@ use gpui::prelude::FluentBuilder; use gpui::*; use std::rc::Rc; +use crate::button::raised_pressed_facet; + use super::{ foundation::{MoonIndexedClickHandler, MoonSize, accent_underline_colored}, + skin::{MoonFacet, MoonFacetKind, MoonFacetTint, facet, facet_tinted}, tag::tone_wash, text::{MoonText, fit_text_to_width, measure_text_width}, theme::{MoonTheme, MoonThemeTokens}, @@ -692,6 +695,9 @@ impl MoonSegmentedControl { let mut replacements = self.replacements; let density = self.density; let (label_size, label_line, hotkey_size, hotkey_line) = segment_text_metrics(&tokens); + let facets = SegmentFacets::resolve(&tokens, frame, fill); + // Cells that stand apart under the Facet skin carry their own hover and press ramp. + let raised_cells = facets.as_ref().is_some_and(|facets| facets.cell.is_some()); let mut root = div() .id(self.id) @@ -709,7 +715,11 @@ impl MoonSegmentedControl { .border_color(rgba_from(p.border, 1.0)) .rounded(fill_radius) .overflow_hidden() - }); + }) + .when_some( + facets.as_ref().and_then(|f| f.strip.clone()), + |this, strip| strip.paint(this), + ); if let Some(bounds) = self.bounds { root = root @@ -728,18 +738,22 @@ impl MoonSegmentedControl { .position(|(index, _)| *index == ix) .map(|position| replacements.swap_remove(position).1); let replaced = replacement.is_some(); - let selected_text = if filled { fill_label } else { accent.text }; - let key_color = if selected { - selected_text + // A faceted selected cell is a wash of the accent over the panel, not the accent + // itself, so its label keeps the ink measured against the panel. + let selected_text = if filled && facets.is_none() { + fill_label } else { - p.text_muted + accent.text }; - let key_alpha = if selected { 0.60 } else { 0.667 }; - let label_color = if selected { - selected_text - } else { - p.text_muted + // A cell that stands apart is a raised chip, lighter than the panel: the muted ink + // falls short on it, so it takes the soft one. + let rest_text = match &facets { + Some(facets) if facets.cell.is_some() && !disabled => p.text_soft, + _ => p.text_muted, }; + let key_color = if selected { selected_text } else { rest_text }; + let key_alpha = if selected { 0.60 } else { 0.667 }; + let label_color = if selected { selected_text } else { rest_text }; let item_click = on_click.clone(); let item_scroll = on_scroll.clone(); let interaction = segment_cell_interaction( @@ -768,10 +782,13 @@ impl MoonSegmentedControl { .when(!interaction.click && !interaction.scroll, |this| { this.cursor_default() }) - .when(!selected && !disabled && !replaced, |this| { - this.hover(move |this| this.bg(rgba_from(p.overlay, 0.025))) - .active(move |this| this.bg(rgba_from(p.overlay, 0.016))) - }) + .when( + !selected && !disabled && !replaced && !raised_cells, + |this| { + this.hover(move |this| this.bg(rgba_from(p.overlay, 0.025))) + .active(move |this| this.bg(rgba_from(p.overlay, 0.016))) + }, + ) .when(filled && selected, |this| this.bg(rgba_from(fill, 1.0))) .when(frame == SegmentFillFrame::JoinedStrip && ix > 0, |this| { this.border_l(hairline) @@ -781,6 +798,24 @@ impl MoonSegmentedControl { this.border(hairline) .border_color(rgba_from(if selected { fill } else { p.border }, 1.0)) .rounded(fill_radius) + }) + .when_some(facets.as_ref(), |this, facets| { + if filled && selected { + // The selected cell is a lit chip of its own, edged all round. + facets + .selected + .clone() + .paint(this.border(hairline).rounded(fill_radius)) + } else if let (Some([rest, hovered, pressed]), false) = + (facets.cell.clone(), disabled) + { + rest.paint(this).when(!replaced, |this| { + this.hover(move |this| hovered.paint(this)) + .active(move |this| pressed.paint(this)) + }) + } else { + this + } }); if let Some(replacement) = replacement { @@ -884,6 +919,52 @@ impl MoonSegmentedControl { } } +/// The surfaces a [`MoonSegmentSelection::Filled`] control paints under the Facet skin. +/// +/// Touching items sit in one well and the selected one stands in it as a lit chip; items that +/// stand apart are raised chips of their own. The underline style has no surfaces, so it +/// resolves none. +#[derive(Clone, Debug, PartialEq)] +struct SegmentFacets { + /// The well a strip of touching items is sunk into; `None` for items that stand apart. + strip: Option, + /// An unselected item that stands apart — at rest, under the pointer and pressed; `None` + /// inside a strip, where items are bare. + cell: Option<[MoonFacet; 3]>, + /// The selected item: a wash of the accent, edged and glowing in it. + selected: MoonFacet, +} + +impl SegmentFacets { + /// Resolve the faceted surfaces of a segmented control. + /// + /// Args: + /// tokens: The theme side; supplies the skin, the palette and the UI scale. + /// frame: The outline policy of the control, from [`segment_fill_frame`]. + /// accent: The accent hue of the selected item as `0xRRGGBB`. + /// + /// Returns: + /// The surfaces to paint, or `None` where the flat colours stand: under the Flat skin + /// and for the underline style. + fn resolve(tokens: &MoonThemeTokens, frame: SegmentFillFrame, accent: u32) -> Option { + if !tokens.skin.is_facet() || frame == SegmentFillFrame::None { + return None; + } + let apart = frame == SegmentFillFrame::SeparateCells; + Some(Self { + strip: (!apart).then(|| facet(MoonFacetKind::Well, tokens)), + cell: apart.then(|| { + [ + facet(MoonFacetKind::Raised, tokens), + facet(MoonFacetKind::RaisedHover, tokens), + raised_pressed_facet(tokens), + ] + }), + selected: facet_tinted(accent, MoonFacetTint::Selected, tokens), + }) + } +} + impl RenderOnce for MoonSegmentedControl { /// Render the control with the active palette and theme tokens. /// diff --git a/crates/moon-ui-components/src/moon/segment/tests.rs b/crates/moon-ui-components/src/moon/segment/tests.rs index 89cc52f..deac480 100644 --- a/crates/moon-ui-components/src/moon/segment/tests.rs +++ b/crates/moon-ui-components/src/moon/segment/tests.rs @@ -14,8 +14,8 @@ use super::{ LABEL_CONTRAST_FLOOR, MoonAccent, MoonPalette, MoonSegmentDensity, MoonSegmentItem, MoonSegmentSelection, MoonSegmentedControl, SEGMENT_COUNT_GAP, SEGMENT_COUNT_PAD_X, SEGMENT_GAP, SEGMENT_LABEL_SIZE, SEGMENT_PAD_X, SegmentCellChrome, SegmentCellInteraction, - SegmentFillFrame, contrast_ratio, count_pill_colors, fit_segment_item, segment_cell_chrome, - segment_cell_interaction, segment_fill_frame, + SegmentFacets, SegmentFillFrame, contrast_ratio, count_pill_colors, fit_segment_item, + segment_cell_chrome, segment_cell_interaction, segment_fill_frame, }; use crate::moon::{MoonScale, MoonTheme, MoonThemeTokens, MoonTone}; @@ -722,3 +722,65 @@ fn counted_item_reserves_its_pill_and_renders_it_inside_the_cell(cx: &mut gpui:: let slack = pill_room - SEGMENT_COUNT_GAP - f32::from(pill.size.width); assert!((0.0..1.0).contains(&slack), "pill reserve slack {slack}"); } + +/// Catches `segment.rs:SegmentFacets::resolve` ignoring the skin or the selection style: every +/// flat theme's filled strip would be repainted as a well, or an underline strip — which has no +/// surfaces at all — would grow a frame under the faceted theme. +#[test] +fn flat_themes_and_underline_strips_resolve_no_segment_facets() { + use crate::moon::MoonThemeConfig; + + let accent = MoonPalette::OBSIDIAN.blue; + for tokens in [ + MoonThemeConfig::moon_terminal().dark, + MoonThemeConfig::moon_terminal().light, + MoonThemeConfig::moon_graphite().dark, + MoonThemeTokens::default(), + ] { + for frame in [ + SegmentFillFrame::None, + SegmentFillFrame::JoinedStrip, + SegmentFillFrame::SeparateCells, + ] { + assert_eq!(SegmentFacets::resolve(&tokens, frame, accent), None); + } + } + let faceted = MoonThemeConfig::moon_obsidian().dark; + assert_eq!( + SegmentFacets::resolve(&faceted, SegmentFillFrame::None, accent), + None + ); +} + +/// Catches a faceted filled strip drifting from its spec. Touching items share one well and are +/// bare inside it; items that stand apart are raised chips with the button's hover and press +/// ramp and no well behind them. Either way the selected item is the accent washed over the +/// panel — not the accent itself, which would bury its label — edged and glowing in that accent. +#[test] +fn a_faceted_filled_strip_is_a_well_or_raised_chips_around_a_lit_selection() { + use crate::button::raised_pressed_facet; + use crate::moon::{MoonFacetKind, MoonFacetTint, MoonThemeConfig, facet, facet_tinted}; + + let tokens = MoonThemeConfig::moon_obsidian().dark; + let accent = tokens.palette.blue; + let selected = facet_tinted(accent, MoonFacetTint::Selected, &tokens); + + let joined = + SegmentFacets::resolve(&tokens, SegmentFillFrame::JoinedStrip, accent).expect("faceted"); + assert_eq!(joined.strip, Some(facet(MoonFacetKind::Well, &tokens))); + assert_eq!(joined.cell, None); + assert_eq!(joined.selected, selected); + + let apart = + SegmentFacets::resolve(&tokens, SegmentFillFrame::SeparateCells, accent).expect("faceted"); + assert_eq!(apart.strip, None); + assert_eq!( + apart.cell, + Some([ + facet(MoonFacetKind::Raised, &tokens), + facet(MoonFacetKind::RaisedHover, &tokens), + raised_pressed_facet(&tokens), + ]) + ); + assert_eq!(apart.selected, selected); +} diff --git a/crates/moon-ui-components/src/moon/selectable_card.rs b/crates/moon-ui-components/src/moon/selectable_card.rs index 50ea073..872e315 100644 --- a/crates/moon-ui-components/src/moon/selectable_card.rs +++ b/crates/moon-ui-components/src/moon/selectable_card.rs @@ -6,7 +6,8 @@ use std::rc::Rc; use super::{ foundation::box_shadow, - theme::MoonTheme, + skin::{MoonFacetKind, facet, facet_glow}, + theme::{MoonTheme, MoonThemeTokens}, tokens::{MoonPalette, MoonTone, rgba_from}, }; @@ -52,6 +53,83 @@ fn card_chrome(selected: bool, focused: bool, accent: u32, p: MoonPalette) -> Ca } } +/// Alpha of the accent on the edge of a selected faceted card. +const FACET_SELECTED_EDGE_ALPHA: f32 = 0.75; +/// Alpha of the glow around a selected faceted card. +const FACET_SELECTED_GLOW_ALPHA: f32 = 0.2; +/// Blur of that glow as the design gives it, in design-reference pixels. +const FACET_SELECTED_GLOW_BLUR: f32 = 22.0; +/// Alpha of the `overlay` ink on the edge of a faceted card under the pointer. +const FACET_HOVER_EDGE_ALPHA: f32 = 0.22; + +/// What a card paints under the Facet skin. +#[derive(Clone, Debug, PartialEq)] +struct CardFacet { + /// Fill of the card. + fill: Background, + /// Colour of the card's one-pixel edge. + edge: Hsla, + /// Colour the edge takes under the pointer; applied to an unselected card only. + hover_edge: Hsla, + /// Shadows of the card: the card's own, then the selected glow or the focus ring. + shadows: Vec, +} + +/// Resolve the look of a card under the Facet skin. +/// +/// A faceted card is a card surface resting on its panel. The selected one is told apart by an +/// accent edge and a soft accent glow, not by the hard one-pixel ring of the Flat skin. Keyboard +/// focus on an unselected card keeps its ring, drawn outside the card's own shadows. +/// +/// Args: +/// selected: Whether the card is the chosen one. +/// focused: Whether the card has keyboard focus. +/// accent: Accent colour of the selected state. +/// tokens: Active theme tokens; supply the skin, the palette and the UI scale. +/// +/// Returns: +/// The faceted look, or `None` under the Flat skin, where [`card_chrome`] alone decides the +/// outline. +fn card_facet( + selected: bool, + focused: bool, + accent: u32, + tokens: &MoonThemeTokens, +) -> Option { + if !tokens.skin.is_facet() { + return None; + } + let p = tokens.palette; + let card = facet(MoonFacetKind::Card, tokens); + let mut shadows = card.shadows; + let edge = if selected { + shadows.push(facet_glow( + accent, + FACET_SELECTED_GLOW_ALPHA, + FACET_SELECTED_GLOW_BLUR, + tokens, + )); + rgba_from(accent, FACET_SELECTED_EDGE_ALPHA) + } else { + if let Some((color, alpha, spread)) = card_chrome(selected, focused, accent, p).ring { + shadows.push(box_shadow( + px(0.0), + px(0.0), + px(0.0), + px(tokens.ui(spread)), + rgba_from(color, alpha), + )); + } + card.edge + }; + Some(CardFacet { + fill: card.fill, + edge, + hover_edge: rgba_from(p.overlay, FACET_HOVER_EDGE_ALPHA), + shadows, + }) +} + /// Moon selectable card: a bordered container for arbitrary content, such as a theme preview, /// that the user picks by click, or by Tab and Space/Enter. The selected card takes an accent /// border. @@ -167,6 +245,9 @@ impl RenderOnce for MoonSelectableCard { .clone(); let focused = interactive && focus_handle.is_focused(window); let chrome = card_chrome(self.selected, focused, accent, p); + // `None` under the Flat skin, where `chrome` alone paints the card. + let card_facet = card_facet(self.selected, focused, accent, &tokens); + let facet_hover_edge = card_facet.as_ref().map(|card_facet| card_facet.hover_edge); let id = self.id.clone(); let mut card = div() @@ -178,23 +259,32 @@ impl RenderOnce for MoonSelectableCard { .p(px(tokens.ui(CARD_PADDING))) .rounded(px(tokens.ui(CARD_RADIUS))) .border(px(tokens.ui(1.0))) - .border_color(rgba_from(chrome.border, 1.0)) - .bg(rgba_from(p.panel_high, 1.0)) - .when_some(chrome.ring, |this, (color, alpha, spread)| { - this.shadow(vec![box_shadow( - px(0.0), - px(0.0), - px(0.0), - px(tokens.ui(spread)), - rgba_from(color, alpha), - )]) + .map(|this| match card_facet { + Some(card_facet) => this + .border_color(card_facet.edge) + .bg(card_facet.fill) + .shadow(card_facet.shadows), + None => this + .border_color(rgba_from(chrome.border, 1.0)) + .bg(rgba_from(p.panel_high, 1.0)) + .when_some(chrome.ring, |this, (color, alpha, spread)| { + this.shadow(vec![box_shadow( + px(0.0), + px(0.0), + px(0.0), + px(tokens.ui(spread)), + rgba_from(color, alpha), + )]) + }), }) .when(self.disabled, |this| this.opacity(0.5)) .when(interactive, |this| { this.track_focus(&focus_handle.clone().tab_stop(true)) .cursor_pointer() - .when(!self.selected, |this| { - this.hover(move |this| this.border_color(rgba_from(p.border_hover, 1.0))) + .when(!self.selected, |this| match facet_hover_edge { + Some(edge) => this.hover(move |this| this.border_color(edge)), + None => this + .hover(move |this| this.border_color(rgba_from(p.border_hover, 1.0))), }) }) .children(self.children); diff --git a/crates/moon-ui-components/src/moon/selectable_card/tests.rs b/crates/moon-ui-components/src/moon/selectable_card/tests.rs index c21d915..3d5c274 100644 --- a/crates/moon-ui-components/src/moon/selectable_card/tests.rs +++ b/crates/moon-ui-components/src/moon/selectable_card/tests.rs @@ -107,3 +107,96 @@ fn cards_select_by_click_and_keyboard_but_not_when_disabled(cx: &mut gpui::TestA "the clicked card keeps focus and Space selects it again" ); } + +// ---- Theme skin: the card surface and how the selected card is told apart ---- + +use super::card_facet; +use crate::moon::{MoonFacetKind, MoonSkin, MoonThemeTokens, facet, rgba_from}; +use gpui::{BoxShadow, point, px}; + +/// Theme tokens for `palette` drawn with `skin`, with every length doubled. +fn doubled_skin_tokens(palette: MoonPalette, skin: MoonSkin) -> MoonThemeTokens { + let mut tokens = MoonThemeTokens { + palette, + skin, + ..MoonThemeTokens::default() + }; + tokens.scale.ui = 2.0; + tokens +} + +/// Catches `selectable_card.rs:card_facet` ignoring the skin: every flat theme's cards would +/// trade the `panel_high` fill and the hard selected ring `card_chrome` gives them for a +/// gradient card with a glow. +#[test] +fn a_flat_card_is_left_to_its_chrome() { + for palette in [MoonPalette::TERMINAL, MoonPalette::LIGHT] { + let tokens = doubled_skin_tokens(palette, MoonSkin::Flat); + for (selected, focused) in [(false, false), (true, false), (false, true), (true, true)] { + assert_eq!(card_facet(selected, focused, palette.accent, &tokens), None); + } + } +} + +/// Catches `selectable_card.rs:card_facet` keeping the Flat skin's hard one-pixel ring on the +/// selected card, dropping its glow, or leaving its edge on the neutral colour: the chosen +/// theme card would either look ringed like a flat one or exactly like the cards beside it. +#[test] +fn a_faceted_card_marks_selection_with_an_accent_edge_and_a_glow() { + let p = MoonPalette::OBSIDIAN; + let tokens = doubled_skin_tokens(p, MoonSkin::Facet); + let accent = p.blue; + let card = facet(MoonFacetKind::Card, &tokens); + + let idle = card_facet(false, false, accent, &tokens).expect("the Facet skin resolves a card"); + assert_eq!(idle.fill, card.fill); + assert_eq!(idle.edge, card.edge); + assert_eq!(idle.shadows, card.shadows); + assert_eq!(idle.hover_edge, rgba_from(p.overlay, 0.22)); + + // The glow written out: centred on the card, no spread, 22 design pixels of blur doubled + // with the UI scale and halved into the renderer's blur radius. + let glow = BoxShadow { + color: rgba_from(accent, 0.2), + offset: point(px(0.0), px(0.0)), + blur_radius: px(22.0), + spread_radius: px(0.0), + inset: false, + }; + let mut card_and_glow = card.shadows.clone(); + card_and_glow.push(glow); + for focused in [false, true] { + let selected = + card_facet(true, focused, accent, &tokens).expect("the Facet skin resolves a card"); + assert_eq!(selected.fill, card.fill); + assert_eq!(selected.edge, rgba_from(accent, 0.75)); + assert_eq!(selected.shadows, card_and_glow, "focused: {focused}"); + assert!( + selected + .shadows + .iter() + .all(|shadow| shadow.spread_radius == px(0.0)), + "no hard spread ring on a selected faceted card" + ); + } + + // The focus ring written out: two design pixels of spread, doubled, and no blur. + let ring = BoxShadow { + color: rgba_from(accent, 0.55), + offset: point(px(0.0), px(0.0)), + blur_radius: px(0.0), + spread_radius: px(4.0), + inset: false, + }; + let mut card_and_ring = card.shadows.clone(); + card_and_ring.push(ring); + let focused = card_facet(false, true, accent, &tokens).expect("the Facet skin resolves a card"); + assert_eq!( + focused.edge, card.edge, + "focus alone must not look selected" + ); + assert_eq!( + focused.shadows, card_and_ring, + "a focused card keeps its focus ring" + ); +} diff --git a/crates/moon-ui-components/src/moon/selector.rs b/crates/moon-ui-components/src/moon/selector.rs index 4e90f8c..6fa5e29 100644 --- a/crates/moon-ui-components/src/moon/selector.rs +++ b/crates/moon-ui-components/src/moon/selector.rs @@ -1,11 +1,15 @@ use gpui::prelude::FluentBuilder; use gpui::*; +use crate::button::raised_pressed_facet; + use super::{ + button::raised_ink, disclosure::{MoonDisclosure, MoonDisclosureDirection}, foundation::box_shadow, + skin::{MoonFacet, MoonFacetKind, MoonSkin, facet}, text::MoonText, - theme::MoonTheme, + theme::{MoonTheme, MoonThemeTokens}, tokens::{MoonRect, rgba_from}, }; @@ -184,6 +188,11 @@ impl MoonSelectorPill { impl RenderOnce for MoonSelectorPill { /// Render the selector pill and its optional decorative caret. /// + /// Under the Facet skin an enabled pill is a raised control: lit from above at rest, relit + /// under the pointer and lit from below while pressed, with muted segment ink lifted to + /// `text_soft`. A disabled pill keeps the flat disabled look on every skin, and the shape + /// and sizes are the same on both. + /// /// Args: /// _window: Window context unused by this renderer. /// cx: Application context used to resolve active theme tokens. @@ -195,6 +204,7 @@ impl RenderOnce for MoonSelectorPill { let p = tokens.palette; let disabled = self.disabled; let text_alpha = if disabled { 0.45 } else { 1.0 }; + let raised = pill_is_raised(tokens.skin, disabled); let mut root = div() .id(self.id) @@ -210,12 +220,18 @@ impl RenderOnce for MoonSelectorPill { .pr(px(tokens.ui(self.pad_right))) .gap(px(tokens.ui(self.gap))) .cursor_default() - .when(!disabled, |this| { + .when(!disabled && !raised, |this| { this.hover(|this| { this.bg(rgba_from(p.panel_high, 1.0)) .border_color(rgba_from(p.border_hover, 1.0)) }) .active(|this| this.bg(rgba_from(p.shell_high, 1.0))) + }) + .when(raised, |this| { + let [rest, hover, pressed] = pill_facets(&tokens); + with_facet(this, rest) + .hover(|this| with_facet(this, hover)) + .active(|this| with_facet(this, pressed)) }); if let Some(bounds) = self.bounds { @@ -246,6 +262,11 @@ impl RenderOnce for MoonSelectorPill { for segment in self.segments { let color = segment.color.unwrap_or(p.text_soft); + let color = if raised { + raised_ink(color, &tokens) + } else { + color + }; root = root.child( div().mt(px(tokens.ui(segment.margin_top))).child( MoonText::new(segment.text) @@ -265,20 +286,56 @@ impl RenderOnce for MoonSelectorPill { // The pill's caret is decorative and permanently collapsed, so `DownUp` selects the // unrotated down-pointing pose. The absolute offsets belong to the pill's geometry, // not the caret's, and therefore stay on this wrapper. + let caret = MoonDisclosure::glyph(false) + .direction(MoonDisclosureDirection::DownUp) + .size(8.0) + .disabled(disabled); + // The caret's own default ink is `text_muted`; on a raised pill it takes the quiet + // ink of a raised control instead, like the muted segments above. + let caret = if raised { + caret.color(raised_ink(p.text_muted, &tokens)) + } else { + caret + }; root = root.child( div() .absolute() .right(px(tokens.ui(self.caret_right))) .top(px(tokens.ui(self.caret_top))) - .child( - MoonDisclosure::glyph(false) - .direction(MoonDisclosureDirection::DownUp) - .size(8.0) - .disabled(disabled), - ), + .child(caret), ); } root } } + +/// Return whether a pill is painted as a raised facet: under the Facet skin, unless it is +/// disabled. +fn pill_is_raised(skin: MoonSkin, disabled: bool) -> bool { + skin.is_facet() && !disabled +} + +/// Resolve the faceted surfaces of a raised pill. +/// +/// Args: +/// tokens: The theme side to resolve them for; supplies the palette and the UI scale. +/// +/// Returns: +/// The surface at rest, under the pointer and while pressed, in that order. +fn pill_facets(tokens: &MoonThemeTokens) -> [MoonFacet; 3] { + [ + facet(MoonFacetKind::Raised, tokens), + facet(MoonFacetKind::RaisedHover, tokens), + raised_pressed_facet(tokens), + ] +} + +/// Paint a faceted surface over the style already set on `this`: its fill as the background, +/// its edge as the border colour and its shadow stack as the shadows. +fn with_facet(this: S, surface: MoonFacet) -> S { + surface.paint(this) +} + +#[cfg(test)] +mod tests; diff --git a/crates/moon-ui-components/src/moon/selector/tests.rs b/crates/moon-ui-components/src/moon/selector/tests.rs new file mode 100644 index 0000000..d4aac71 --- /dev/null +++ b/crates/moon-ui-components/src/moon/selector/tests.rs @@ -0,0 +1,268 @@ +//! Regression coverage for the selector pill under the Flat and Facet skins. + +// Do not use `super::*`: the glob would import the `gpui::test` macro, causing `#[test]` to +// recursively expand into itself. +use std::{cell::RefCell, rc::Rc}; + +use gpui::{ + AppContext as _, Bounds, BoxShadow, Context, IntoElement, ParentElement as _, Pixels, Render, + Styled as _, TestAppContext, Window, div, linear_color_stop, linear_gradient, point, px, +}; + +use super::super::button::scene_probe::{Painted, Pointer, painted}; +use super::{MoonSelectorPill, pill_facets, pill_is_raised}; +use crate::moon::{ + MoonFacetKind, MoonPalette, MoonSkin, MoonTheme, MoonThemeConfig, MoonThemeTokens, facet, + rgba_from, +}; + +/// Theme tokens painting `palette` with the Facet skin at the reference scale. +fn faceted_tokens(palette: MoonPalette) -> MoonThemeTokens { + MoonThemeTokens { + skin: MoonSkin::Facet, + palette, + ..MoonThemeTokens::default() + } +} + +/// An opaque gradient from `top` at the top edge to `bottom` at the bottom edge, written out +/// here rather than taken from the code under test. +fn top_to_bottom(top: u32, bottom: u32) -> gpui::Background { + linear_gradient( + 180.0, + linear_color_stop(rgba_from(top, 1.0), 0.0), + linear_color_stop(rgba_from(bottom, 1.0), 1.0), + ) +} + +/// Catches `selector.rs:pill_is_raised` answering for the Flat skin, which would restyle the +/// pill of every existing theme, or for a disabled pill, which keeps its flat disabled look. +#[test] +fn only_an_enabled_pill_under_the_facet_skin_is_raised() { + assert!(pill_is_raised(MoonSkin::Facet, false)); + assert!(!pill_is_raised(MoonSkin::Facet, true)); + assert!(!pill_is_raised(MoonSkin::Flat, false)); + assert!(!pill_is_raised(MoonSkin::Flat, true)); +} + +/// Catches `selector.rs:pill_facets` handing the hover surface to the resting pill, or a +/// pressed surface that still casts its drop shadow: the pill would look hovered at rest, or +/// would not sink when pressed. +#[test] +fn a_raised_pill_rests_raised_relights_under_the_pointer_and_sinks_when_pressed() { + for palette in [MoonPalette::OBSIDIAN, MoonPalette::LIGHT] { + let tokens = faceted_tokens(palette); + let [rest, hover, pressed] = pill_facets(&tokens); + + assert_eq!( + rest.fill, + top_to_bottom(palette.panel_high, palette.panel), + "the resting pill is lit from above" + ); + assert!(!rest.shadows.is_empty()); + assert_eq!(rest, facet(MoonFacetKind::Raised, &tokens)); + assert_eq!(hover, facet(MoonFacetKind::RaisedHover, &tokens)); + assert_ne!(hover, rest); + + assert_eq!( + pressed.fill, + top_to_bottom(palette.panel, palette.panel_high), + "the pressed pill is lit from below" + ); + assert_eq!(pressed.edge, rest.edge); + let inner: Vec = rest + .shadows + .iter() + .filter(|shadow| shadow.inset) + .cloned() + .collect(); + assert_eq!(pressed.shadows, inner, "the pressed pill casts no shadow"); + assert!(pressed.shadows.len() < rest.shadows.len()); + } +} + +/// The Obsidian theme with its dark side drawn in `skin`: the same palette and scale on both +/// skins, so two renders differ by the skin alone. +fn obsidian_with(skin: MoonSkin) -> MoonThemeConfig { + let mut config = MoonThemeConfig::moon_obsidian(); + config.dark.skin = skin; + config +} + +/// The pill every probe below draws, enabled or disabled. +fn probed_pill(disabled: bool) -> MoonSelectorPill { + MoonSelectorPill::new("pill") + .label("BTCUSDT") + .leading_dot(MoonPalette::OBSIDIAN.green) + .disabled(disabled) +} + +/// Catches `selector.rs:MoonSelectorPill::render` painting anything new on a flat theme: the +/// pill keeps its solid panel fill, its hairline and its hover and pressed fills, and casts no +/// shadow. The colours are the flat design's own, written out rather than read back. +#[test] +fn a_flat_theme_paints_the_pill_as_it_did_before_the_facet_skin() { + let p = MoonPalette::OBSIDIAN; + let flat = |fill: (u32, f32), border: (u32, f32)| { + Painted::flat(rgba_from(fill.0, fill.1), rgba_from(border.0, border.1)) + }; + let cases = [ + ( + "resting", + Pointer::Away, + false, + flat((p.panel, 1.0), (p.border, 1.0)), + ), + ( + "hovered", + Pointer::Over, + false, + flat((p.panel_high, 1.0), (p.border_hover, 1.0)), + ), + ( + "pressed", + Pointer::Pressed, + false, + flat((p.shell_high, 1.0), (p.border_hover, 1.0)), + ), + ( + "disabled", + Pointer::Over, + true, + flat((p.panel, 0.48), (p.border, 0.55)), + ), + ]; + + let mismatches = paint_mismatches(MoonSkin::Flat, cases); + assert!( + mismatches.is_empty(), + "under the Flat skin:\n{}", + mismatches.join("\n") + ); +} + +/// Draw each case's pill under the Obsidian palette with `skin` and describe every one that +/// did not paint what was expected, so one run names all the states that drifted. A case is +/// `(name, pointer, disabled, expected)`. +fn paint_mismatches(skin: MoonSkin, cases: [(&str, Pointer, bool, Painted); 4]) -> Vec { + cases + .into_iter() + .filter_map(|(name, pointer, disabled, expected)| { + let got = painted(obsidian_with(skin), pointer, move || probed_pill(disabled)); + (got != expected).then(|| format!("the {name} pill painted {got:?}, not {expected:?}")) + }) + .collect() +} + +/// Catches `selector.rs:MoonSelectorPill::render` not painting the raised surfaces it resolves: +/// a pill that stays a flat slab on the faceted theme, does not relight under the pointer or +/// keeps its drop shadow while pressed. Also catches a disabled pill picking a surface up. +#[test] +fn the_faceted_theme_paints_the_pill_raised_relit_and_pressed() { + let p = MoonPalette::OBSIDIAN; + let tokens = faceted_tokens(p); + let raised = facet(MoonFacetKind::Raised, &tokens); + let cases = [ + ( + "resting", + Pointer::Away, + false, + Painted::facet(raised.clone()), + ), + ( + "hovered", + Pointer::Over, + false, + Painted::facet(facet(MoonFacetKind::RaisedHover, &tokens)), + ), + ( + "pressed", + Pointer::Pressed, + false, + Painted { + fill: Some(top_to_bottom(p.panel, p.panel_high)), + border: Some(raised.edge), + shadows: raised + .shadows + .iter() + .filter(|shadow| shadow.inset) + .cloned() + .collect(), + }, + ), + ( + "disabled", + Pointer::Over, + true, + Painted::flat(rgba_from(p.panel, 0.48), rgba_from(p.border, 0.55)), + ), + ]; + + let mismatches = paint_mismatches(MoonSkin::Facet, cases); + assert!( + mismatches.is_empty(), + "under the Facet skin:\n{}", + mismatches.join("\n") + ); +} + +/// A window rendering one pill at the origin and recording its laid-out bounds. +struct PillHarness { + disabled: bool, + bounds: Rc>>>, +} + +impl Render for PillHarness { + /// Render the pill start-aligned in a wide row, so the row does not stretch it. + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + let sink = self.bounds.clone(); + div() + .w(px(400.0)) + .flex() + .flex_row() + .items_start() + .justify_start() + .on_children_prepainted(move |bounds, _, _| *sink.borrow_mut() = bounds) + .child(probed_pill(self.disabled)) + } +} + +/// Draw a pill in a real window and return its box. +fn laid_out_pill(cx: &mut TestAppContext, disabled: bool) -> Bounds { + let bounds = Rc::new(RefCell::new(Vec::new())); + let sink = bounds.clone(); + let window = cx.add_window(move |_, _| PillHarness { + disabled, + bounds: sink, + }); + cx.update_window(window.into(), |_, window, cx| { + window.draw(cx).clear(); + }) + .unwrap(); + + let out = bounds.borrow(); + assert_eq!(out.len(), 1, "expected exactly the pill as a child"); + out[0] +} + +/// Catches the Facet skin changing the pill's box, by adding a border width or padding, and +/// catches the faceted render path panicking: the pill keeps its shape and size on both skins, +/// enabled or disabled. The 24-pixel height is the pill's own design-reference default. +#[gpui::test] +fn the_facet_skin_leaves_the_pill_box_unchanged(cx: &mut TestAppContext) { + cx.update(crate::init); + + for disabled in [false, true] { + cx.update(|cx| MoonTheme::install_config(obsidian_with(MoonSkin::Flat), cx)); + let flat = laid_out_pill(cx, disabled); + cx.update(|cx| MoonTheme::install_config(obsidian_with(MoonSkin::Facet), cx)); + let faceted = laid_out_pill(cx, disabled); + + assert_eq!(flat.origin, point(px(0.0), px(0.0))); + assert_eq!(flat.size.height, px(24.0)); + assert_eq!( + faceted, flat, + "the pill moved under the Facet skin (disabled={disabled})" + ); + } +} diff --git a/crates/moon-ui-components/src/moon/skin.rs b/crates/moon-ui-components/src/moon/skin.rs new file mode 100644 index 0000000..60904dd --- /dev/null +++ b/crates/moon-ui-components/src/moon/skin.rs @@ -0,0 +1,539 @@ +//! The theme skin: the manner in which a theme draws depth. +//! +//! A palette says which colours a theme has; the skin says what a surface made of them looks +//! like. [`MoonSkin::Flat`] is every theme that existed before skins did: one solid fill and a +//! hairline. [`MoonSkin::Facet`] lights each surface from above: a two-stop vertical gradient, +//! a one-pixel edge, a highlight along the top and a shadow underneath. +//! +//! Components ask [`MoonSkin::active`] and, only when it answers `Facet`, paint the surface +//! [`facet`] or [`facet_tinted`] resolves for them. Nothing here is consulted under `Flat`, so +//! a theme without a skin renders exactly as it did before this module existed. +//! +//! Every colour is derived from the palette by rule rather than stored, so the same code +//! serves a dark and a light palette: `overlay` is the highlight ink, `shadow` is the shadow +//! ink, and [`MoonPalette::is_light`] decides strengths only. Every length goes through +//! [`MoonThemeTokens::ui`], so a facet grows with the control it is painted on. + +use gpui::{ + AnyElement, App, Background, BoxShadow, Div, Hsla, InteractiveElement as _, IntoElement, + ParentElement as _, Styled, div, linear_color_stop, linear_gradient, px, relative, +}; +use serde::{Deserialize, Serialize}; + +use super::{ + foundation::box_shadow, + theme::{MoonTheme, MoonThemeTokens}, + tokens::{MoonPalette, rgba_from}, +}; + +/// The white end of [`lift`], and the highlight ink a light palette uses in place of its +/// (black) `overlay`. +const WHITE: u32 = 0xFFFFFF; +/// The black end of [`sink`]. +const BLACK: u32 = 0x000000; + +/// What a blur of the design becomes as a blur radius of the renderer. +/// +/// The skin's blurs are written as the design gives them: CSS `box-shadow` blurs, where the +/// gaussian's standard deviation is half the blur. The renderer takes the standard deviation +/// itself as its blur radius, so a design blur is halved on the way in. Passed through as it is, +/// every shadow and glow would spread twice as wide and arrive at a fraction of its strength. +const DESIGN_BLUR_TO_RENDERER: f32 = 0.5; + +/// How far down the window the backdrop's top light reaches, as a fraction of its height. +const BACKDROP_TOP_LIGHT_REACH: f32 = 0.5; +/// Blur of a backdrop ambient light as the design gives it, in design-reference pixels. +const BACKDROP_LIGHT_BLUR: f32 = 260.0; +/// Spread radius of a backdrop ambient light, in design-reference pixels. +const BACKDROP_LIGHT_SPREAD: f32 = 140.0; + +/// The manner in which a theme draws depth. +/// +/// A theme side carries one in [`MoonThemeTokens::skin`]. A theme file that does not name a +/// skin gets [`Self::Flat`], which is what every theme was before skins existed. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum MoonSkin { + /// Solid fills and hairlines; no component consults this module. + #[default] + Flat, + /// Surfaces lit from above: gradient fills, one-pixel edges, highlights and shadows. + Facet, +} + +impl MoonSkin { + /// Return whether this is the [`Self::Facet`] skin. + pub fn is_facet(self) -> bool { + matches!(self, Self::Facet) + } + + /// Return the skin of the active theme side. + /// + /// Reads the [`MoonTheme`] global directly, so it is cheap enough to ask on every render. + /// + /// Args: + /// cx: The application context holding the theme global. + /// + /// Returns: + /// The active skin, or [`Self::Flat`] when no theme is installed. + pub fn active(cx: &App) -> Self { + MoonTheme::global(cx) + .map(|theme| theme.skin) + .unwrap_or_default() + } +} + +/// A resolved faceted surface, ready to paint. +/// +/// A component applies `fill` as its background, `edge` as the colour of a one-pixel border +/// and `shadows` through `Styled::shadow`. +#[derive(Clone, Debug, PartialEq)] +pub struct MoonFacet { + /// The surface fill: a two-stop gradient running from the top edge to the bottom edge. + pub fill: Background, + /// The colour of the one-pixel edge around the surface. + pub edge: Hsla, + /// The shadow stack: the inset shadow first, then the shadows outside the surface. + pub shadows: Vec, +} + +impl MoonFacet { + /// Paint this surface over the style already set on `this`. + /// + /// Sets the fill as the background, the edge as the border colour and the shadow stack. + /// The border width and the corner radius stay with the caller, so the same surface serves + /// a hairline frame at any scale. + /// + /// Args: + /// this: The element or style refinement to paint on. + /// + /// Returns: + /// `this`, painted. + pub fn paint(self, this: S) -> S { + this.bg(self.fill) + .border_color(self.edge) + .shadow(self.shadows) + } +} + +/// The neutral surfaces a faceted theme is built from. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MoonFacetKind { + /// A dock panel: the large slab the rest of the interface sits on. + Slab, + /// A control that stands off its surface, such as a button or a pill. + Raised, + /// [`Self::Raised`] under the pointer. + RaisedHover, + /// A surface sunk into its parent: an input, a toggle track, a slider groove. + Well, + /// A small well that holds a mark: a checkbox box, a radio circle, a toggle track. It is + /// [`Self::Well`] under an edge strong enough to read at a dozen pixels, where the well's + /// own edge disappears into the surface around it. + Socket, + /// A surface floating above the window: a menu, popover, tooltip or dialog. + Popup, + /// A card resting on a panel. + Card, +} + +/// How strongly a tone colours a faceted surface. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MoonFacetTint { + /// A light wash of the tone over the panel. + Soft, + /// A stronger wash with a glow, for the selected one of several. + Selected, + /// The tone itself as the fill, for a primary action. + Solid, +} + +/// Resolve a neutral faceted surface from the palette. +/// +/// Args: +/// kind: Which surface to resolve. +/// tokens: The theme side to resolve it for; supplies the palette and the UI scale. +/// +/// Returns: +/// The fill, edge and shadow stack for that surface. +pub fn facet(kind: MoonFacetKind, tokens: &MoonThemeTokens) -> MoonFacet { + let p = tokens.palette; + let top_highlight = + |dark: f32, light: f32| inset_shadow(1.0, 0.0, highlight(p, dark, light), tokens); + let cast = |y: f32, blur: f32, dark: f32, light: f32| { + drop_shadow(y, blur, shade(p, dark, light), tokens) + }; + let raised_shadows = || vec![top_highlight(0.16, 0.9), cast(1.0, 2.0, 0.55, 0.12)]; + + match kind { + MoonFacetKind::Slab => MoonFacet { + fill: vertical(lift(p.shell_high, 0.045), p.shell_high), + edge: edge(p, 0.07, p.border), + shadows: vec![ + top_highlight(0.12, 0.9), + cast(18.0, 40.0, 0.65, 0.10), + cast(2.0, 6.0, 0.5, 0.08), + ], + }, + MoonFacetKind::Raised => MoonFacet { + fill: vertical(p.panel_high, p.panel), + edge: edge(p, 0.09, p.border), + shadows: raised_shadows(), + }, + MoonFacetKind::RaisedHover => MoonFacet { + fill: vertical(lift(p.panel_high, 0.05), lift(p.panel, 0.04)), + edge: edge(p, 0.2, p.border_hover), + shadows: raised_shadows(), + }, + // A well inverts the stack: the inset shadow is the dark one, pooling under the top + // edge, and the light sits outside, along the lip below the well. + MoonFacetKind::Well => MoonFacet { + fill: vertical(sink(p.gutter, well_sink(p)), p.gutter), + edge: edge(p, 0.06, p.border), + shadows: vec![ + inset_shadow(1.0, 3.0, shade(p, 0.7, 0.12), tokens), + drop_shadow(1.0, 0.0, highlight(p, 0.07, 0.8), tokens), + ], + }, + MoonFacetKind::Socket => MoonFacet { + edge: edge(p, 0.17, p.border_hover), + ..facet(MoonFacetKind::Well, tokens) + }, + MoonFacetKind::Popup => MoonFacet { + fill: vertical(lift(p.panel_high, 0.02), p.card), + edge: edge(p, 0.13, p.border), + shadows: vec![ + top_highlight(0.2, 0.9), + cast(28.0, 60.0, 0.8, 0.16), + cast(6.0, 16.0, 0.55, 0.10), + ], + }, + MoonFacetKind::Card => MoonFacet { + fill: vertical(lift(p.card, 0.04), p.card), + edge: edge(p, 0.08, p.border), + shadows: vec![top_highlight(0.12, 0.9), cast(2.0, 6.0, 0.5, 0.08)], + }, + } +} + +/// Resolve a faceted surface coloured by a tone. +/// +/// Args: +/// tone: The colouring tone as `0xRRGGBB`, such as the palette's `blue` or `accent`. +/// tint: How strongly the tone colours the surface. +/// tokens: The theme side to resolve it for; supplies the palette and the UI scale. +/// +/// Returns: +/// The fill, edge and shadow stack for that surface. +pub fn facet_tinted(tone: u32, tint: MoonFacetTint, tokens: &MoonThemeTokens) -> MoonFacet { + let p = tokens.palette; + let washed = |share: f32| mix(p.panel, tone, share); + // The `overlay` ink in either palette: a highlight on a dark one, and on a light one, where + // `overlay` is black, a faint line of shade under the top edge instead. + let soft_highlight = || inset_shadow(1.0, 0.0, rgba_from(p.overlay, 0.10), tokens); + let contact = || drop_shadow(1.0, 2.0, shade(p, 0.55, 0.12), tokens); + + match tint { + MoonFacetTint::Soft => MoonFacet { + fill: vertical(washed(0.14), washed(0.06)), + edge: rgba_from(tone, 0.42), + shadows: vec![soft_highlight(), contact()], + }, + MoonFacetTint::Selected => MoonFacet { + fill: vertical(washed(0.20), washed(0.08)), + edge: rgba_from(tone, 0.7), + shadows: vec![ + soft_highlight(), + contact(), + facet_glow(tone, 0.26, 18.0, tokens), + ], + }, + // The highlight on a solid tone is white in either palette: the fill is the tone itself, + // which the palette's side does not change. + MoonFacetTint::Solid => MoonFacet { + fill: vertical(lift(tone, 0.25), sink(tone, 0.06)), + edge: rgba_from(sink(tone, 0.2), 1.0), + shadows: vec![ + inset_shadow(1.0, 0.0, rgba_from(WHITE, 0.45), tokens), + contact(), + facet_glow(tone, if p.is_light() { 0.2 } else { 0.32 }, 18.0, tokens), + ], + }, + } +} + +/// Build a glow: a blurred shadow centred on its element, with no offset and no spread. +/// +/// Args: +/// color: The glow colour as `0xRRGGBB`. +/// alpha: The glow's opacity at full strength, from 0 to 1. +/// blur: The blur as the design gives it, a CSS `box-shadow` blur in design-reference +/// pixels; scaled with the UI and converted for the renderer. +/// tokens: The theme side, for the UI scale. +/// +/// Returns: +/// The shadow, to append to a surface's shadow stack. +pub fn facet_glow(color: u32, alpha: f32, blur: f32, tokens: &MoonThemeTokens) -> BoxShadow { + box_shadow( + px(0.0), + px(0.0), + renderer_blur(blur, tokens), + px(0.0), + rgba_from(color, alpha), + ) +} + +/// Convert a blur of the design, in design-reference pixels, to the renderer's blur radius at +/// the UI scale of `tokens`; see [`DESIGN_BLUR_TO_RENDERER`]. +fn renderer_blur(design_blur: f32, tokens: &MoonThemeTokens) -> gpui::Pixels { + px(tokens.ui(design_blur * DESIGN_BLUR_TO_RENDERER)) +} + +/// Build a horizontal one-pixel rule that fades out toward both ends. +/// +/// The rule fills its parent's width. A gradient has exactly two stops, so the rule is two +/// halves: the first fades in from transparent, the second fades back out. +/// +/// Args: +/// color: The colour at the middle of the rule; its own alpha is kept there. +/// +/// Returns: +/// The rule element. +pub fn fading_rule_h(color: Hsla) -> impl IntoElement { + let [rising, falling] = fading_halves(color, 90.0); + div() + .flex() + .flex_row() + .flex_none() + .w_full() + .h(px(1.0)) + .child(div().flex_1().h_full().bg(rising)) + .child(div().flex_1().h_full().bg(falling)) +} + +/// Build a vertical one-pixel rule that fades out toward both ends. +/// +/// The rule fills its parent's height; see [`fading_rule_h`] for the construction. +/// +/// Args: +/// color: The colour at the middle of the rule; its own alpha is kept there. +/// +/// Returns: +/// The rule element. +pub fn fading_rule_v(color: Hsla) -> impl IntoElement { + let [rising, falling] = fading_halves(color, 180.0); + div() + .flex() + .flex_col() + .flex_none() + .h_full() + .w(px(1.0)) + .child(div().flex_1().w_full().bg(rising)) + .child(div().flex_1().w_full().bg(falling)) +} + +/// Build the window backdrop of a faceted theme. +/// +/// The backdrop is the window fill with a top light — `shell` lifted along the top edge and +/// settling to plain `shell` part of the way down — and two very soft ambient lights: the +/// accent around the upper-left quarter and blue around the lower-right one. Each light is a +/// one-pixel element casting a large blurred shadow, clipped to the backdrop. +/// +/// The element is absolutely positioned and fills its nearest positioned ancestor. It is +/// opaque, so it belongs behind everything else in that ancestor; it takes no pointer input. +/// `MoonRoot` paints it on its own when it owns an opaque window fill. A host whose root +/// paints no fill, to show what lies behind the scene, places it where it wants one. +/// +/// Args: +/// tokens: The theme side to build it for; supplies the skin, the palette and the UI scale. +/// +/// Returns: +/// The backdrop under [`MoonSkin::Facet`], `None` under [`MoonSkin::Flat`]. +pub fn moon_backdrop(tokens: &MoonThemeTokens) -> Option { + if !tokens.skin.is_facet() { + return None; + } + let p = tokens.palette; + let light = |color: u32, alpha: f32| moon_ambient_light(color, alpha, tokens); + + Some( + div() + .debug_selector(|| "moon-backdrop".into()) + .absolute() + .top_0() + .left_0() + .size_full() + .overflow_hidden() + .bg(backdrop_fill(p)) + .child( + light(p.accent, 0.10) + .left(relative(0.25)) + .top(relative(0.25)), + ) + .child( + light(p.blue, 0.08) + .right(relative(0.25)) + .bottom(relative(0.25)), + ) + .into_any_element(), + ) +} + +/// Build one ambient light of a faceted theme: a wide, soft pool of `color`. +/// +/// The light is a one-pixel element casting a large blurred shadow — the lights of +/// [`moon_backdrop`] are two of these. It is absolutely positioned and the caller places it; +/// the pool is centred on that point. A host whose window paints no backdrop, because something +/// shows through its root, lays one inside an opaque band of its own instead, in a parent that +/// clips it to that band. +/// +/// The skin is the caller's question: this builds the light under either one. +/// +/// Args: +/// color: The light's colour as `0xRRGGBB`. +/// alpha: The light's opacity at its centre, from 0 to 1. +/// tokens: The theme side, for the UI scale. +/// +/// Returns: +/// The light, absolutely positioned and not yet placed. +pub fn moon_ambient_light(color: u32, alpha: f32, tokens: &MoonThemeTokens) -> Div { + div() + .absolute() + .size(px(1.0)) + .shadow(vec![ambient_light(color, alpha, tokens)]) +} + +/// Build the backdrop fill: `shell` lifted along the top edge, plain `shell` from +/// [`BACKDROP_TOP_LIGHT_REACH`] of the way down. +fn backdrop_fill(p: MoonPalette) -> Background { + linear_gradient( + 180.0, + linear_color_stop(rgba_from(lift(p.shell, 0.035), 1.0), 0.0), + linear_color_stop(rgba_from(p.shell, 1.0), BACKDROP_TOP_LIGHT_REACH), + ) +} + +/// Build the shadow that makes one backdrop ambient light of `color` at `alpha`. +fn ambient_light(color: u32, alpha: f32, tokens: &MoonThemeTokens) -> BoxShadow { + box_shadow( + px(0.0), + px(0.0), + renderer_blur(BACKDROP_LIGHT_BLUR, tokens), + px(tokens.ui(BACKDROP_LIGHT_SPREAD)), + rgba_from(color, alpha), + ) +} + +/// Build the two halves of a fading rule along the gradient `angle`: transparent rising to +/// `color`, then `color` falling back to transparent. +/// +/// The transparent ends keep the hue of `color`, so the fade does not pass through grey. +fn fading_halves(color: Hsla, angle: f32) -> [Background; 2] { + let clear = color.opacity(0.0); + [ + linear_gradient( + angle, + linear_color_stop(clear, 0.0), + linear_color_stop(color, 1.0), + ), + linear_gradient( + angle, + linear_color_stop(color, 0.0), + linear_color_stop(clear, 1.0), + ), + ] +} + +/// Build an opaque gradient running from `top` at the top edge to `bottom` at the bottom edge, +/// both `0xRRGGBB`. +fn vertical(top: u32, bottom: u32) -> Background { + linear_gradient( + 180.0, + linear_color_stop(rgba_from(top, 1.0), 0.0), + linear_color_stop(rgba_from(bottom, 1.0), 1.0), + ) +} + +/// Mix `over` into `base` per sRGB channel, all `0xRRGGBB`. +/// +/// Args: +/// base: The colour at an `amount` of 0. +/// over: The colour at an `amount` of 1. +/// amount: The share of `over` in the result, clamped to 0..=1. +/// +/// Returns: +/// The mixed colour, each channel rounded to the nearest level. +fn mix(base: u32, over: u32, amount: f32) -> u32 { + let amount = amount.clamp(0.0, 1.0); + let channel = |shift: u32| { + let from = ((base >> shift) & 0xFF) as f32; + let to = ((over >> shift) & 0xFF) as f32; + ((from + (to - from) * amount).round() as u32) << shift + }; + channel(16) | channel(8) | channel(0) +} + +/// Mix `rgb` toward white by `amount`: 0 keeps the colour, 1 is white. +fn lift(rgb: u32, amount: f32) -> u32 { + mix(rgb, WHITE, amount) +} + +/// Mix `rgb` toward black by `amount`: 0 keeps the colour, 1 is black. +fn sink(rgb: u32, amount: f32) -> u32 { + mix(rgb, BLACK, amount) +} + +/// Resolve how far the top of a well is sunk below its `gutter` floor. +/// +/// A share of black, so the same share darkens a light floor many times more than a dark one: +/// 15% is barely a step on a near-black gutter and a slab of grey on a near-white one. A light +/// palette therefore sinks its wells by a fraction of what a dark one does. +fn well_sink(p: MoonPalette) -> f32 { + if p.is_light() { 0.04 } else { 0.15 } +} + +/// Resolve a highlight ink: the palette's `overlay` at `dark` on a dark palette, white at +/// `light` on a light one, where `overlay` is black and cannot light anything. +fn highlight(p: MoonPalette, dark: f32, light: f32) -> Hsla { + if p.is_light() { + rgba_from(WHITE, light) + } else { + rgba_from(p.overlay, dark) + } +} + +/// Resolve a shadow ink: the palette's `shadow` at `dark` on a dark palette and at `light` on +/// a light one, where a shadow needs far less to show. +fn shade(p: MoonPalette, dark: f32, light: f32) -> Hsla { + rgba_from(p.shadow, if p.is_light() { light } else { dark }) +} + +/// Resolve an edge colour: the `overlay` ink at `dark` on a dark palette, the opaque +/// `light_role` border entry on a light one. +fn edge(p: MoonPalette, dark: f32, light_role: u32) -> Hsla { + if p.is_light() { + rgba_from(light_role, 1.0) + } else { + rgba_from(p.overlay, dark) + } +} + +/// Build a shadow outside its element, `y` below it and blurred by the design blur `blur`, both +/// in design-reference pixels. +fn drop_shadow(y: f32, blur: f32, color: Hsla, tokens: &MoonThemeTokens) -> BoxShadow { + box_shadow( + px(0.0), + px(tokens.ui(y)), + renderer_blur(blur, tokens), + px(0.0), + color, + ) +} + +/// Build a shadow inside its element, reaching `y` down from the top edge and blurred by the +/// design blur `blur`, both in design-reference pixels. +fn inset_shadow(y: f32, blur: f32, color: Hsla, tokens: &MoonThemeTokens) -> BoxShadow { + drop_shadow(y, blur, color, tokens).inset() +} + +#[cfg(test)] +mod tests; diff --git a/crates/moon-ui-components/src/moon/skin/tests.rs b/crates/moon-ui-components/src/moon/skin/tests.rs new file mode 100644 index 0000000..8872d27 --- /dev/null +++ b/crates/moon-ui-components/src/moon/skin/tests.rs @@ -0,0 +1,540 @@ +//! Guards the facet rules — what each surface resolves to on a dark and on a light palette, and +//! how a blur of the design reaches the renderer — and where the Facet backdrop is painted. + +use gpui::{ + AppContext as _, Bounds, BoxShadow, Context, Hsla, IntoElement, Pixels, Render, TestAppContext, + Window, div, linear_color_stop, linear_gradient, point, px, +}; + +use super::{ + MoonFacetKind, MoonFacetTint, MoonSkin, ambient_light, backdrop_fill, facet, facet_glow, + facet_tinted, fading_halves, lift, mix, moon_backdrop, sink, +}; +use crate::moon::{ + MoonBackgroundPolicy, MoonRoot, MoonTheme, MoonThemeConfig, MoonThemeTokens, + foundation::ThemeMode, + tokens::{MoonPalette, rgba_from}, +}; + +const WHITE: u32 = 0xFFFFFF; + +const ALL_KINDS: [MoonFacetKind; 7] = [ + MoonFacetKind::Slab, + MoonFacetKind::Raised, + MoonFacetKind::RaisedHover, + MoonFacetKind::Well, + MoonFacetKind::Socket, + MoonFacetKind::Popup, + MoonFacetKind::Card, +]; + +/// Theme tokens for `palette` at the default scale. +fn tokens_for(palette: MoonPalette) -> MoonThemeTokens { + MoonThemeTokens { + palette, + ..MoonThemeTokens::default() + } +} + +/// Theme tokens for `palette` with every length doubled. +fn doubled_tokens_for(palette: MoonPalette) -> MoonThemeTokens { + let mut tokens = tokens_for(palette); + tokens.scale.ui = 2.0; + tokens +} + +/// An opaque gradient from `top` to `bottom`, written out rather than taken from `skin.rs`. +fn top_to_bottom(top: u32, bottom: u32) -> gpui::Background { + linear_gradient( + 180.0, + linear_color_stop(rgba_from(top, 1.0), 0.0), + linear_color_stop(rgba_from(bottom, 1.0), 1.0), + ) +} + +/// A shadow with no horizontal offset and no spread, written out rather than taken from +/// `skin.rs`. `design_blur` is the blur as the design gives it, a CSS `box-shadow` blur. +fn shadow(y: f32, design_blur: f32, color: Hsla, inset: bool) -> BoxShadow { + BoxShadow { + color, + offset: point(px(0.0), px(y)), + // The renderer's blur radius is the gaussian's standard deviation: half a CSS blur. + blur_radius: px(design_blur / 2.0), + spread_radius: px(0.0), + inset, + } +} + +/// Catches `skin.rs:mix` weighting the wrong end, or truncating a channel instead of rounding +/// it: every facet fill is built from these three, so a slip here tints or darkens all of them. +#[test] +fn lift_sink_and_mix_move_each_channel_by_the_given_share() { + // 0x20 + 0.25 * (0xFF - 0x20) = 87.75, 0x40 -> 111.75, 0x60 -> 135.75, each rounded up. + assert_eq!(lift(0x204060, 0.25), 0x587088); + // 0x20 * 0.75 = 24, 0x40 -> 48, 0x60 -> 72. + assert_eq!(sink(0x204060, 0.25), 0x183048); + // A quarter of the way from black to (0xFF, 0x80, 0x00): 63.75, 32, 0. + assert_eq!(mix(0x000000, 0xFF8000, 0.25), 0x402000); + + assert_eq!(lift(0x204060, 0.0), 0x204060); + assert_eq!(lift(0x204060, 1.0), WHITE); + assert_eq!(sink(0x204060, 1.0), 0x000000); + assert_eq!( + mix(0x204060, 0xFF8000, 2.0), + 0xFF8000, + "a share above one is clamped, not extrapolated past the end colour" + ); +} + +/// Catches a kind resolving without shadows, or without the inset one in the documented first +/// position: under the Facet skin that surface would lose its top light or its lift off the +/// surface below, on a dark palette or a light one. +#[test] +fn every_kind_stacks_an_inset_shadow_ahead_of_its_outer_ones() { + for (name, palette) in [ + ("terminal", MoonPalette::TERMINAL), + ("light", MoonPalette::LIGHT), + ("graphite", MoonPalette::GRAPHITE), + ("obsidian", MoonPalette::OBSIDIAN), + ] { + let tokens = tokens_for(palette); + for kind in ALL_KINDS { + let shadows = facet(kind, &tokens).shadows; + assert!( + !shadows.is_empty(), + "{name} {kind:?} must resolve a shadow stack" + ); + assert!( + shadows[0].inset, + "{name} {kind:?} lists its inset shadow first" + ); + assert!( + shadows.len() > 1 && shadows[1..].iter().all(|shadow| !shadow.inset), + "{name} {kind:?} follows it with outer shadows only" + ); + } + } +} + +/// Catches swapping the gradient ends in `skin.rs:facet`, which lights a raised control from +/// below so that it reads as pressed, and catches a wrong strength on its edge or shadows. +#[test] +fn a_raised_facet_runs_from_panel_high_down_to_panel_on_a_dark_palette() { + let p = MoonPalette::OBSIDIAN; + let raised = facet(MoonFacetKind::Raised, &tokens_for(p)); + + assert_eq!(raised.fill, top_to_bottom(p.panel_high, p.panel)); + assert_eq!(raised.edge, rgba_from(p.overlay, 0.09)); + assert_eq!( + raised.shadows, + vec![ + shadow(1.0, 0.0, rgba_from(p.overlay, 0.16), true), + shadow(1.0, 2.0, rgba_from(p.shadow, 0.55), false), + ] + ); + + let hover = facet(MoonFacetKind::RaisedHover, &tokens_for(p)); + assert_eq!(hover.edge, rgba_from(p.overlay, 0.2)); + assert_eq!( + hover.shadows, raised.shadows, + "hover relights the fill and the edge, and leaves the shadows alone" + ); + assert_ne!(hover.fill, raised.fill); +} + +/// Catches `skin.rs:facet` reading the dark strengths on a light palette: a 55% black shadow +/// under every light-theme button, a black "highlight" along its top and a 9% black edge +/// where the palette's own border belongs. +#[test] +fn a_light_palette_takes_its_own_borders_white_highlights_and_faint_shadows() { + let p = MoonPalette::LIGHT; + let tokens = tokens_for(p); + + let raised = facet(MoonFacetKind::Raised, &tokens); + assert_eq!(raised.edge, rgba_from(p.border, 1.0)); + assert_eq!( + raised.shadows, + vec![ + shadow(1.0, 0.0, rgba_from(WHITE, 0.9), true), + shadow(1.0, 2.0, rgba_from(p.shadow, 0.12), false), + ] + ); + + assert_eq!( + facet(MoonFacetKind::RaisedHover, &tokens).edge, + rgba_from(p.border_hover, 1.0) + ); + assert_eq!( + facet(MoonFacetKind::Popup, &tokens).shadows, + vec![ + shadow(1.0, 0.0, rgba_from(WHITE, 0.9), true), + shadow(28.0, 60.0, rgba_from(p.shadow, 0.16), false), + shadow(6.0, 16.0, rgba_from(p.shadow, 0.10), false), + ] + ); + assert_eq!( + facet(MoonFacetKind::Well, &tokens).shadows, + vec![ + shadow(1.0, 3.0, rgba_from(p.shadow, 0.12), true), + shadow(1.0, 0.0, rgba_from(WHITE, 0.8), false), + ] + ); +} + +/// Catches turning a well into one more raised surface in `skin.rs:facet`: an input would be +/// lit along its top like the button beside it instead of reading as sunk into the panel. +#[test] +fn a_well_is_darkest_at_the_top_and_carries_its_shadow_inside() { + let p = MoonPalette::OBSIDIAN; + let well = facet(MoonFacetKind::Well, &tokens_for(p)); + + // `gutter` 0x08090B sunk by 15%: 6.8, 7.65 and 9.35, rounded. + const WELL_TOP: u32 = 0x070809; + assert_eq!(well.fill, top_to_bottom(WELL_TOP, p.gutter)); + assert_eq!(well.edge, rgba_from(p.overlay, 0.06)); + assert_eq!( + well.shadows, + vec![ + shadow(1.0, 3.0, rgba_from(p.shadow, 0.7), true), + shadow(1.0, 0.0, rgba_from(p.overlay, 0.07), false), + ] + ); +} + +/// Catches a socket losing its stronger edge in `skin.rs:facet`, or drifting from the well it +/// is cut from: an unchecked checkbox would vanish into its card, or stop matching the input +/// beside it. +#[test] +fn a_socket_is_a_well_under_an_edge_that_reads_at_small_sizes() { + for p in [MoonPalette::OBSIDIAN, MoonPalette::LIGHT] { + let tokens = tokens_for(p); + let well = facet(MoonFacetKind::Well, &tokens); + let socket = facet(MoonFacetKind::Socket, &tokens); + + assert_eq!(socket.fill, well.fill); + assert_eq!(socket.shadows, well.shadows); + let expected_edge = if p.is_light() { + rgba_from(p.border_hover, 1.0) + } else { + rgba_from(p.overlay, 0.17) + }; + assert_eq!(socket.edge, expected_edge); + assert_ne!(socket.edge, well.edge); + } +} + +/// Catches `skin.rs:MoonFacet::paint` dropping one of the three parts of a surface, or setting +/// a border width or radius of its own: a component painting through it would lose its edge or +/// shadow, or have its frame resized by the skin. +#[test] +fn painting_a_facet_sets_its_fill_edge_and_shadows_and_nothing_else() { + use gpui::{Fill, StyleRefinement}; + + let surface = facet(MoonFacetKind::Raised, &tokens_for(MoonPalette::OBSIDIAN)); + let painted = surface.clone().paint(StyleRefinement::default()); + + assert_eq!(painted.background, Some(Fill::from(surface.fill))); + assert_eq!(painted.border_color, Some(surface.edge)); + assert_eq!(painted.box_shadow, Some(surface.shadows)); + assert_eq!( + StyleRefinement { + background: None, + border_color: None, + box_shadow: None, + ..painted + }, + StyleRefinement::default() + ); +} + +/// Catches a light palette's well taking the dark palette's depth in `skin.rs:well_sink`: 15% of +/// black on a near-white floor turns every input and groove into a slab of grey. +#[test] +fn a_light_well_sinks_a_fraction_of_what_a_dark_one_does() { + let p = MoonPalette::LIGHT; + let well = facet(MoonFacetKind::Well, &tokens_for(p)); + + // `gutter` 0xEEF2F6 sunk by 4%: 228.48, 232.32 and 236.16, rounded. + const WELL_TOP: u32 = 0xE4E8EC; + assert_eq!(well.fill, top_to_bottom(WELL_TOP, p.gutter)); +} + +/// Catches a facet shadow built from a raw `px(..)` instead of `tokens.ui(..)`: at a doubled UI +/// scale the control doubles and its shadow, glow or backdrop light would stay the old size. +#[test] +fn facet_lengths_follow_the_ui_scale() { + let p = MoonPalette::OBSIDIAN; + let tokens = doubled_tokens_for(p); + + // Each length is the design's, doubled: the slab's `0 18px 40px` and `0 2px 6px` shadows, + // and a glow blurred by 18. + assert_eq!( + facet(MoonFacetKind::Slab, &tokens).shadows, + vec![ + shadow(2.0, 0.0, rgba_from(p.overlay, 0.12), true), + shadow(36.0, 80.0, rgba_from(p.shadow, 0.65), false), + shadow(4.0, 12.0, rgba_from(p.shadow, 0.5), false), + ] + ); + assert_eq!( + facet_glow(p.blue, 0.26, 18.0, &tokens), + shadow(0.0, 36.0, rgba_from(p.blue, 0.26), false), + "a glow has no offset and no spread, and its design blur is scaled" + ); + // The light written out: 260 design pixels of blur, doubled and then halved for the renderer, + // around 140 of spread, doubled only. + assert_eq!( + ambient_light(p.accent, 0.10, &tokens), + BoxShadow { + color: rgba_from(p.accent, 0.10), + offset: point(px(0.0), px(0.0)), + blur_radius: px(260.0), + spread_radius: px(280.0), + inset: false, + } + ); +} + +/// Catches `skin.rs:renderer_blur` passing a design blur through as it is, or the conversion +/// reaching an offset or a spread. A blur of the design is a CSS `box-shadow` blur, twice the +/// gaussian's standard deviation that the renderer takes as its blur radius: unhalved, every +/// glow and shadow spreads twice as wide and arrives much fainter than designed. +#[test] +fn a_design_blur_reaches_the_renderer_halved_and_offsets_and_spread_do_not() { + let p = MoonPalette::OBSIDIAN; + let tokens = tokens_for(p); + + // A glow the design blurs by 18. + assert_eq!( + facet_glow(p.accent, 0.26, 18.0, &tokens), + BoxShadow { + color: rgba_from(p.accent, 0.26), + offset: point(px(0.0), px(0.0)), + blur_radius: px(9.0), + spread_radius: px(0.0), + inset: false, + } + ); + + // The slab's long shadow, `0 18px 40px` in the design: 18 down still, blurred by 20. + assert_eq!( + facet(MoonFacetKind::Slab, &tokens).shadows[1], + BoxShadow { + color: rgba_from(p.shadow, 0.65), + offset: point(px(0.0), px(18.0)), + blur_radius: px(20.0), + spread_radius: px(0.0), + inset: false, + } + ); + + // A backdrop light, `0 0 260px 140px` in the design: spread by 140 still, blurred by 130. + assert_eq!( + ambient_light(p.blue, 0.08, &tokens), + BoxShadow { + color: rgba_from(p.blue, 0.08), + offset: point(px(0.0), px(0.0)), + blur_radius: px(130.0), + spread_radius: px(140.0), + inset: false, + } + ); +} + +/// Catches `skin.rs:facet_tinted` mixing the tone over the wrong base or with the two shares +/// swapped, and catches a selected surface losing the glow that tells it from a soft one. +#[test] +fn tinted_facets_wash_the_panel_with_the_tone_and_only_selected_and_solid_glow() { + let p = MoonPalette::OBSIDIAN; + let tokens = tokens_for(p); + let tone = p.accent; + // `panel` 0x171A1F with `accent` 0xFFB347 mixed in at 14%, 6%, 20% and 8%. + const SOFT_TOP: u32 = 0x372F25; + const SOFT_BOTTOM: u32 = 0x252321; + const SELECTED_TOP: u32 = 0x453927; + const SELECTED_BOTTOM: u32 = 0x2A2622; + // `accent` lifted by 25%, sunk by 6% and sunk by 20%. + const SOLID_TOP: u32 = 0xFFC675; + const SOLID_BOTTOM: u32 = 0xF0A843; + const SOLID_EDGE: u32 = 0xCC8F39; + + let soft = facet_tinted(tone, MoonFacetTint::Soft, &tokens); + assert_eq!(soft.fill, top_to_bottom(SOFT_TOP, SOFT_BOTTOM)); + assert_eq!(soft.edge, rgba_from(tone, 0.42)); + assert_eq!( + soft.shadows, + vec![ + shadow(1.0, 0.0, rgba_from(p.overlay, 0.10), true), + shadow(1.0, 2.0, rgba_from(p.shadow, 0.55), false), + ] + ); + + let selected = facet_tinted(tone, MoonFacetTint::Selected, &tokens); + assert_eq!(selected.fill, top_to_bottom(SELECTED_TOP, SELECTED_BOTTOM)); + assert_eq!(selected.edge, rgba_from(tone, 0.7)); + let mut soft_and_glow = soft.shadows.clone(); + soft_and_glow.push(shadow(0.0, 18.0, rgba_from(tone, 0.26), false)); + assert_eq!(selected.shadows, soft_and_glow); + + let solid = facet_tinted(tone, MoonFacetTint::Solid, &tokens); + assert_eq!(solid.fill, top_to_bottom(SOLID_TOP, SOLID_BOTTOM)); + assert_eq!(solid.edge, rgba_from(SOLID_EDGE, 1.0)); + assert_eq!( + solid.shadows, + vec![ + shadow(1.0, 0.0, rgba_from(WHITE, 0.45), true), + shadow(1.0, 2.0, rgba_from(p.shadow, 0.55), false), + shadow(0.0, 18.0, rgba_from(tone, 0.32), false), + ] + ); + + let light = MoonPalette::LIGHT; + let solid_on_light = facet_tinted(light.accent, MoonFacetTint::Solid, &tokens_for(light)); + assert_eq!( + solid_on_light.shadows, + vec![ + shadow(1.0, 0.0, rgba_from(WHITE, 0.45), true), + shadow(1.0, 2.0, rgba_from(light.shadow, 0.12), false), + shadow(0.0, 18.0, rgba_from(light.accent, 0.2), false), + ], + "a light palette keeps the white highlight and takes a fainter shadow and glow" + ); +} + +/// Catches swapping the two halves in `skin.rs:fading_halves`: the rule would be solid at both +/// ends and vanish in the middle, a gap where the line belongs. +#[test] +fn a_fading_rule_rises_to_its_colour_in_the_middle_and_falls_away_again() { + let color = rgba_from(MoonPalette::OBSIDIAN.accent, 0.6); + let clear = Hsla { a: 0.0, ..color }; + + for angle in [90.0, 180.0] { + assert_eq!( + fading_halves(color, angle), + [ + linear_gradient( + angle, + linear_color_stop(clear, 0.0), + linear_color_stop(color, 1.0) + ), + linear_gradient( + angle, + linear_color_stop(color, 0.0), + linear_color_stop(clear, 1.0) + ), + ] + ); + } +} + +/// Catches the backdrop fill losing its top light, or carrying it all the way down the window +/// so that the whole window is lifted off `shell` instead of only its upper part. +#[test] +fn the_backdrop_fill_is_lit_at_the_top_and_plain_shell_from_halfway_down() { + let p = MoonPalette::OBSIDIAN; + // `shell` 0x050608 lifted by 3.5%: 13.75, 14.715 and 16.645, rounded. + const LIT_SHELL: u32 = 0x0E0F11; + + assert_eq!( + backdrop_fill(p), + linear_gradient( + 180.0, + linear_color_stop(rgba_from(LIT_SHELL, 1.0), 0.0), + linear_color_stop(rgba_from(p.shell, 1.0), 0.5), + ) + ); +} + +/// Catches `skin.rs:moon_backdrop` ignoring the skin: every flat theme would get a gradient +/// window fill and two coloured lights it never asked for. +#[test] +fn flat_tokens_have_no_backdrop() { + for palette in [ + MoonPalette::TERMINAL, + MoonPalette::LIGHT, + MoonPalette::OBSIDIAN, + ] { + assert!(moon_backdrop(&tokens_for(palette)).is_none()); + } +} + +/// Catches `skin.rs:MoonSkin::active` reading the configured dark side instead of the active +/// one, or defaulting to Facet with no theme: components would draw facets on a flat theme. +#[gpui::test] +fn the_active_skin_is_the_installed_themes_active_side(cx: &mut TestAppContext) { + cx.update(|cx| assert_eq!(MoonSkin::active(cx), MoonSkin::Flat, "no theme installed")); + + cx.update(crate::init); + cx.update(|cx| MoonTheme::install_config(MoonThemeConfig::moon_obsidian(), cx)); + cx.update(|cx| assert_eq!(MoonSkin::active(cx), MoonSkin::Facet)); + + cx.update(|cx| MoonTheme::set_mode(ThemeMode::Light, cx)); + cx.update(|cx| { + assert_eq!( + MoonSkin::active(cx), + MoonSkin::Flat, + "Obsidian's light side is flat" + ) + }); + + cx.update(|cx| MoonTheme::install_config(MoonThemeConfig::moon_terminal(), cx)); + cx.update(|cx| assert_eq!(MoonSkin::active(cx), MoonSkin::Flat)); +} + +/// The content view of the probe windows below; it paints nothing. +struct Blank; + +impl Render for Blank { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + } +} + +/// Open a window on a `MoonRoot` with `policy` and return where its backdrop was painted and +/// the size of the window it was painted in. +fn backdrop_in_a_root( + policy: MoonBackgroundPolicy, + cx: &mut TestAppContext, +) -> (Option>, gpui::Size) { + let (_root, cx) = cx.add_window_view(|window, cx| { + let view = cx.new(|_| Blank); + MoonRoot::new(view, window, cx) + .bordered(false) + .background_policy(policy) + }); + cx.run_until_parked(); + let viewport = cx.update(|window, _| window.viewport_size()); + (cx.debug_bounds("moon-backdrop"), viewport) +} + +/// Catches `root.rs:MoonRoot::render` painting the backdrop under the Flat skin, which would +/// change every existing theme, or under a root that paints no fill, where the opaque backdrop +/// would cover the GPU canvas such a root exists to show through. +#[gpui::test] +fn only_an_opaque_root_under_the_facet_skin_paints_the_backdrop(cx: &mut TestAppContext) { + cx.update(crate::init); + + cx.update(|cx| MoonTheme::install_config(MoonThemeConfig::moon_terminal(), cx)); + let (flat, _) = backdrop_in_a_root(MoonBackgroundPolicy::Opaque, cx); + assert_eq!(flat, None, "a flat theme's root has no backdrop element"); + + cx.update(|cx| MoonTheme::install_config(MoonThemeConfig::moon_obsidian(), cx)); + let (faceted, viewport) = backdrop_in_a_root(MoonBackgroundPolicy::Opaque, cx); + assert_eq!( + faceted, + Some(Bounds::new(point(px(0.0), px(0.0)), viewport)), + "the backdrop fills the window behind the content" + ); + + for policy in [ + MoonBackgroundPolicy::NoFill, + MoonBackgroundPolicy::Transparent, + ] { + let (see_through, _) = backdrop_in_a_root(policy, cx); + assert_eq!(see_through, None, "{policy:?} root must stay see-through"); + } + + cx.update(|cx| MoonTheme::set_mode(ThemeMode::Light, cx)); + let (light_side, _) = backdrop_in_a_root(MoonBackgroundPolicy::Opaque, cx); + assert_eq!(light_side, None, "Obsidian's light side is flat"); +} diff --git a/crates/moon-ui-components/src/moon/slider.rs b/crates/moon-ui-components/src/moon/slider.rs index eff3198..416d330 100644 --- a/crates/moon-ui-components/src/moon/slider.rs +++ b/crates/moon-ui-components/src/moon/slider.rs @@ -61,3 +61,6 @@ impl RenderOnce for MoonSlider { slider } } + +#[cfg(test)] +mod tests; diff --git a/crates/moon-ui-components/src/moon/slider/tests.rs b/crates/moon-ui-components/src/moon/slider/tests.rs new file mode 100644 index 0000000..a0d9746 --- /dev/null +++ b/crates/moon-ui-components/src/moon/slider/tests.rs @@ -0,0 +1,161 @@ +//! Guards the lengths and the thumb a slider draws under each skin, and that the skin never +//! changes the row a slider takes. + +use gpui::{ + AppContext as _, BoxShadow, Context, Entity, Hsla, InteractiveElement as _, IntoElement, + ParentElement as _, Render, Rgba, Styled as _, TestAppContext, VisualTestContext, Window, div, + point, px, +}; + +use super::{MoonSlider, MoonSliderState}; +use crate::moon::{MoonPalette, MoonSkin, MoonTheme, MoonThemeConfig, rgba_from}; +use crate::slider::{SliderMetrics, SliderThumbFacet}; + +/// A shadow with no horizontal offset and no spread, written out rather than taken from +/// `slider.rs`. +fn shadow(y: f32, blur: f32, color: Hsla) -> BoxShadow { + BoxShadow { + color, + offset: point(px(0.0), px(y)), + blur_radius: px(blur), + spread_radius: px(0.0), + inset: false, + } +} + +/// Catches the Flat skin picking up the groove's lengths or the faceted thumb: every flat +/// theme's slider must stay the hairline it was — a 2px track its fill covers whole, under a 7px +/// thumb — and resolve no faceted thumb, so the thumb paints its flat disc and glow alone. +#[test] +fn the_flat_skin_keeps_the_hairline_slider() { + assert_eq!( + SliderMetrics::for_skin(MoonSkin::Flat), + SliderMetrics { + track: px(2.), + fill: px(2.), + thumb: px(7.), + } + ); + assert_eq!(SliderMetrics::for_skin(MoonSkin::default()).thumb, px(7.)); + + for p in [ + MoonPalette::TERMINAL, + MoonPalette::LIGHT, + MoonPalette::OBSIDIAN, + ] { + for disabled in [false, true] { + assert_eq!( + SliderThumbFacet::resolve(MoonSkin::Flat, p, rgba_from(p.accent, 1.0), disabled), + None + ); + } + } +} + +/// Catches the Facet skin's slider drifting from its spec: the track becomes a 4px groove, the +/// fill keeps its 2px along the groove's middle, and the thumb grows to 10px. A fill as thick as +/// the groove would hide the groove; a thumb left at 7px would sit lost in it. +#[test] +fn the_facet_skin_sinks_a_groove_under_a_larger_thumb() { + assert_eq!( + SliderMetrics::for_skin(MoonSkin::Facet), + SliderMetrics { + track: px(4.), + fill: px(2.), + thumb: px(10.), + } + ); +} + +/// Catches the faceted thumb drifting from its spec: it runs from its colour mixed halfway to +/// white down to that colour, and casts a 1px-down, 2px-blur shadow at 60% under the 14px glow +/// at 72% the flat thumb has. A disabled thumb fades the fill to 42%, the shadow to 25% and the +/// glow to 14%; left at full strength it would look as usable as an enabled one. +#[test] +fn a_faceted_thumb_is_lit_from_above_and_keeps_the_flat_glow() { + let p = MoonPalette::OBSIDIAN; + let accent = rgba_from(p.accent, 1.0); + // The accent 0xFFB347 is (255, 179, 71); halfway to 255 is (255, 217, 163). + let lit_top = [255.0, 217.0, 163.0]; + + for (disabled, fill_alpha, drop_alpha, glow_alpha) in + [(false, 1.0, 0.6, 0.72), (true, 0.42, 0.25, 0.14)] + { + let thumb = + SliderThumbFacet::resolve(MoonSkin::Facet, p, accent, disabled).expect("faceted"); + + let top = Rgba::from(thumb.fill[0]); + for (level, expected) in [top.r, top.g, top.b].into_iter().zip(lit_top) { + assert!( + (level * 255.0 - expected).abs() < 0.1, + "thumb top {top:?} is not {lit_top:?}" + ); + } + assert!((top.a - fill_alpha).abs() < 1e-6, "disabled={disabled}"); + assert_eq!( + thumb.fill[1], + Hsla { + a: fill_alpha, + ..accent + }, + "disabled={disabled}" + ); + assert_eq!( + thumb.shadows, + vec![ + shadow(1.0, 2.0, rgba_from(p.shadow, drop_alpha)), + shadow( + 0.0, + 14.0, + Hsla { + a: glow_alpha, + ..accent + } + ), + ], + "disabled={disabled}" + ); + } +} + +/// A slider in a fixed-width column, inside a probe that measures the row it takes. +struct SliderHarness { + state: Entity, +} + +impl Render for SliderHarness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div().w(px(200.)).child( + div() + .debug_selector(|| "slider-row".into()) + .flex() + .child(MoonSlider::new(&self.state)), + ) + } +} + +/// Catches the Facet skin growing the row a slider takes — a container sized from the larger +/// thumb or the thicker groove instead of the fixed 18px hit area: under the faceted theme the +/// slider must take exactly the 200x18 row it takes under a flat one, so a form never reflows +/// when the theme changes. +#[gpui::test] +fn a_faceted_slider_takes_the_same_row_as_a_flat_one(cx: &mut TestAppContext) { + cx.update(crate::init); + for (config, faceted) in [ + (MoonThemeConfig::moon_terminal(), false), + (MoonThemeConfig::moon_obsidian(), true), + ] { + cx.update(|cx| MoonTheme::install_config(config, cx)); + cx.update(|cx| assert_eq!(MoonSkin::active(cx).is_facet(), faceted)); + + let window = cx.add_window(|_, cx| SliderHarness { + state: cx.new(|_| MoonSliderState::new().default_value(40.)), + }); + let mut cx = VisualTestContext::from_window(window.into(), cx); + cx.run_until_parked(); + + let row = cx.debug_bounds("slider-row").expect("slider must render"); + assert_eq!(row.size.width, px(200.), "faceted={faceted}"); + assert_eq!(row.size.height, px(18.), "faceted={faceted}"); + } +} diff --git a/crates/moon-ui-components/src/moon/status_bar.rs b/crates/moon-ui-components/src/moon/status_bar.rs index 45bb040..f398905 100644 --- a/crates/moon-ui-components/src/moon/status_bar.rs +++ b/crates/moon-ui-components/src/moon/status_bar.rs @@ -3,10 +3,12 @@ use std::fmt; use crate::status_bar::StatusBar as CoreStatusBar; +use gpui::prelude::FluentBuilder as _; use gpui::*; use super::{ foundation::MoonClickHandler, + skin::fading_rule_h, text::MoonText, theme::{MoonTheme, MoonThemeTokens}, tokens::{MoonPalette, MoonRect, MoonTone, rgba_from}, @@ -317,6 +319,18 @@ impl MoonStatusBar { self.render_with_theme(p, MoonThemeTokens::default()) } + /// Render the bar with an explicit palette and theme tokens. + /// + /// Under the Flat skin the bar paints its fill and a solid top border. Under the Facet skin + /// it paints no fill and a rule along its top that fades out toward both ends; see + /// [`status_bar_facet_rule`]. + /// + /// Args: + /// p: Palette used for the bar and its items. + /// tokens: Tokens used for the bar's geometry, its text and the theme skin. + /// + /// Returns: + /// The rendered status bar. pub fn render_with_theme(self, p: MoonPalette, tokens: MoonThemeTokens) -> impl IntoElement { let Self { id, @@ -401,6 +415,10 @@ impl MoonStatusBar { &tokens, ); + // `None` is the Flat skin. Under the Facet skin the bar paints no fill, and its top + // border keeps its pixel of height but stops painting: the fading rule added below + // takes that row. + let facet_rule = status_bar_facet_rule(p, &tokens); let mut status = CoreStatusBar::new() .left(left_row) .h(px(height)) @@ -408,8 +426,14 @@ impl MoonStatusBar { .py(px(0.0)) .px(px(0.0)) .gap(px(0.0)) - .bg(rgba_from(bg, 1.0)) - .border_color(rgba_from(border, 1.0)); + .map(|this| match facet_rule { + Some(_) => this + .bg(transparent_black()) + .border_color(transparent_black()), + None => this + .bg(rgba_from(bg, 1.0)) + .border_color(rgba_from(border, 1.0)), + }); if !right_items.is_empty() { let right_row = Self::render_items( @@ -426,7 +450,19 @@ impl MoonStatusBar { status = status.right(right_row); } - root.child(status) + let root = root.child(status); + match facet_rule { + Some(color) => root.child( + div() + .absolute() + .left_0() + .right_0() + .top_0() + .h(px(1.0)) + .child(fading_rule_h(color)), + ), + None => root, + } } /// Render ordered text and separator items into one edge region. @@ -545,6 +581,25 @@ impl MoonStatusBar { } } +/// Alpha of the `overlay` ink at the middle of a faceted status bar's top rule. +const FACET_STATUS_RULE_ALPHA: f32 = 0.09; + +/// Resolve the colour of the fading rule a status bar draws along its top under the Facet skin. +/// +/// Args: +/// p: Palette the bar paints with. +/// tokens: Tokens the bar renders with; supply the skin. +/// +/// Returns: +/// The colour at the middle of the rule, or `None` under the Flat skin, where the bar keeps +/// its solid fill and its solid top border. +fn status_bar_facet_rule(p: MoonPalette, tokens: &MoonThemeTokens) -> Option { + tokens + .skin + .is_facet() + .then(|| rgba_from(p.overlay, FACET_STATUS_RULE_ALPHA)) +} + impl RenderOnce for MoonStatusBar { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let tokens = MoonTheme::active_tokens(cx); diff --git a/crates/moon-ui-components/src/moon/status_bar/tests.rs b/crates/moon-ui-components/src/moon/status_bar/tests.rs index c332aea..d771e19 100644 --- a/crates/moon-ui-components/src/moon/status_bar/tests.rs +++ b/crates/moon-ui-components/src/moon/status_bar/tests.rs @@ -106,3 +106,47 @@ fn interactive_text_item_dispatches_click_without_external_hitbox(cx: &mut gpui: assert_status_item_interactions(cx, palette); } } + +// ---- Theme skin: what the bar paints behind its items ---- + +use super::status_bar_facet_rule; +use crate::moon::{MoonSkin, MoonThemeTokens, rgba_from}; + +/// Theme tokens for `palette` drawn with `skin`, at the default scale. +fn skin_tokens(palette: MoonPalette, skin: MoonSkin) -> MoonThemeTokens { + MoonThemeTokens { + palette, + skin, + ..MoonThemeTokens::default() + } +} + +/// Catches `status_bar.rs:status_bar_facet_rule` ignoring the skin: every flat theme's status +/// bar would lose its `shell_high` fill and its solid top border, which `render_with_theme` +/// keeps only while this answers `None`. +#[test] +fn a_flat_status_bar_keeps_its_fill_and_top_border() { + for palette in [ + MoonPalette::TERMINAL, + MoonPalette::LIGHT, + MoonPalette::OBSIDIAN, + ] { + assert_eq!( + status_bar_facet_rule(palette, &skin_tokens(palette, MoonSkin::Flat)), + None + ); + } +} + +/// Catches `status_bar.rs:status_bar_facet_rule` drawing the rule at the flat border's full +/// strength, or in another ink: a faceted bar would carry a hard line across the bottom of a +/// window whose other lines fade at their ends. +#[test] +fn a_faceted_status_bar_draws_a_faint_rule_in_the_overlay_ink() { + let p = MoonPalette::OBSIDIAN; + + assert_eq!( + status_bar_facet_rule(p, &skin_tokens(p, MoonSkin::Facet)), + Some(rgba_from(p.overlay, 0.09)) + ); +} diff --git a/crates/moon-ui-components/src/moon/tab.rs b/crates/moon-ui-components/src/moon/tab.rs index 708431f..c66d215 100644 --- a/crates/moon-ui-components/src/moon/tab.rs +++ b/crates/moon-ui-components/src/moon/tab.rs @@ -11,6 +11,7 @@ use super::button::{MoonButtonSize, MoonButtonVariant}; use super::dropdown::{MoonDropdown, MoonMenuItem}; use super::foundation::{MoonSize, accent_underline, h_flex}; use super::icons::MOON_ICON_CARET_DOWN; +use super::skin::fading_rule_h; use super::text::MoonText; use super::theme::{MoonTheme, MoonThemeTokens}; use super::tokens::{MoonPalette, MoonRect, rgba_from}; @@ -73,6 +74,7 @@ pub struct MoonTabStrip { padding_left: f32, gap: f32, overflow_menu: bool, + bottom_rule: bool, on_click: Option, on_close: Option, } @@ -86,6 +88,7 @@ impl MoonTabStrip { padding_left: 20.0, gap: 8.0, overflow_menu: false, + bottom_rule: true, on_click: None, on_close: None, } @@ -117,6 +120,16 @@ impl MoonTabStrip { self } + /// Choose whether the strip draws the line along its bottom edge. Default `true`. + /// + /// A host that seats the strip in a wider row and rules that whole row itself turns the + /// strip's own line off, so the row carries one rule rather than two laid over each other + /// across the strip's slot. + pub fn bottom_rule(mut self, bottom_rule: bool) -> Self { + self.bottom_rule = bottom_rule; + self + } + pub fn item(mut self, item: MoonTabItem) -> Self { self.items.push(item); self @@ -171,7 +184,8 @@ impl MoonTabStrip { /// window: Window used to persist the scroll handle and tab bounds. /// cx: App context used to read keyed state. /// p: Palette used for the strip and item states. - /// tokens: Scale tokens used for tab geometry. + /// tokens: Tokens used for tab geometry and for the theme skin: under the Facet skin + /// the strip paints no fill and its bottom line fades out toward both ends. /// /// Returns: /// The themed tab-strip element. @@ -222,6 +236,10 @@ impl MoonTabStrip { prev_selected.update(cx, |value, _| *value = selected_ix); } + // Under the Facet skin the strip sits on a lit surface: it paints no fill of its own and + // its bottom line fades out toward both ends. `None` is the Flat skin, which keeps the + // solid fill and the solid line. + let facet_rule = strip_facet_rule(p, &tokens); let mut root = div() .id(self.id) .relative() @@ -231,16 +249,23 @@ impl MoonTabStrip { .min_w_0() .w_full() .h(px(tab_h)) - .bg(rgba_from(p.shell_high, 1.0)) - .child( - div() - .absolute() - .left(px(0.0)) - .bottom(px(0.0)) - .w_full() - .h(px(1.0)) - .bg(rgba_from(p.border, 0.78)), - ); + .when(facet_rule.is_none(), |this| { + this.bg(rgba_from(p.shell_high, 1.0)) + }) + .when(self.bottom_rule, |this| { + this.child( + div() + .absolute() + .left(px(0.0)) + .bottom(px(0.0)) + .w_full() + .h(px(1.0)) + .map(|line| match facet_rule { + Some(color) => line.child(fading_rule_h(color)), + None => line.bg(rgba_from(p.border, 0.78)), + }), + ) + }); if let Some(bounds) = self.bounds { root = root.max_w(px(bounds.w)).max_h(px(bounds.h)); @@ -307,6 +332,26 @@ impl RenderOnce for MoonTabStrip { } } +/// Alpha of the `overlay` ink at the middle of a faceted strip's bottom rule. +const FACET_STRIP_RULE_ALPHA: f32 = 0.07; + +/// Resolve the colour of the fading rule a tab strip draws along its bottom under the Facet +/// skin. +/// +/// Args: +/// p: Palette the strip paints with. +/// tokens: Tokens the strip renders with; supply the skin. +/// +/// Returns: +/// The colour at the middle of the rule, or `None` under the Flat skin, where the strip +/// keeps its solid fill and its solid bottom line. +fn strip_facet_rule(p: MoonPalette, tokens: &MoonThemeTokens) -> Option { + tokens + .skin + .is_facet() + .then(|| rgba_from(p.overlay, FACET_STRIP_RULE_ALPHA)) +} + fn render_moon_tab( ix: usize, item: MoonTabItem, @@ -490,3 +535,6 @@ pub fn moon_active_tab_underline(p: MoonPalette) -> Div { pub fn moon_active_tab_underline_scaled(p: MoonPalette, tokens: MoonThemeTokens) -> Div { accent_underline(p, &tokens, 5.0, 5.0, 0.0) } + +#[cfg(test)] +mod tests; diff --git a/crates/moon-ui-components/src/moon/tab/tests.rs b/crates/moon-ui-components/src/moon/tab/tests.rs new file mode 100644 index 0000000..c8e04dd --- /dev/null +++ b/crates/moon-ui-components/src/moon/tab/tests.rs @@ -0,0 +1,45 @@ +//! Guards what a tab strip paints under each theme skin. + +use super::strip_facet_rule; +use crate::moon::{MoonPalette, MoonSkin, MoonThemeTokens, rgba_from}; + +/// Theme tokens for `palette` drawn with `skin`, at the default scale. +fn skin_tokens(palette: MoonPalette, skin: MoonSkin) -> MoonThemeTokens { + MoonThemeTokens { + palette, + skin, + ..MoonThemeTokens::default() + } +} + +/// Catches `tab.rs:strip_facet_rule` ignoring the skin: every flat theme would lose the strip's +/// `shell_high` fill and its solid bottom line, which `render_with_theme` keeps only while this +/// answers `None`. +#[test] +fn a_flat_strip_keeps_its_fill_and_solid_line() { + for palette in [ + MoonPalette::TERMINAL, + MoonPalette::LIGHT, + MoonPalette::GRAPHITE, + MoonPalette::OBSIDIAN, + ] { + assert_eq!( + strip_facet_rule(palette, &skin_tokens(palette, MoonSkin::Flat)), + None + ); + } +} + +/// Catches `tab.rs:strip_facet_rule` taking the palette from the tokens instead of the palette +/// the strip was asked to paint with, or drawing the rule at the flat line's strength: a +/// faceted strip would carry a hard line across a surface that is meant to fade at its ends. +#[test] +fn a_faceted_strip_draws_a_faint_rule_in_the_overlay_ink_of_its_own_palette() { + let tokens = skin_tokens(MoonPalette::TERMINAL, MoonSkin::Facet); + let p = MoonPalette::OBSIDIAN; + + assert_eq!( + strip_facet_rule(p, &tokens), + Some(rgba_from(p.overlay, 0.07)) + ); +} diff --git a/crates/moon-ui-components/src/moon/table.rs b/crates/moon-ui-components/src/moon/table.rs index 60603b8..bc85d1c 100644 --- a/crates/moon-ui-components/src/moon/table.rs +++ b/crates/moon-ui-components/src/moon/table.rs @@ -301,6 +301,52 @@ impl MoonTableStyle { selection_bar_width: self.selection_bar_width, } } + + /// Resolve what a table header paints under the Facet skin. + /// + /// A faceted table sits on a lit surface, so the header drops the palette's own header fill + /// and takes a quieter rule under it. A colour the caller chose — one that differs from the + /// palette's own table style — is kept as given. + /// + /// Args: + /// p: Palette this style was themed for. + /// tokens: Active theme tokens; supply the skin. + /// + /// Returns: + /// The header's faceted look, or `None` under the Flat skin, where the header paints + /// `header_bg` and `header_separator` as it always has. + pub(crate) fn header_facet( + &self, + p: MoonPalette, + tokens: &MoonThemeTokens, + ) -> Option { + if !tokens.skin.is_facet() { + return None; + } + let own = Self::for_palette(p); + let own_separator = self.header_separator == own.header_separator + && self.header_separator_alpha == own.header_separator_alpha; + Some(TableHeaderFacet { + fill: (self.header_bg != own.header_bg).then(|| rgba_from(self.header_bg, 1.0)), + separator: if own_separator { + rgba_from(p.overlay, FACET_HEADER_SEPARATOR_ALPHA) + } else { + rgba_from(self.header_separator, self.header_separator_alpha) + }, + }) + } +} + +/// Alpha of the `overlay` ink a faceted table header draws its separators with. +const FACET_HEADER_SEPARATOR_ALPHA: f32 = 0.07; + +/// What a table header paints under the Facet skin. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct TableHeaderFacet { + /// Fill of the header; `None` leaves it unpainted, so the surface under the table shows. + pub(crate) fill: Option, + /// Colour of the rule under the header and of the row-header gutter's edge. + pub(crate) separator: Hsla, } pub(crate) struct MoonTable; diff --git a/crates/moon-ui-components/src/moon/tag.rs b/crates/moon-ui-components/src/moon/tag.rs index c3b9fe0..b3e62d5 100644 --- a/crates/moon-ui-components/src/moon/tag.rs +++ b/crates/moon-ui-components/src/moon/tag.rs @@ -31,6 +31,13 @@ const DIMMED_OPACITY: f32 = 0.65; /// Design-reference width of the accent ring around a selected clickable tag. const SELECTED_RING_WIDTH: f32 = 2.0; +/// Opacity of the tone at the top edge of the wash an outline tag or badge carries under the +/// Facet skin. +const FACET_WASH_TOP_ALPHA: f32 = 0.14; + +/// Opacity of the tone at the bottom edge of that wash. +const FACET_WASH_BOTTOM_ALPHA: f32 = 0.04; + /// How a [`MoonTag`] paints its tone. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum MoonTagVariant { @@ -309,6 +316,46 @@ pub(super) fn tone_wash(tone: MoonTone, p: MoonPalette) -> (u32, u32) { (fill, soft_label(tone, color, fill, p)) } +/// Build the faint vertical wash of a tone that lights an outlined chip from above. +/// +/// Shared by the outline tag and the outline badge, which carry it under the Facet skin only. +/// +/// Args: +/// color: The tone as `0xRRGGBB`. +/// strength: A share from 0.0 to 1.0 that both ends of the wash are scaled by, for a chip +/// that fades its paint, such as a disabled badge. +/// +/// Returns: +/// A gradient running from the tone at [`FACET_WASH_TOP_ALPHA`] along the top edge down to +/// the tone at [`FACET_WASH_BOTTOM_ALPHA`] along the bottom edge. +pub(super) fn facet_outline_wash(color: u32, strength: f32) -> Background { + linear_gradient( + 180.0, + linear_color_stop(rgba_from(color, FACET_WASH_TOP_ALPHA * strength), 0.0), + linear_color_stop(rgba_from(color, FACET_WASH_BOTTOM_ALPHA * strength), 1.0), + ) +} + +/// Resolve the wash a tag paints over its flat background. +/// +/// Args: +/// tokens: Active theme tokens (skin and palette). +/// tone: The tag's tone. +/// variant: How the tone is painted. +/// +/// Returns: +/// The tone wash for an outline tag under the Facet skin. `None` under the Flat skin and +/// for the soft, filled and tinted variants, which keep the flat background of +/// [`tag_chrome`]. +fn tag_wash( + tokens: &MoonThemeTokens, + tone: MoonTone, + variant: MoonTagVariant, +) -> Option { + (tokens.skin.is_facet() && variant == MoonTagVariant::Outline) + .then(|| facet_outline_wash(tone.color(tokens.palette), 1.0)) +} + /// Resolve a tag's paint and geometry. /// /// The outline variant reproduces the original tag exactly; the label contrast of the soft and @@ -412,10 +459,13 @@ fn count_color(tone: MoonTone, variant: MoonTagVariant, p: MoonPalette) -> u32 { } impl RenderOnce for MoonTag { + /// Render the tag with its flat chrome and, for an outline tag under the Facet skin, the + /// tone wash of [`tag_wash`] over its transparent fill. fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { let tokens = MoonTheme::active_tokens(cx); let text = tokens.text(11.0, 14.0); let chrome = tag_chrome(&tokens, self.tone, self.variant, self.radius, self.compact); + let wash = tag_wash(&tokens, self.tone, self.variant); let p = tokens.palette; let count_color = count_color(self.tone, self.variant, p); @@ -429,6 +479,7 @@ impl RenderOnce for MoonTag { .border_1() .border_color(chrome.border) .bg(chrome.background) + .when_some(wash, |this, wash| this.bg(wash)) .text_color(rgba_from( if self.dimmed { p.text } else { chrome.text }, 1.0, diff --git a/crates/moon-ui-components/src/moon/tag/tests.rs b/crates/moon-ui-components/src/moon/tag/tests.rs index 3a64e14..831a04a 100644 --- a/crates/moon-ui-components/src/moon/tag/tests.rs +++ b/crates/moon-ui-components/src/moon/tag/tests.rs @@ -5,16 +5,16 @@ use std::{cell::Cell, rc::Rc}; use gpui::{ - Context, IntoElement, Modifiers, ParentElement as _, Render, VisualTestContext, Window, point, - px, + Context, IntoElement, Modifiers, ParentElement as _, Render, VisualTestContext, Window, + linear_color_stop, linear_gradient, point, px, }; use super::{ DIMMED_OPACITY, LABEL_CONTRAST_FLOOR, MoonTag, MoonTagRadius, MoonTagVariant, SOFT_FILL_ALPHA, - TagChrome, composite, contrast_ratio, count_color, tag_chrome, + TagChrome, composite, contrast_ratio, count_color, facet_outline_wash, tag_chrome, tag_wash, }; use crate::h_flex; -use crate::moon::{MoonPalette, MoonThemeTokens, MoonTone, rgba_from}; +use crate::moon::{MoonPalette, MoonSkin, MoonThemeTokens, MoonTone, rgba_from}; /// The status tones a soft tag is offered in, on both built-in palettes. const SOFT_TONES: [MoonTone; 4] = [ @@ -316,3 +316,168 @@ fn dimmed_label_stays_above_the_contrast_floor() { assert!(ratio >= LABEL_CONTRAST_FLOOR, "dimmed label: {ratio:.2}"); } } + +/// Every way a tag paints its tone. +const ALL_VARIANTS: [MoonTagVariant; 4] = [ + MoonTagVariant::Outline, + MoonTagVariant::Soft, + MoonTagVariant::Filled, + MoonTagVariant::Tinted, +]; + +/// Theme tokens painting `palette` with the Facet skin at the reference scale. +fn faceted_tokens_with(palette: MoonPalette) -> MoonThemeTokens { + MoonThemeTokens { + skin: MoonSkin::Facet, + ..tokens_with(palette) + } +} + +/// Catches `tag.rs:facet_outline_wash` running the wash upside down or at the wrong strength: +/// an outline chip lit from below, or a wash as heavy as a soft tag's fill. The oracle is the +/// depth study's `.d1 .tg` rule: the tone at 14% along the top edge down to 4% at the bottom. +#[test] +fn the_facet_outline_wash_fades_the_tone_from_the_top_edge_down() { + let tone = MoonPalette::OBSIDIAN.accent; + + assert_eq!( + facet_outline_wash(tone, 1.0), + linear_gradient( + 180.0, + linear_color_stop(rgba_from(tone, 0.14), 0.0), + linear_color_stop(rgba_from(tone, 0.04), 1.0), + ) + ); + assert_eq!( + facet_outline_wash(tone, 0.5), + linear_gradient( + 180.0, + linear_color_stop(rgba_from(tone, 0.07), 0.0), + linear_color_stop(rgba_from(tone, 0.02), 1.0), + ), + "a faded chip scales both ends of the wash" + ); +} + +/// Catches `tag.rs:tag_wash` answering for the Flat skin, which would put a fill behind every +/// outline tag of every existing theme, or for a soft, filled or tinted tag, whose own fill +/// the wash would replace. +#[test] +fn only_an_outline_tag_under_the_facet_skin_is_washed() { + for palette in [MoonPalette::OBSIDIAN, MoonPalette::LIGHT] { + let faceted = faceted_tokens_with(palette); + let flat = tokens_with(palette); + + for tone in SOFT_TONES { + assert_eq!( + tag_wash(&faceted, tone, MoonTagVariant::Outline), + Some(facet_outline_wash(tone.color(palette), 1.0)), + "outline {tone:?} takes the wash of its own tone" + ); + for variant in ALL_VARIANTS { + assert_eq!( + tag_wash(&flat, tone, variant), + None, + "{variant:?} {tone:?} is washed under the Flat skin" + ); + } + for variant in [ + MoonTagVariant::Soft, + MoonTagVariant::Filled, + MoonTagVariant::Tinted, + ] { + assert_eq!( + tag_wash(&faceted, tone, variant), + None, + "{variant:?} {tone:?} keeps its own fill under the Facet skin" + ); + } + } + } +} + +/// Catches `tag.rs:MoonTag::render` painting a fill behind an outline tag on a flat theme, not +/// painting the wash it resolves on the faceted one, or letting the wash replace the fill of a +/// soft, filled or tinted tag. The flat colours are the flat design's own, written out. +#[test] +fn only_the_outline_tag_of_the_faceted_theme_paints_a_wash() { + use super::super::button::scene_probe::{Painted, Pointer, painted}; + use crate::moon::MoonThemeConfig; + + let p = MoonPalette::OBSIDIAN; + let tone = MoonTone::Warning.color(p); + let obsidian_with = |skin: MoonSkin| { + let mut config = MoonThemeConfig::moon_obsidian(); + config.dark.skin = skin; + config + }; + let tag = |skin: MoonSkin, variant: MoonTagVariant| { + painted(obsidian_with(skin), Pointer::Away, move || { + MoonTag::warning().variant(variant).label("live") + }) + }; + let flat_outline = Painted::flat(rgba_from(tone, 0.0), rgba_from(tone, 0.48)); + assert_eq!(flat_outline.fill, None, "a flat outline tag has no fill"); + + assert_eq!(tag(MoonSkin::Flat, MoonTagVariant::Outline), flat_outline); + assert_eq!( + tag(MoonSkin::Facet, MoonTagVariant::Outline), + Painted { + fill: Some(linear_gradient( + 180.0, + linear_color_stop(rgba_from(tone, 0.14), 0.0), + linear_color_stop(rgba_from(tone, 0.04), 1.0), + )), + ..flat_outline + } + ); + + let unchanged = [ + ( + MoonTagVariant::Soft, + Painted::flat(rgba_from(tone, 0.14), rgba_from(tone, 0.0)), + ), + ( + MoonTagVariant::Filled, + Painted::flat(rgba_from(tone, 1.0), rgba_from(tone, 1.0)), + ), + ( + MoonTagVariant::Tinted, + Painted::flat(rgba_from(tone, 0.14), rgba_from(tone, 0.35)), + ), + ]; + for (variant, expected) in unchanged { + for skin in [MoonSkin::Flat, MoonSkin::Facet] { + assert_eq!(tag(skin, variant), expected, "{variant:?} under {skin:?}"); + } + } +} + +/// Catches `tag.rs:tag_chrome` reading the skin: the border, label, radius, padding and row +/// height of a tag are the same under both skins, so the Facet skin only ever adds the wash. +#[test] +fn the_facet_skin_leaves_the_tag_chrome_unchanged() { + for palette in [MoonPalette::OBSIDIAN, MoonPalette::LIGHT] { + for tone in SOFT_TONES { + for variant in ALL_VARIANTS { + assert_eq!( + tag_chrome( + &faceted_tokens_with(palette), + tone, + variant, + MoonTagRadius::Full, + false + ), + tag_chrome( + &tokens_with(palette), + tone, + variant, + MoonTagRadius::Full, + false + ), + "{variant:?} {tone:?}" + ); + } + } + } +} diff --git a/crates/moon-ui-components/src/moon/theme.rs b/crates/moon-ui-components/src/moon/theme.rs index 727b1ce..7fc9268 100644 --- a/crates/moon-ui-components/src/moon/theme.rs +++ b/crates/moon-ui-components/src/moon/theme.rs @@ -7,6 +7,7 @@ use super::{ colors::MoonColors, foundation::selected_flat, foundation::{MoonControlMetrics, MoonSize, ThemeMode}, + skin::MoonSkin, tokens::{MoonMetrics, MoonPalette, rgba_from}, }; use crate::theme::{Theme as BaseTheme, ThemeColor, ThemeMode as BaseThemeMode}; @@ -75,6 +76,12 @@ impl Default for MoonTypography { #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default)] pub struct MoonThemeTokens { + /// The manner in which this side draws depth; [`MoonSkin::Flat`] unless the theme names one. + /// + /// Declared ahead of the tables below: in TOML a table's plain values come before its + /// sub-tables, and some serializers write fields in declaration order and reject a plain + /// value that follows a table. + pub skin: MoonSkin, pub palette: MoonPalette, pub scale: MoonScale, pub metrics: MoonMetrics, @@ -92,6 +99,7 @@ pub struct MoonThemeTokens { impl Default for MoonThemeTokens { fn default() -> Self { Self { + skin: MoonSkin::Flat, palette: MoonPalette::TERMINAL, scale: MoonScale::default(), metrics: MoonMetrics::TERMINAL, @@ -383,6 +391,7 @@ impl Default for MoonThemeConfig { mode: ThemeMode::Dark, dark: MoonThemeTokens::default(), light: MoonThemeTokens { + skin: MoonSkin::Flat, palette: MoonPalette::LIGHT, scale: MoonScale::default(), metrics: MoonMetrics::TERMINAL, @@ -427,6 +436,18 @@ impl MoonThemeConfig { .expect("bundled moon-graphite theme must parse") } + /// The faceted theme — [`MoonPalette::OBSIDIAN`] drawn with [`MoonSkin::Facet`] on the DARK + /// side, `LIGHT` with the flat skin on the light side. + /// + /// `mode` is `Dark`, for the reason [`Self::moon_graphite`] gives: Obsidian is a dark theme + /// by its own colours. It is the only bundled theme whose file names a skin, so it is the + /// only one a component draws faceted; the skin is per side, and switching this theme to + /// its light side returns to the flat look. + pub fn moon_obsidian() -> Self { + toml::from_str(include_str!("../../themes/moon-obsidian.toml")) + .expect("bundled moon-obsidian theme must parse") + } + /// The colour modes: [`MoonColors::DARK`] on the dark side and [`MoonColors::LIGHT`] on the /// light side, in `Dark` mode. /// @@ -535,6 +556,8 @@ fn normalized_zoom(zoom: f32) -> f32 { #[derive(Clone, Debug)] pub struct MoonTheme { pub mode: ThemeMode, + /// The active side's skin; see [`MoonThemeTokens::skin`]. + pub skin: MoonSkin, pub palette: MoonPalette, pub scale: MoonScale, pub metrics: MoonMetrics, @@ -567,6 +590,7 @@ impl MoonTheme { }; Self { mode: config.mode, + skin: tokens.skin, palette: tokens.palette, scale: tokens.scale, metrics: tokens.metrics, @@ -647,6 +671,7 @@ impl MoonTheme { fn tokens(&self) -> MoonThemeTokens { MoonThemeTokens { + skin: self.skin, palette: self.palette, scale: self.scale, metrics: self.metrics, diff --git a/crates/moon-ui-components/src/moon/theme/tests.rs b/crates/moon-ui-components/src/moon/theme/tests.rs index 381b233..bbd87c8 100644 --- a/crates/moon-ui-components/src/moon/theme/tests.rs +++ b/crates/moon-ui-components/src/moon/theme/tests.rs @@ -28,6 +28,7 @@ fn selection_inks_are_readable_on_the_surface_each_one_lands_on() { for (name, palette) in [ ("dark", MoonPalette::TERMINAL), ("graphite", MoonPalette::GRAPHITE), + ("obsidian", MoonPalette::OBSIDIAN), ("light", MoonPalette::LIGHT), ("dark colour mode", MoonColors::DARK.to_palette()), ("light colour mode", MoonColors::LIGHT.to_palette()), @@ -289,3 +290,117 @@ zoom = -2.0 assert_eq!(theme.scale.zoom, 1.0); assert_eq!(theme.config.light.zoom(), 1.0); } + +/// Catches a bundled theme other than Obsidian gaining a skin, or a `MoonThemeTokens::default` +/// that is not flat: components would start drawing facets on themes whose every pixel is +/// meant to stay as it was before skins existed. +#[test] +fn every_bundled_theme_but_obsidian_is_flat_on_both_sides() { + use super::{MoonSkin, ThemeMode}; + + for (name, config) in [ + ("terminal", MoonThemeConfig::moon_terminal()), + ("light", MoonThemeConfig::moon_light()), + ("graphite", MoonThemeConfig::moon_graphite()), + ("colour modes", MoonThemeConfig::moon_color_modes()), + ("default", MoonThemeConfig::default()), + ] { + assert_eq!(config.dark.skin, MoonSkin::Flat, "{name}: dark side"); + assert_eq!(config.light.skin, MoonSkin::Flat, "{name}: light side"); + for mode in [ThemeMode::Dark, ThemeMode::Light, ThemeMode::System] { + let mut config = config.clone(); + config.mode = mode; + let theme = MoonTheme::from_config(config); + assert_eq!(theme.skin, MoonSkin::Flat, "{name}: installed in {mode:?}"); + assert_eq!( + theme.tokens().skin, + MoonSkin::Flat, + "{name}: tokens in {mode:?}" + ); + } + } + assert_eq!(MoonThemeTokens::default().skin, MoonSkin::Flat); +} + +/// Catches `themes/moon-obsidian.toml` drifting from `MoonPalette::OBSIDIAN`, losing its skin +/// line, or putting the skin on the light side: Obsidian would paint another palette, come up +/// flat, or facet a light side that is meant to be the plain light theme. +#[test] +fn obsidian_theme_config_is_the_obsidian_palette_faceted_on_the_dark_side_only() { + use super::{MoonSkin, ThemeMode}; + + let config = MoonThemeConfig::moon_obsidian(); + assert_eq!(config.mode, ThemeMode::Dark); + assert_eq!(config.dark.palette, MoonPalette::OBSIDIAN); + assert_eq!(config.dark.skin, MoonSkin::Facet); + assert_eq!(config.light.palette, MoonPalette::LIGHT); + assert_eq!(config.light.skin, MoonSkin::Flat); + + // The light side is Graphite's light side in every field, not only in its palette. + let written = |tokens: &MoonThemeTokens| toml::to_string(tokens).unwrap(); + assert_eq!( + written(&config.light), + written(&MoonThemeConfig::moon_graphite().light) + ); +} + +/// Catches `theme.rs:MoonTheme::from_config` or `MoonTheme::tokens` dropping the skin, or +/// taking it from the dark side in light mode: `MoonSkin::active` and `active_tokens` would +/// report a flat Obsidian, or a faceted light theme. +#[test] +fn the_installed_theme_carries_the_skin_of_its_active_side() { + use super::{MoonSkin, ThemeMode}; + + let dark = MoonTheme::from_config(MoonThemeConfig::moon_obsidian()); + assert_eq!(dark.skin, MoonSkin::Facet); + assert_eq!(dark.tokens().skin, MoonSkin::Facet); + + let mut config = MoonThemeConfig::moon_obsidian(); + config.mode = ThemeMode::Light; + let light = MoonTheme::from_config(config); + assert_eq!(light.skin, MoonSkin::Flat); + assert_eq!(light.tokens().skin, MoonSkin::Flat); +} + +/// Catches removing the serde default from `MoonThemeTokens::skin`, which would reject every +/// theme file written before skins existed, and catches a renamed variant, which would reject +/// the one file that names a skin. +#[test] +fn a_theme_toml_without_a_skin_is_flat_and_one_naming_facet_is_faceted() { + use super::MoonSkin; + + let legacy: MoonThemeConfig = toml::from_str( + "[dark.scale] +ui = 1.2 +[light.scale] +font = 1.1 +", + ) + .unwrap(); + assert_eq!(legacy.dark.skin, MoonSkin::Flat); + assert_eq!(legacy.light.skin, MoonSkin::Flat); + + let faceted: MoonThemeConfig = toml::from_str( + "[dark] +skin = \"Facet\" +[light.scale] +font = 1.1 +", + ) + .unwrap(); + assert_eq!(faceted.dark.skin, MoonSkin::Facet); + assert_eq!(faceted.light.skin, MoonSkin::Flat); +} + +/// Catches a `#[serde(skip)]` on `MoonThemeTokens::skin`: a host that saves the theme it +/// installed would write Obsidian back as a flat theme and lose the facets on the next start. +#[test] +fn a_written_theme_keeps_the_skin_of_each_side() { + use super::MoonSkin; + + let text = toml::to_string(&MoonThemeConfig::moon_obsidian()).unwrap(); + let read: MoonThemeConfig = toml::from_str(&text).unwrap(); + assert_eq!(read.dark.skin, MoonSkin::Facet); + assert_eq!(read.light.skin, MoonSkin::Flat); + assert_eq!(read.dark.palette, MoonPalette::OBSIDIAN); +} diff --git a/crates/moon-ui-components/src/moon/toggle.rs b/crates/moon-ui-components/src/moon/toggle.rs index e88be47..46fe399 100644 --- a/crates/moon-ui-components/src/moon/toggle.rs +++ b/crates/moon-ui-components/src/moon/toggle.rs @@ -7,7 +7,8 @@ use crate::checkbox::{ChoiceColors, MoonCheckboxMetrics, choice_text_column}; use super::{ colors::MoonColors, - foundation::{MoonSize, moon_cubic_bezier, moon_shadow_sm, snap_centered}, + foundation::{MoonSize, box_shadow, moon_cubic_bezier, moon_shadow_sm, snap_centered}, + skin::{MoonFacetKind, facet, facet_glow}, theme::{MoonTheme, MoonThemeTokens}, tokens::{MoonPalette, MoonRect, MoonTone, rgba_from}, }; @@ -514,6 +515,9 @@ struct TogglePaint { /// How far the track drops to sit on the first line of the text beside it, or `None` where /// there is no text. box_offset: Option, + /// The faceted surfaces that replace `colors` on the track and the thumb, from + /// [`ToggleFacet::resolve`]; `None` leaves the flat colours standing. + facet: Option, } impl TogglePaint { @@ -522,6 +526,9 @@ impl TogglePaint { /// It is a child of the track rather than the track itself, so that its own border never insets /// the layers placed against the track's box, and so that it can give up the cap a settled /// thumb draws. + /// + /// Under the Facet skin the same quad is a well: the facet's fill, a one-pixel edge and its + /// shadows, on the geometry the flat rail has. fn rail(&self, settled: bool) -> Div { let (left, width, left_radius, right_radius) = self.metrics.rail(self.checked, settled); let hover_group = self.hover_group.clone(); @@ -536,13 +543,28 @@ impl TogglePaint { .rounded_bl(px(left_radius)) .rounded_tr(px(right_radius)) .rounded_br(px(right_radius)) - .bg(self.colors.track.rest) - .border(px(self.metrics.border)) - .border_color(self.colors.border.rest) - .when(!self.disabled, |this| { - this.group_hover(hover_group, move |this| { - this.bg(track_hover).border_color(border_hover) - }) + .map(|rail| match &self.facet { + // A facet is only resolved for an enabled toggle, so its hover needs no gate. + Some(facet) => { + let (fill_hover, edge_hover) = + (facet.track_fill_hovered, facet.track_edge.hovered); + rail.bg(facet.track_fill) + .border_1() + .border_color(facet.track_edge.rest) + .shadow(facet.track_shadows.clone()) + .group_hover(hover_group, move |this| { + this.bg(fill_hover).border_color(edge_hover) + }) + } + None => rail + .bg(self.colors.track.rest) + .border(px(self.metrics.border)) + .border_color(self.colors.border.rest) + .when(!self.disabled, |this| { + this.group_hover(hover_group, move |this| { + this.bg(track_hover).border_color(border_hover) + }) + }), }) } @@ -552,6 +574,9 @@ impl TogglePaint { /// the outline's width. Drawing the outline as a border instead leaves the disc's own edge and /// the stroke on one curve: GPUI's quad shader mixes the fill into a 1px stroke wherever its /// inner and outer bands meet, which thins the outline and steps it around the circle. + /// + /// Under the Facet skin the thumb of either variant is one disc lit from above, with no + /// outline to draw and so no face: its gradient and its shadows set it off the track. fn thumb(&self, travel: ThumbTravel, shadow: Vec) -> AnyElement { let m = self.metrics; let size = m.thumb_size; @@ -562,6 +587,8 @@ impl TogglePaint { let outline_hover = self.colors.thumb_border.hovered; let thumb_selector = format!("{}:thumb", self.id); let face_selector = format!("{}:thumb-face", self.id); + let facet_fill = self.facet.as_ref().map(|facet| vertical(facet.thumb)); + let outlined = rides && facet_fill.is_none(); let thumb = div() .debug_selector(|| thumb_selector) @@ -576,11 +603,11 @@ impl TogglePaint { } else { face }) - .when(!disabled && rides, |this| { + .when(!disabled && outlined, |this| { this.group_hover(hover_group, move |this| this.bg(outline_hover)) }) .shadow(shadow) - .when(rides, |this| { + .when(outlined, |this| { this.child( div() .debug_selector(|| face_selector) @@ -592,7 +619,8 @@ impl TogglePaint { .rounded(px((size - ring * 2.0) * 0.5)) .bg(face), ) - }); + }) + .when_some(facet_fill, |this, fill| this.bg(fill)); let Some(from) = travel.from() else { return thumb.into_any_element(); @@ -641,8 +669,11 @@ impl TogglePaint { .bottom_0() .rounded(px(m.radius())) // A thumb that rides the track is not clipped at all: clipping would flatten - // its shadow against the track it sits on. - .when(!self.rides, |this| this.overflow_hidden()) + // its shadow against the track it sits on. Neither is a faceted thumb, whose + // glow reaches past the track it sits in. + .when(!self.rides && self.facet.is_none(), |this| { + this.overflow_hidden() + }) .child(self.thumb(travel, shadow)), ) .when(self.focused, |this| { @@ -716,6 +747,12 @@ impl RenderOnce for MoonToggle { layer.color = layer.color.opacity(ToggleColors::DISABLED_ALPHA); } } + // Under the Facet skin an enabled toggle is a well with a lit thumb, which brings its + // own shadows; a disabled one keeps the flat colours and the shadow above. + let facet = ToggleFacet::resolve(&tokens, roles, self.tone, checked, disabled); + if let Some(facet) = &facet { + thumb_shadow = facet.thumb_shadows.clone(); + } // Empty text counts as no text, so a bare toggle never gains the text column and its gap. let label = self.label.filter(|label| !label.is_empty()); @@ -738,6 +775,7 @@ impl RenderOnce for MoonToggle { focused: focus_handle.is_focused(window), ring_accent: rgba_from(self.tone.unwrap_or(MoonTone::Info).color(p), 1.0), box_offset: has_text.then(|| choice.box_offset()), + facet, }; let switch = paint.track(settled, current, thumb_shadow); @@ -971,5 +1009,149 @@ impl ToggleColors { } } +/// How far an unchecked faceted thumb's top edge is mixed toward white. +const FACET_THUMB_LIFT: f32 = 0.25; +/// How far a checked faceted thumb's top edge is mixed toward white. +const FACET_THUMB_LIFT_ON: f32 = 0.45; +/// The share of the tone washed over a checked faceted track at its top edge, then its bottom. +const FACET_TRACK_WASH: [f32; 2] = [0.16, 0.08]; +/// Opacity of the tone in a checked faceted track's edge. +const FACET_TRACK_EDGE_ALPHA: f32 = 0.45; +/// Opacity of the glow a checked faceted thumb casts in its tone. +const FACET_THUMB_GLOW_ALPHA: f32 = 0.75; +/// Blur of that glow as the design gives it, in design-reference pixels. +const FACET_THUMB_GLOW_BLUR: f32 = 10.0; + +/// The surfaces a toggle paints under the Facet skin, in place of its [`ToggleColors`]. +/// +/// The track is a well sunk into the surface and the thumb a disc lit from above. Both variants +/// paint the same surfaces; they keep their own geometry. +#[derive(Clone, Debug, PartialEq)] +struct ToggleFacet { + /// The track's fill at rest. + track_fill: Background, + /// The track's fill under the pointer, equal to `track_fill` where hovering changes nothing. + track_fill_hovered: Background, + /// The track's one-pixel edge. + track_edge: Hovered, + /// The well's shadow stack, which the track keeps whether the toggle is on or off. + track_shadows: Vec, + /// The thumb's fill: the colour at its top edge, then the one at its bottom edge. + thumb: [Hsla; 2], + /// The thumb's shadow on the track, followed by its glow once the toggle is on. + thumb_shadows: Vec, +} + +impl ToggleFacet { + /// Resolves a toggle's faceted surfaces. + /// + /// An unchecked toggle is the skin's well under a thumb of the muted text ink. A checked one + /// washes the well with its tone, edges it in that tone, and lights the thumb in it with a + /// glow. The tone is the one set on the toggle, or the palette accent that `bg_brand_solid` + /// stands for. + /// + /// Hovering follows [`ToggleColors::resolve`]: only a checked track with no tone of its own + /// answers to the pointer, by taking `bg_brand_solid_hover` for its tone. A disabled toggle + /// keeps the flat colours that function mixes into the surface, so it resolves no facet. + /// + /// Args: + /// tokens: The active theme side; supplies the skin, the palette and the UI scale. + /// roles: The active colour roles, for the brand's hover colour. + /// tone: The tone set on the toggle, or `None` for the accent. + /// checked: Whether the toggle is on. + /// disabled: Whether the toggle is disabled. + /// + /// Returns: + /// The surfaces to paint, or `None` where the flat colours stand: under the Flat skin and + /// on a disabled toggle. + fn resolve( + tokens: &MoonThemeTokens, + roles: MoonColors, + tone: Option, + checked: bool, + disabled: bool, + ) -> Option { + if !tokens.skin.is_facet() || disabled { + return None; + } + let p = tokens.palette; + let well = facet(MoonFacetKind::Socket, tokens); + let drop = box_shadow( + px(0.0), + px(tokens.ui(1.0)), + px(tokens.ui(2.0)), + px(0.0), + rgba_from(p.shadow, thumb_drop_alpha(p)), + ); + if !checked { + return Some(Self { + track_fill: well.fill, + track_fill_hovered: well.fill, + track_edge: Hovered::flat(well.edge), + track_shadows: well.shadows, + thumb: lit(rgba_from(p.text_muted, 1.0), FACET_THUMB_LIFT), + thumb_shadows: vec![drop], + }); + } + + let tone_rgb = tone.map_or(p.accent, |tone| tone.color(p)); + let rest = rgba_from(tone_rgb, 1.0); + let hovered = match tone { + Some(_) => rest, + None => roles.bg_brand_solid_hover.into(), + }; + Some(Self { + track_fill: vertical(washed_well(p, rest)), + track_fill_hovered: vertical(washed_well(p, hovered)), + track_edge: Hovered { + rest: rest.opacity(FACET_TRACK_EDGE_ALPHA), + hovered: hovered.opacity(FACET_TRACK_EDGE_ALPHA), + }, + track_shadows: well.shadows, + thumb: lit(rest, FACET_THUMB_LIFT_ON), + thumb_shadows: vec![ + drop, + facet_glow( + tone_rgb, + FACET_THUMB_GLOW_ALPHA, + FACET_THUMB_GLOW_BLUR, + tokens, + ), + ], + }) + } +} + +/// Returns the opacity of the shadow a faceted thumb casts on its track: strong on a dark +/// palette, and far weaker on a light one, where a shadow needs little to show. +fn thumb_drop_alpha(p: MoonPalette) -> f32 { + if p.is_light() { 0.2 } else { 0.6 } +} + +/// Returns the two ends of a checked faceted track's fill: `tone` washed over the palette's +/// `gutter` by [`FACET_TRACK_WASH`], top edge first. +/// +/// The tone's own alpha thins the wash, so a translucent hover colour tints the well less than +/// the opaque colour it stands in for. +fn washed_well(p: MoonPalette, tone: Hsla) -> [Hsla; 2] { + let gutter = rgba_from(p.gutter, 1.0); + FACET_TRACK_WASH.map(|share| gutter.blend(tone.opacity(share))) +} + +/// Returns a surface lit from above in `color`: `color` mixed toward white by `lift` for the top +/// edge, then `color` itself for the bottom edge. +fn lit(color: Hsla, lift: f32) -> [Hsla; 2] { + [color.blend(white().opacity(lift)), color] +} + +/// Builds the gradient running from `stops[0]` at the top edge to `stops[1]` at the bottom edge. +fn vertical(stops: [Hsla; 2]) -> Background { + linear_gradient( + 180.0, + linear_color_stop(stops[0], 0.0), + linear_color_stop(stops[1], 1.0), + ) +} + #[cfg(test)] mod tests; diff --git a/crates/moon-ui-components/src/moon/toggle/tests.rs b/crates/moon-ui-components/src/moon/toggle/tests.rs index 957b84f..8240589 100644 --- a/crates/moon-ui-components/src/moon/toggle/tests.rs +++ b/crates/moon-ui-components/src/moon/toggle/tests.rs @@ -2,15 +2,16 @@ use super::{ MoonToggle, MoonToggleLabelSide, MoonToggleSize, MoonToggleVariant, THUMB_TRAVEL, - THUMB_TRAVEL_CURVE, ThumbTravel, ToggleColors, moon_toggle_click_plan, + THUMB_TRAVEL_CURVE, ThumbTravel, ToggleColors, ToggleFacet, moon_toggle_click_plan, vertical, + washed_well, }; use crate::checkbox::{ChoiceColors, MoonCheckboxMetrics}; use crate::moon::checkbox::tier_size; use crate::moon::{ - MoonColors, MoonPalette, MoonScale, MoonSize, MoonThemeConfig, MoonThemeTokens, MoonTone, - rgba_from, + MoonColors, MoonFacetKind, MoonPalette, MoonScale, MoonSize, MoonTheme, MoonThemeConfig, + MoonThemeTokens, MoonTone, facet, rgba_from, }; -use gpui::{px, size}; +use gpui::{BoxShadow, Hsla, Rgba, point, px, size}; /// Catches changing a tier's unscaled reference geometry in `MoonToggleSize::reference_metrics` /// away from the reviewed designer reference (Sm 36x20 track/16 thumb/14px-20 text/8 gap, Md @@ -381,6 +382,39 @@ fn render_toggle( #[gpui::test] fn track_and_thumb_render_at_the_reviewed_size(cx: &mut gpui::TestAppContext) { cx.update(crate::init); + assert_track_and_thumb_at_the_reviewed_size(cx); +} + +/// Catches the Facet skin moving a toggle off its reviewed geometry — a facet edge that insets +/// the thumb, a thumb resized to the design mock-up's, or a slim thumb pulled inside its track: +/// under the faceted theme every variant, tier and state must measure exactly as under a flat +/// one, so switching themes never reflows a toolbar. A faceted slim thumb is one lit disc, so it +/// draws no face. +#[gpui::test] +fn a_faceted_toggle_keeps_the_reviewed_geometry(cx: &mut gpui::TestAppContext) { + cx.update(crate::init); + cx.update(|cx| MoonTheme::install_config(MoonThemeConfig::moon_obsidian(), cx)); + cx.update(|cx| assert!(MoonTheme::active_tokens(cx).skin.is_facet())); + + assert_track_and_thumb_at_the_reviewed_size(cx); + + let mut cx = render_toggle( + cx, + SizedToggleHarness { + variant: MoonToggleVariant::Slim, + ..SizedToggleHarness::bare(MoonSize::Sm, true) + }, + ); + assert!(cx.debug_bounds("sized:thumb").is_some()); + assert!( + cx.debug_bounds("sized:thumb-face").is_none(), + "a faceted thumb has no outline, so it needs no face" + ); +} + +/// Renders every variant, tier and state under the installed theme and asserts the reviewed +/// track and thumb geometry. +fn assert_track_and_thumb_at_the_reviewed_size(cx: &mut gpui::TestAppContext) { // A default toggle keeps its thumb 2px inside the track; a slim one's thumb is the track's // own height, so it sits flush against every edge. for (variant, tier, track, thumb, gap) in [ @@ -914,3 +948,203 @@ fn the_thumb_keeps_one_gap_on_every_side_at_any_scale_factor(cx: &mut gpui::Test } } } + +/// The sRGB channel levels of `color`, each from 0 to 255 and left unrounded. +fn levels(color: Hsla) -> [f32; 3] { + let color = Rgba::from(color); + [color.r * 255.0, color.g * 255.0, color.b * 255.0] +} + +/// Asserts `color` sits at `expected` levels per channel, to within a tenth of a level. +fn assert_levels(color: Hsla, expected: [f32; 3], what: &str) { + let actual = levels(color); + for (actual_level, expected_level) in actual.into_iter().zip(expected) { + assert!( + (actual_level - expected_level).abs() < 0.1, + "{what}: {actual:?} is not {expected:?}" + ); + } +} + +/// A shadow straight below its element with no spread, written out rather than taken from +/// `toggle.rs`. `blur` is the renderer's blur radius, which the thumb's own shadow is built in. +fn shadow_below(y: f32, blur: f32, color: Hsla) -> BoxShadow { + BoxShadow { + color, + offset: point(px(0.0), px(y)), + blur_radius: px(blur), + spread_radius: px(0.0), + inset: false, + } +} + +/// A glow centred on its element with no spread, written out rather than taken from `skin.rs`. +/// `design_blur` is the blur as the design gives it, a CSS `box-shadow` blur. +fn glow(design_blur: f32, color: Hsla) -> BoxShadow { + // The renderer's blur radius is the gaussian's standard deviation: half a CSS blur. + shadow_below(0.0, design_blur / 2.0, color) +} + +/// Catches `toggle.rs:ToggleFacet::resolve` ignoring the skin or the disabled flag: every flat +/// theme's toggles would be repainted as wells, or a disabled toggle on the faceted theme would +/// lose the flat colours the design mixes into the surface. With no facet resolved, a toggle +/// paints `ToggleColors` alone, exactly as it did before skins existed. +#[test] +fn flat_themes_and_disabled_toggles_resolve_no_facet() { + let flat_sides = [ + MoonThemeConfig::moon_terminal().dark, + MoonThemeConfig::moon_terminal().light, + MoonThemeConfig::moon_graphite().dark, + MoonThemeConfig::moon_obsidian().light, + MoonThemeTokens::default(), + ]; + for tokens in &flat_sides { + let roles = MoonColors::from_palette(tokens.palette); + for tone in [None, Some(MoonTone::Info)] { + for checked in [false, true] { + for disabled in [false, true] { + assert_eq!( + ToggleFacet::resolve(tokens, roles, tone, checked, disabled), + None, + "a flat theme never resolves a facet" + ); + } + } + } + } + + let faceted = MoonThemeConfig::moon_obsidian().dark; + let roles = MoonColors::from_palette(faceted.palette); + for checked in [false, true] { + assert_eq!( + ToggleFacet::resolve(&faceted, roles, None, checked, true), + None, + "a disabled toggle keeps the flat colours" + ); + assert!(ToggleFacet::resolve(&faceted, roles, None, checked, false).is_some()); + } +} + +/// Catches an unchecked faceted toggle drifting from its spec: the track is the skin's socket — +/// a well under an edge that reads at the track's size — with the same fill and edge under +/// the pointer, and the thumb runs from the muted text ink lifted a quarter of the way to white +/// down to that ink, over a 1px-down, 2px-blur shadow at 60%. A thumb lit from below, or one +/// carrying the checked glow, would read as switched on. +#[test] +fn an_unchecked_faceted_toggle_is_a_well_under_a_muted_thumb() { + let tokens = MoonThemeConfig::moon_obsidian().dark; + let p = tokens.palette; + let roles = MoonColors::from_palette(p); + let well = facet(MoonFacetKind::Socket, &tokens); + + let off = ToggleFacet::resolve(&tokens, roles, None, false, false).expect("faceted"); + assert_eq!(off.track_fill, well.fill); + assert_eq!(off.track_fill_hovered, well.fill); + assert_eq!(off.track_edge.rest, rgba_from(p.overlay, 0.17)); + assert_eq!(off.track_edge.hovered, off.track_edge.rest); + assert_eq!(off.track_shadows, well.shadows); + + // `text_muted` 0x878279 is (135, 130, 121); a quarter of the way to 255 is +30, +31.25, +33.5. + assert_levels(off.thumb[0], [165.0, 161.25, 154.5], "thumb top"); + assert_eq!(off.thumb[1], rgba_from(p.text_muted, 1.0)); + assert_eq!( + off.thumb_shadows, + vec![shadow_below(1.0, 2.0, rgba_from(p.shadow, 0.6))] + ); +} + +/// Catches a checked faceted toggle drifting from its spec, with the accent and with an explicit +/// tone: the well is washed with the tone, 16% at the top and 8% at the bottom, and edged in it +/// at 45%; the thumb runs from the tone lifted 45% toward white down to the tone, and adds a +/// glow in the tone at 75% to its shadow — 10px of blur in the design, so 5px at the renderer. +/// Swapping the two washes lights the track from below; dropping the tone repaints every toned +/// toggle in the accent. +#[test] +fn a_checked_faceted_toggle_washes_its_well_and_lights_its_thumb_in_the_tone() { + let tokens = MoonThemeConfig::moon_obsidian().dark; + let p = tokens.palette; + let roles = MoonColors::from_palette(p); + let well = facet(MoonFacetKind::Socket, &tokens); + + // `gutter` 0x08090B is (8, 9, 11). The accent 0xFFB347 is (255, 179, 71) and the Info tone, + // `blue` 0x7FC9FF, is (127, 201, 255). + for (tone, rgb, wash_top, wash_bottom, thumb_top) in [ + ( + None, + p.accent, + [47.52, 36.2, 20.6], + [27.76, 22.6, 15.8], + [255.0, 213.2, 153.8], + ), + ( + Some(MoonTone::Info), + p.blue, + [27.04, 39.72, 50.04], + [17.52, 24.36, 30.52], + [184.6, 225.3, 255.0], + ), + ] { + let ink = rgba_from(rgb, 1.0); + let on = ToggleFacet::resolve(&tokens, roles, tone, true, false).expect("faceted"); + + let [top, bottom] = washed_well(p, ink); + assert_levels(top, wash_top, "track top"); + assert_levels(bottom, wash_bottom, "track bottom"); + assert_eq!(on.track_fill, vertical([top, bottom])); + assert_eq!(on.track_edge.rest, Hsla { a: 0.45, ..ink }); + assert_eq!(on.track_shadows, well.shadows); + + assert_levels(on.thumb[0], thumb_top, "thumb top"); + assert_eq!(on.thumb[1], ink); + assert_eq!( + on.thumb_shadows, + vec![ + shadow_below(1.0, 2.0, rgba_from(p.shadow, 0.6)), + glow(10.0, rgba_from(rgb, 0.75)), + ] + ); + } +} + +/// Catches the faceted track losing the one hover a flat toggle has, or gaining one it has not: +/// a checked track with no tone takes `bg_brand_solid_hover` for its tone under the pointer — +/// on a legacy palette the accent at 82%, which thins the wash and the edge by that much — while +/// a toned track keeps its fill and edge, as a toned flat track keeps its fill. +#[test] +fn only_an_untoned_faceted_track_answers_to_the_pointer() { + let tokens = MoonThemeConfig::moon_obsidian().dark; + let p = tokens.palette; + let roles = MoonColors::from_palette(p); + + let brand = ToggleFacet::resolve(&tokens, roles, None, true, false).expect("faceted"); + let [top, bottom] = washed_well(p, roles.bg_brand_solid_hover.into()); + // The accent's wash at 209/255 of its strength: 16% becomes 13.11% and 8% becomes 6.56%. + assert_levels(top, [40.39, 31.29, 18.87], "hovered track top"); + assert_levels(bottom, [24.20, 20.15, 14.93], "hovered track bottom"); + assert_eq!(brand.track_fill_hovered, vertical([top, bottom])); + assert_ne!(brand.track_fill_hovered, brand.track_fill); + assert!((brand.track_edge.hovered.a - 0.45 * 209.0 / 255.0).abs() < 1e-4); + + let toned = + ToggleFacet::resolve(&tokens, roles, Some(MoonTone::Info), true, false).expect("faceted"); + assert_eq!(toned.track_fill_hovered, toned.track_fill); + assert_eq!(toned.track_edge.hovered, toned.track_edge.rest); +} + +/// Catches a facet shadow built from a raw `px(..)`: at a doubled UI scale the thumb doubles, and +/// its shadow and glow would stay the size they have at 100%. +#[test] +fn faceted_thumb_shadows_follow_the_ui_scale() { + let tokens = MoonThemeConfig::moon_obsidian().with_ui_scale(2.0).dark; + let p = tokens.palette; + let on = ToggleFacet::resolve(&tokens, MoonColors::from_palette(p), None, true, false) + .expect("faceted"); + // The thumb's 1px-down, 2px shadow and its glow's 10 design pixels of blur, each doubled. + assert_eq!( + on.thumb_shadows, + vec![ + shadow_below(2.0, 4.0, rgba_from(p.shadow, 0.6)), + glow(20.0, rgba_from(p.accent, 0.75)), + ] + ); +} diff --git a/crates/moon-ui-components/src/moon/tokens.rs b/crates/moon-ui-components/src/moon/tokens.rs index b2a0495..5c8fdc1 100644 --- a/crates/moon-ui-components/src/moon/tokens.rs +++ b/crates/moon-ui-components/src/moon/tokens.rs @@ -278,6 +278,82 @@ impl MoonPalette { yellow: 0xFFD93D, }; + /// The palette of the faceted "Obsidian" theme: near-black surfaces that the + /// [`MoonSkin::Facet`] skin lights from above, darker than [`Self::TERMINAL`]. + /// + /// It is a DARK palette by measurement, not by declaration — `shell` has a luma of 5.9, so + /// [`Self::is_light`] answers `false` and every consumer that branches on it takes the dark + /// arm with no new code, exactly as [`Self::GRAPHITE`] does. What makes Obsidian look + /// different is the skin its theme file selects, never a third colour table. + /// + /// The surface steps are wider apart than `TERMINAL`'s, and the skin depends on that: a + /// facet's fill is a gradient between two palette entries, so it reads as depth only where + /// those entries differ. `panel_high` and `panel`, the two ends of a raised control, are 11 + /// to 14 levels apart per channel here and 1 to 2 in `TERMINAL`; `shell` has about a + /// quarter of `TERMINAL`'s relative luminance, and `gutter` stays below `panel` so a well + /// reads as sunk. Moving those entries toward each other flattens every facet at once. + /// + /// Measured with [`contrast_ratio`] against the WCAG floor of 4.5:1 — `text` 15.99 / 15.99 / + /// 13.76 and `text_muted` 5.31 / 5.31 / 4.57 on `shell` / `window` / `panel`. `text_muted` + /// is NOT the dark palette's `0x7D7669`, which measures 3.88 on this `panel`. Even the + /// lifted ink misses the floor on `panel_high` (3.98), the lit top of a raised facet: muted + /// ink belongs on `panel` and below, and `text_soft` (5.91) is the quiet ink for a raised + /// control. + /// + /// `green` and `red` are lighter than `TERMINAL`'s at nearly the same hue, because on a + /// near-black surface they are inks as often as fills: `red` measures 6.80 / 5.85 / 6.12 on + /// `shell` / `panel` / `card`, where `TERMINAL`'s `0xE5484D` would fall to 4.46 on `panel` + /// (see `GRAPHITE` on the outline danger button). The accent hues are carried over from + /// `TERMINAL` unchanged. + /// + /// [`MoonSkin::Facet`]: super::MoonSkin::Facet + pub const OBSIDIAN: Self = Self { + shell: 0x050608, + shell_high: 0x0D0F12, + window: 0x050608, + surface: 0x0A0B0E, + panel: 0x171A1F, + panel_high: 0x22262D, + chrome: 0x0D0F12, + tabbar: 0x0D0F12, + panel_head: 0x1A1D22, + gutter: 0x08090B, + chart_bg: 0x08090B, + card: 0x13151A, + row_alt: 0x0D0F12, + head_row: 0x101216, + border: 0x1F2227, + border_soft: 0x1A1D21, + border_card: 0x1F2227, + border_hover: 0x3A3F47, + row_line: 0x1A1D21, + shadow: 0x000000, + overlay: 0xFFFFFF, + on_accent: 0xFFFFFF, + text: 0xE8E4DC, + text_soft: 0xA6A198, + text_dim: 0xE8E4DC, + text_muted: 0x878279, + text_faint: 0x878279, + table_head: 0x101216, + table_body: 0x0D0F12, + table_selected: 0xFFB347, + table_hover: 0x171A1F, + green: 0x34C07A, + green_btn: 0x1E8C5B, + green_text: 0x34C07A, + red: 0xF26A69, + red_text: 0xF26A69, + red_soft_bd: 0xF26A69, + orange: 0xFF8E5A, + amber: 0xFFB347, + blue: 0x7FC9FF, + accent: 0xFFB347, + accent_fg: 0xFFCF94, + accent_tint_a: 0.11, + yellow: 0xFFD93D, + }; + pub fn with_legacy_defaults(mut self) -> Self { if self.window == 0 { self.window = self.shell; diff --git a/crates/moon-ui-components/src/moon/tokens/tests.rs b/crates/moon-ui-components/src/moon/tokens/tests.rs index a0be816..1a6f71d 100644 --- a/crates/moon-ui-components/src/moon/tokens/tests.rs +++ b/crates/moon-ui-components/src/moon/tokens/tests.rs @@ -16,6 +16,7 @@ fn selected_row_ink_stays_readable_on_the_panel_it_is_tinted_over() { for (name, p) in [ ("dark", MoonPalette::TERMINAL), ("graphite", MoonPalette::GRAPHITE), + ("obsidian", MoonPalette::OBSIDIAN), ("light", MoonPalette::LIGHT), ] { let ratio = contrast_ratio(p.selected_fg(), p.panel); @@ -36,6 +37,7 @@ fn ink_on_a_solid_fill_is_readable_in_both_palettes() { for (name, p) in [ ("dark", MoonPalette::TERMINAL), ("graphite", MoonPalette::GRAPHITE), + ("obsidian", MoonPalette::OBSIDIAN), ("light", MoonPalette::LIGHT), ] { for (fill_name, fill) in [("accent", p.accent), ("panel", p.panel)] { @@ -93,6 +95,48 @@ fn graphite_text_and_muted_text_clear_the_surface_contrast_floor() { } } +/// Catches raising `tokens.rs:MoonPalette::OBSIDIAN.shell` above the dark luma threshold. +/// A light classification flips every `is_light()` consumer to the light branch at once, and +/// hands the Facet skin its light-palette strengths on a near-black theme. +#[test] +fn obsidian_palette_is_a_dark_palette_by_measured_shell_luma() { + assert!(!MoonPalette::OBSIDIAN.is_light()); +} + +/// Catches darkening Obsidian's `text_muted` back toward the dark palette's value, which +/// measures 3.88:1 on this panel. Text and muted text on Obsidian's shell, window, or panel +/// must remain readable at the WCAG floor. +#[test] +fn obsidian_text_and_muted_text_clear_the_surface_contrast_floor() { + let palette = MoonPalette::OBSIDIAN; + for (ink_name, ink) in [("text", palette.text), ("text_muted", palette.text_muted)] { + for (surface_name, surface) in [ + ("shell", palette.shell), + ("window", palette.window), + ("panel", palette.panel), + ] { + let ratio = contrast_ratio(ink, surface); + assert!( + ratio >= SELECTED_TEXT_CONTRAST_FLOOR, + "Obsidian {ink_name} on {surface_name} is {ratio:.2}:1, below {SELECTED_TEXT_CONTRAST_FLOOR}:1" + ); + } + } +} + +/// Catches flattening Obsidian's surface steps: the Facet skin fills a raised control from +/// `panel_high` down to `panel`, a popup from `panel_high` down to `card` and a well with +/// `gutter`, so a `panel_high` no lighter than the other two draws those facets flat or lit +/// from below, and a `gutter` no darker than `panel` makes a well stand out instead of in. +#[test] +fn obsidian_surface_steps_keep_the_order_the_facet_skin_lights() { + let luminance = super::relative_luminance; + let p = MoonPalette::OBSIDIAN; + assert!(luminance(p.panel) < luminance(p.panel_high)); + assert!(luminance(p.card) < luminance(p.panel_high)); + assert!(luminance(p.gutter) < luminance(p.panel)); +} + /// Catches changing `tokens.rs:MoonPalette::LIGHT` away from the neutral-terminal design /// specification, which would break semantic contrast and status coloring in the light theme. #[test] diff --git a/crates/moon-ui-components/src/moon/tooltip.rs b/crates/moon-ui-components/src/moon/tooltip.rs index b80746f..f044bcf 100644 --- a/crates/moon-ui-components/src/moon/tooltip.rs +++ b/crates/moon-ui-components/src/moon/tooltip.rs @@ -1,7 +1,9 @@ +use gpui::prelude::FluentBuilder as _; use gpui::*; use super::{ icons::{MOON_ICON_TOOLTIP_ARROW, moon_icon}, + skin::{MoonFacet, MoonFacetKind, facet}, text::MoonText, theme::{MoonTheme, MoonThemeTokens}, tokens::{MoonPalette, MoonRect, MoonTone, rgba_from}, @@ -254,6 +256,17 @@ impl ParentElement for MoonTooltip { } impl RenderOnce for MoonTooltip { + /// Render the tooltip box, its text or custom children, an optional shortcut and the arrow. + /// + /// The box is the solid Moon surface under the Flat skin and a popup surface under the + /// Facet skin; see [`tooltip_facet`]. + /// + /// Args: + /// _window: Window the tooltip renders in; unused. + /// cx: Application context used to resolve the active palette and theme tokens. + /// + /// Returns: + /// The rendered tooltip. fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { let p = MoonPalette::active(cx); let tokens = MoonTheme::active_tokens(cx); @@ -274,11 +287,22 @@ impl RenderOnce for MoonTooltip { .id(ElementId::from(self.id.clone())) .relative() .min_h(px(metrics.min_height)) - .rounded(px(metrics.radius)) - .border(px(1.0)) - .border_color(rgba_from(p.border, 1.0)) - .bg(rgba_from(p.shell_high, 0.98)) - .shadow(vec![shadow]) + // `None` is the Flat skin. A faceted tooltip is a popup surface: gradient fill, lit + // edge and the deeper shadow of something floating above the window. + .map(|this| match tooltip_facet(&tokens, self.size) { + Some((surface, radius)) => this + .rounded(px(radius)) + .border(px(1.0)) + .border_color(surface.edge) + .bg(surface.fill) + .shadow(surface.shadows), + None => this + .rounded(px(metrics.radius)) + .border(px(1.0)) + .border_color(rgba_from(p.border, 1.0)) + .bg(rgba_from(p.shell_high, 0.98)) + .shadow(vec![shadow]), + }) .occlude() .flex() .items_center() @@ -375,7 +399,74 @@ impl RenderOnce for MoonTooltip { } } +/// Design-reference corner radius of a normal tooltip under the Facet skin. +const FACET_TOOLTIP_RADIUS: f32 = 8.0; +/// Design-reference corner radius of a compact tooltip under the Facet skin. +const FACET_TOOLTIP_COMPACT_RADIUS: f32 = 6.0; + +/// Resolve the surface a tooltip paints under the Facet skin. +/// +/// Args: +/// tokens: Active theme tokens; supply the skin, the palette and the UI scale. +/// size: The tooltip's size; a custom size keeps the radius its caller chose. +/// +/// Returns: +/// The popup surface and the corner radius in rendered pixels, or `None` under the Flat +/// skin, where the tooltip keeps its solid fill, its palette border and its soft shadow. +fn tooltip_facet(tokens: &MoonThemeTokens, size: MoonTooltipSize) -> Option<(MoonFacet, f32)> { + if !tokens.skin.is_facet() { + return None; + } + let radius = match size { + MoonTooltipSize::Compact => FACET_TOOLTIP_COMPACT_RADIUS, + MoonTooltipSize::Normal => FACET_TOOLTIP_RADIUS, + MoonTooltipSize::Custom { radius, .. } => radius, + }; + Some((facet(MoonFacetKind::Popup, tokens), tokens.ui(radius))) +} + +/// Resolve the colour of the tooltip arrow, as `0xRRGGBB` and alpha. +/// +/// The arrow continues the tooltip's fill past one of its edges. Under the Flat skin that fill +/// is one colour. A faceted tooltip is lit from above — its fill runs from `panel_high` at the +/// top down to `card` — so the arrow takes the colour of the edge it hangs from: `card` below +/// a tooltip placed above its target, `panel_high` on the top edge and near the top of a side. +/// +/// Args: +/// p: Palette the tooltip paints with. +/// tokens: Active theme tokens; supply the skin. +/// placement: Where the tooltip sits relative to its target. +/// +/// Returns: +/// The arrow's colour and alpha. +fn tooltip_arrow_color( + p: MoonPalette, + tokens: &MoonThemeTokens, + placement: MoonTooltipPlacement, +) -> (u32, f32) { + if !tokens.skin.is_facet() { + return (p.shell_high, 0.98); + } + match placement { + MoonTooltipPlacement::Top => (p.card, 1.0), + MoonTooltipPlacement::Bottom | MoonTooltipPlacement::Left | MoonTooltipPlacement::Right => { + (p.panel_high, 1.0) + } + } +} + impl MoonTooltip { + /// Render the arrow that points from the tooltip to its target. + /// + /// Args: + /// p: Palette the tooltip paints with. + /// metrics: Scaled tooltip metrics; supply the arrow size. + /// placement: Where the tooltip sits relative to its target, which picks the edge the + /// arrow hangs from and its rotation. + /// tokens: Active theme tokens; supply the UI scale and the skin. + /// + /// Returns: + /// The absolutely positioned arrow, coloured to continue the tooltip's fill. fn render_arrow( p: MoonPalette, metrics: TooltipMetrics, @@ -384,11 +475,12 @@ impl MoonTooltip { ) -> Svg { let half = metrics.arrow_size * 0.5; let offset = tokens.ui(18.0); + let (arrow_color, arrow_alpha) = tooltip_arrow_color(p, tokens, placement); let mut arrow = moon_icon( MOON_ICON_TOOLTIP_ARROW, metrics.arrow_size, - p.shell_high, - 0.98, + arrow_color, + arrow_alpha, ) .absolute(); @@ -419,3 +511,6 @@ impl MoonTooltip { arrow } } + +#[cfg(test)] +mod tests; diff --git a/crates/moon-ui-components/src/moon/tooltip/tests.rs b/crates/moon-ui-components/src/moon/tooltip/tests.rs new file mode 100644 index 0000000..b5e7233 --- /dev/null +++ b/crates/moon-ui-components/src/moon/tooltip/tests.rs @@ -0,0 +1,106 @@ +//! Guards the surface and the arrow a tooltip paints under each theme skin. + +use super::{MoonTooltipPlacement, MoonTooltipSize, tooltip_arrow_color, tooltip_facet}; +use crate::moon::{MoonFacetKind, MoonPalette, MoonSkin, MoonThemeTokens, facet}; + +const ALL_PLACEMENTS: [MoonTooltipPlacement; 4] = [ + MoonTooltipPlacement::Top, + MoonTooltipPlacement::Bottom, + MoonTooltipPlacement::Left, + MoonTooltipPlacement::Right, +]; + +/// Theme tokens for `palette` drawn with `skin`, with every length doubled. +fn doubled_skin_tokens(palette: MoonPalette, skin: MoonSkin) -> MoonThemeTokens { + let mut tokens = MoonThemeTokens { + palette, + skin, + ..MoonThemeTokens::default() + }; + tokens.scale.ui = 2.0; + tokens +} + +/// A custom tooltip size whose only distinguishing value is its corner radius. +fn custom_size(radius: f32) -> MoonTooltipSize { + MoonTooltipSize::Custom { + min_height: 20.0, + font_size: 10.0, + line_height: 13.0, + pad_x: 6.0, + pad_y: 3.0, + radius, + gap: 6.0, + arrow_size: 6.0, + } +} + +/// Catches `tooltip.rs:tooltip_facet` or `tooltip_arrow_color` ignoring the skin: a flat +/// tooltip would get a gradient popup surface, or an arrow that no longer matches its +/// `shell_high` body. +#[test] +fn a_flat_tooltip_keeps_its_solid_surface_and_matching_arrow() { + for palette in [MoonPalette::TERMINAL, MoonPalette::LIGHT] { + let tokens = doubled_skin_tokens(palette, MoonSkin::Flat); + for size in [ + MoonTooltipSize::Compact, + MoonTooltipSize::Normal, + custom_size(3.0), + ] { + assert_eq!(tooltip_facet(&tokens, size), None); + } + for placement in ALL_PLACEMENTS { + assert_eq!( + tooltip_arrow_color(palette, &tokens, placement), + (palette.shell_high, 0.98) + ); + } + } +} + +/// Catches `tooltip.rs:tooltip_facet` giving every size one radius, overriding a caller's own +/// radius, or leaving the radius unscaled: a compact tooltip would be rounder than its height +/// allows, and at a doubled UI scale the corners would stay half as round as the box. +#[test] +fn a_faceted_tooltip_is_a_popup_whose_radius_follows_its_size_and_the_ui_scale() { + let tokens = doubled_skin_tokens(MoonPalette::OBSIDIAN, MoonSkin::Facet); + let popup = facet(MoonFacetKind::Popup, &tokens); + + for (size, radius) in [ + (MoonTooltipSize::Normal, 16.0), + (MoonTooltipSize::Compact, 12.0), + (custom_size(3.0), 6.0), + ] { + assert_eq!( + tooltip_facet(&tokens, size), + Some((popup.clone(), radius)), + "{size:?}" + ); + } +} + +/// Catches `tooltip.rs:tooltip_arrow_color` keeping the flat `shell_high` under the Facet skin, +/// or swapping the two ends of the popup fill: the arrow would show as a patch of another +/// colour against the edge of the tooltip it is meant to continue. +#[test] +fn a_faceted_arrow_takes_the_colour_of_the_edge_it_hangs_from() { + let p = MoonPalette::OBSIDIAN; + let tokens = doubled_skin_tokens(p, MoonSkin::Facet); + + assert_eq!( + tooltip_arrow_color(p, &tokens, MoonTooltipPlacement::Top), + (p.card, 1.0), + "a tooltip above its target points down, from the dark end of its fill" + ); + for placement in [ + MoonTooltipPlacement::Bottom, + MoonTooltipPlacement::Left, + MoonTooltipPlacement::Right, + ] { + assert_eq!( + tooltip_arrow_color(p, &tokens, placement), + (p.panel_high, 1.0), + "{placement:?}" + ); + } +} diff --git a/crates/moon-ui-components/src/moon/window_frame.rs b/crates/moon-ui-components/src/moon/window_frame.rs index 963ba3c..c2ad9f9 100644 --- a/crates/moon-ui-components/src/moon/window_frame.rs +++ b/crates/moon-ui-components/src/moon/window_frame.rs @@ -69,11 +69,12 @@ impl FrameButton { } } - fn invoke(self, window: &mut Window) { + /// Client-drawn controls preserve the native distinction between user and forced close. + fn invoke(self, window: &mut Window, cx: &mut App) { match self { Self::Minimize => window.minimize_window(), Self::Maximize => window.zoom_window(), - Self::Close => window.remove_window(), + Self::Close => window.request_close(cx), } } @@ -325,7 +326,7 @@ impl MoonWindowFrame { .when(cfg!(not(target_os = "windows")), move |this| { this.on_mouse_down(MouseButton::Left, move |_event, window, cx| { cx.stop_propagation(); - button.invoke(window); + button.invoke(window, cx); }) }) .child(button.icon(default_color, hover_color, group)), diff --git a/crates/moon-ui-components/src/moon/window_frame/tests.rs b/crates/moon-ui-components/src/moon/window_frame/tests.rs index 701a6d7..95ad2fd 100644 --- a/crates/moon-ui-components/src/moon/window_frame/tests.rs +++ b/crates/moon-ui-components/src/moon/window_frame/tests.rs @@ -1,5 +1,51 @@ //! Regression tests for reusable window-frame title behavior. +use gpui::{AppContext as _, Context, IntoElement, Render, TestAppContext, Window, div}; +use std::{cell::Cell, rc::Rc}; + +/// A real window whose lifetime is controlled through the reusable frame button. +struct CloseHost; + +impl Render for CloseHost { + /// No layout is needed to test the button's shared invocation path. + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + } +} + +/// Replacing request_close with remove_window would destroy the window despite its veto. +#[gpui::test] +fn frame_close_preserves_veto_and_marks_an_accepted_user_close(cx: &mut TestAppContext) { + let allow = Rc::new(Cell::new(false)); + let requests = Rc::new(Cell::new(0)); + let window = cx.add_window({ + let allow = allow.clone(); + let requests = requests.clone(); + move |window, cx| { + window.on_window_should_close(cx, move |_, _| { + requests.set(requests.get() + 1); + allow.get() + }); + CloseHost + } + }); + window + .update(cx, |_, window, cx| { + super::FrameButton::Close.invoke(window, cx) + }) + .unwrap(); + assert_eq!(requests.get(), 1); + assert!(cx.update_window(window.into(), |_, _, _| ()).is_ok()); + allow.set(true); + window + .update(cx, |_, window, cx| { + super::FrameButton::Close.invoke(window, cx) + }) + .unwrap(); + assert_eq!(requests.get(), 2); + assert!(cx.update_window(window.into(), |_, _, _| ()).is_err()); +} + /// Removing `.truncate()` from `window_frame.rs:MoonWindowFrame::title_cluster` lets a narrow /// window title wrap onto a second line and overrun the fixed-height title bar. #[test] diff --git a/crates/moon-ui-components/src/popover.rs b/crates/moon-ui-components/src/popover.rs index f667635..6d4b7d0 100644 --- a/crates/moon-ui-components/src/popover.rs +++ b/crates/moon-ui-components/src/popover.rs @@ -6,9 +6,7 @@ use gpui::{ }; use std::{cell::Cell, rc::Rc}; -use crate::{ - ElementExt, Selectable, StyledExt as _, actions::Cancel, global_state::GlobalState, v_flex, -}; +use crate::{ElementExt, Selectable, StyledExt as _, actions::Cancel, v_flex}; const CONTEXT: &str = "Popover"; pub(crate) fn init(cx: &mut App) { @@ -274,22 +272,6 @@ impl PopoverState { } } - fn set_open(&mut self, open: bool, cx: &mut Context) { - // Only touch the global registry on an actual transition. A CONTROLLED popover runs this - // from `render` on every frame, and `GlobalState::global_mut` schedules a global-observer - // notification (plus a key allocation) each time — enough, on a popover living in a - // high-frequency host like a chart tab strip, to feed a repaint loop back into itself. - if self.open == open { - return; - } - self.open = open; - if self.open { - GlobalState::global_mut(cx).register_deferred_popover(&self.focus_handle); - } else { - GlobalState::global_mut(cx).unregister_deferred_popover(&self.focus_handle); - } - } - /// Remember what had focus before this popover takes it, so closing can hand it back. /// /// Must run while the popover is still CLOSED and from a synchronous path: reading it later @@ -299,14 +281,31 @@ impl PopoverState { self.previous_focus_handle = window.focused(cx); } - /// Apply the focus and dismiss-subscription work that belongs to the CURRENT value of `open`. + /// Capture the current content focus before a close removes its dispatch-tree ancestors. + /// Returns no handle when focus has already moved to a dialog or another overlay. + fn closing_focus(&self, window: &Window, cx: &App) -> Option { + window.focused(cx).filter(|focused| { + self.focus_handle.contains(focused, window) + || self + .tracked_focus_handle + .as_ref() + .is_some_and(|tracked| tracked.contains(focused, window)) + }) + } + + /// Apply focus/subscription changes; `closing_focus` is captured before removing the content. /// /// Split out of `toggle_open` because a CONTROLLED popover (`Popover::open(bool)`, driven by a - /// caller-held flag) never went through it: `render` flipped the flag with `set_open` alone, + /// caller-held flag) never went through it: `render` flipped the flag alone, /// which left focus parked on the popover's about-to-be-removed handle and the dismiss /// subscription alive. That emptied the window's focus path, so element-level key handlers fell /// off the dispatch tree and application hotkeys stopped arriving until the user clicked. - pub(crate) fn sync_open_focus(&mut self, window: &mut Window, cx: &mut Context) { + pub(crate) fn sync_open_focus( + &mut self, + closing_focus: Option, + window: &mut Window, + cx: &mut Context, + ) { if self.open { let state = cx.entity(); let focus_handle = if let Some(tracked_focus_handle) = self.tracked_focus_handle.clone() @@ -328,30 +327,31 @@ impl PopoverState { ); } else { self._dismiss_subscription = None; - // Hand focus back. `focus_contains` rather than `contains_focused`: focus may sit on a - // DESCENDANT of the popover (an input, a list row) or on a tracked handle, and on the - // deferred close path the popover is already gone from the rendered frame. - let holds_focus = window - .focused(cx) - .is_some_and(|focused| focused == self.focus_handle); - if let Some(prev) = self.previous_focus_handle.take() { - prev.focus(window, cx); - } else if holds_focus { - // Nothing to restore — the common case for a header or strip gear opened while - // nothing was focused. Blur explicitly: leaving focus on the handle that is about - // to stop rendering is exactly what strands the dispatch path and kills hotkeys. - window.blur(); + let previous = self.previous_focus_handle.take(); + // A deferred close must not steal focus transferred after its ownership snapshot. + if closing_focus.is_some() && window.focused(cx) == closing_focus { + if let Some(previous) = previous { + previous.focus(window, cx); + } else { + window.blur(); + } } } } + /// Change visibility together with its focus, subscription and caller notification. fn toggle_open(&mut self, window: &mut Window, cx: &mut Context) { let opening = !self.open; if opening { self.remember_previous_focus(window, cx); } - self.set_open(opening, cx); - self.sync_open_focus(window, cx); + let closing_focus = if opening { + None + } else { + self.closing_focus(window, cx) + }; + self.open = opening; + self.sync_open_focus(closing_focus, window, cx); if let Some(callback) = self.on_open_change.as_ref() { callback(&self.open, window, cx); @@ -440,12 +440,17 @@ impl RenderOnce for Popover { // deferred below — focusing mid-render is not safe — but skipping it entirely is what used // to strand the window's focus (see `sync_open_focus`). let open_changed = force_open.is_some_and(|force_open| state.read(cx).open != force_open); + let closing_focus = if open_changed && force_open == Some(false) { + state.read(cx).closing_focus(window, cx) + } else { + None + }; if let Some(force_open) = force_open { state.update(cx, |state, cx| { if open_changed && force_open { state.remember_previous_focus(window, cx); } - state.set_open(force_open, cx); + state.open = force_open; }); } @@ -463,7 +468,9 @@ impl RenderOnce for Popover { if open_changed { let state = state.clone(); window.defer(cx, move |window, cx| { - state.update(cx, |state, cx| state.sync_open_focus(window, cx)); + state.update(cx, |state, cx| { + state.sync_open_focus(closing_focus, window, cx) + }); }); } @@ -486,7 +493,7 @@ impl RenderOnce for Popover { state.update(cx, |state, cx| { // We force set open to false to toggle it correctly. // Because if the mouse down out will toggle open first. - state.set_open(open, cx); + state.open = open; state.toggle_open(window, cx); }); cx.notify(parent_view_id); @@ -566,63 +573,4 @@ impl RenderOnce for Popover { } #[cfg(test)] -mod tests { - use super::*; - use gpui::MouseButton; - - #[test] - fn test_popover_builder_chaining() { - let popover = Popover::new("test") - .anchor(Anchor::BottomCenter) - .mouse_button(MouseButton::Right) - .default_open(true) - .appearance(false) - .overlay_closable(false); - - assert_eq!(popover.anchor, Anchor::BottomCenter); - assert_eq!(popover.mouse_button, MouseButton::Right); - assert!(popover.default_open); - assert!(!popover.appearance); - assert!(!popover.overlay_closable); - } - - #[test] - fn test_resolved_corner_top_positions() { - use gpui::px; - - let bounds = Bounds { - origin: Point { - x: px(100.), - y: px(100.), - }, - size: gpui::Size { - width: px(200.), - height: px(50.), - }, - }; - - let pos = Popover::resolved_corner(Anchor::TopLeft, bounds); - assert_eq!(pos.x, px(100.)); - assert_eq!(pos.y, px(100.)); - - let pos = Popover::resolved_corner(Anchor::TopCenter, bounds); - assert_eq!(pos.x, px(200.)); - assert_eq!(pos.y, px(100.)); - - let pos = Popover::resolved_corner(Anchor::TopRight, bounds); - assert_eq!(pos.x, px(300.)); - assert_eq!(pos.y, px(100.)); - - let pos = Popover::resolved_corner(Anchor::BottomLeft, bounds); - assert_eq!(pos.x, px(100.)); - assert_eq!(pos.y, px(50.)); - - let pos = Popover::resolved_corner(Anchor::BottomCenter, bounds); - assert_eq!(pos.x, px(200.)); - assert_eq!(pos.y, px(50.)); - - let pos = Popover::resolved_corner(Anchor::BottomRight, bounds); - assert_eq!(pos.x, px(300.)); - assert_eq!(pos.y, px(50.)); - } -} +mod tests; diff --git a/crates/moon-ui-components/src/popover/tests.rs b/crates/moon-ui-components/src/popover/tests.rs new file mode 100644 index 0000000..9e96eff --- /dev/null +++ b/crates/moon-ui-components/src/popover/tests.rs @@ -0,0 +1,290 @@ +use super::Popover; +use gpui::MouseButton; +use gpui::{Anchor, Bounds, Point}; + +#[test] +fn test_popover_builder_chaining() { + let popover = Popover::new("test") + .anchor(Anchor::BottomCenter) + .mouse_button(MouseButton::Right) + .default_open(true) + .appearance(false) + .overlay_closable(false); + + assert_eq!(popover.anchor, Anchor::BottomCenter); + assert_eq!(popover.mouse_button, MouseButton::Right); + assert!(popover.default_open); + assert!(!popover.appearance); + assert!(!popover.overlay_closable); +} + +#[test] +fn test_resolved_corner_top_positions() { + use gpui::px; + + let bounds = Bounds { + origin: Point { + x: px(100.), + y: px(100.), + }, + size: gpui::Size { + width: px(200.), + height: px(50.), + }, + }; + + let pos = Popover::resolved_corner(Anchor::TopLeft, bounds); + assert_eq!(pos.x, px(100.)); + assert_eq!(pos.y, px(100.)); + + let pos = Popover::resolved_corner(Anchor::TopCenter, bounds); + assert_eq!(pos.x, px(200.)); + assert_eq!(pos.y, px(100.)); + + let pos = Popover::resolved_corner(Anchor::TopRight, bounds); + assert_eq!(pos.x, px(300.)); + assert_eq!(pos.y, px(100.)); + + let pos = Popover::resolved_corner(Anchor::BottomLeft, bounds); + assert_eq!(pos.x, px(100.)); + assert_eq!(pos.y, px(50.)); + + let pos = Popover::resolved_corner(Anchor::BottomCenter, bounds); + assert_eq!(pos.x, px(200.)); + assert_eq!(pos.y, px(50.)); + + let pos = Popover::resolved_corner(Anchor::BottomRight, bounds); + assert_eq!(pos.x, px(300.)); + assert_eq!(pos.y, px(50.)); +} + +use crate::{ + Root, WindowExt as _, + moon::{MoonDropdown, MoonMenuItem, MoonTheme, ThemeMode}, +}; +use gpui::{ + AppContext as _, Context, FocusHandle, InteractiveElement as _, IntoElement, + ParentElement as _, Render, Styled as _, VisualTestContext, Window, div, px, +}; +use std::{cell::Cell, rc::Rc}; + +/// A window-root trigger whose menu opens a real Root dialog with its own focus target. +struct DialogFocusHarness { + open: Rc>, + origin: FocusHandle, + field: FocusHandle, +} + +impl Render for DialogFocusHarness { + /// Mount the real dropdown selection handler, not a duplicate selection policy. + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let field = self.field.clone(); + let open = self.open.clone(); + let owner = cx.entity_id(); + div().track_focus(&self.origin).child( + MoonDropdown::new("dialog-opener") + .label("Open") + .open(self.open.get()) + .on_open_change(move |value, _, cx| { + open.set(value); + cx.notify(owner); + }) + .item( + MoonMenuItem::with_key("modal", "Modal") + .closes_menu(true) + .on_click(move |_, window, cx| { + let body_focus = field.clone(); + window.open_dialog(cx, move |dialog, _, _| { + let body_focus = body_focus.clone(); + dialog.content(move |body, _, _| { + body.child( + div() + .track_focus(&body_focus) + .w(px(180.)) + .h(px(40.)) + .child( + div() + .debug_selector(|| { + "dialog-inner-trigger".into() + }) + .w(px(80.)) + .h(px(30.)) + .child( + MoonDropdown::new("dialog-inner") + .label("Nested") + .item(MoonMenuItem::new( + "Nested action", + )), + ), + ), + ) + }) + }); + field.focus(window, cx); + }), + ), + ) + } +} + +/// Closing a menu after its callback opened a dialog must neither steal the field's focus nor +/// leave the dialog's return focus on a hidden menu. Reordering close after the callback reddens +/// the final origin assertion; unconditional deferred restoration reddens the field assertion. +#[gpui::test] +fn dropdown_select_plan_respects_close_and_controlled_state(cx: &mut gpui::TestAppContext) { + cx.update(crate::init); + for theme in [ThemeMode::Dark, ThemeMode::Light] { + cx.update(|cx| MoonTheme::set_mode(theme, cx)); + let open = Rc::new(Cell::new(false)); + let handles = Rc::new(std::cell::RefCell::new(None)); + let window = cx.add_window({ + let open = open.clone(); + let handles = handles.clone(); + move |window, cx| { + let origin = cx.focus_handle(); + let field = cx.focus_handle(); + *handles.borrow_mut() = Some((origin.clone(), field.clone())); + let view = cx.new(|_| DialogFocusHarness { + open, + origin, + field, + }); + Root::new(view, window, cx) + } + }); + let (origin, field) = handles.borrow().clone().unwrap(); + let mut visual = VisualTestContext::from_window(window.into(), cx); + visual.run_until_parked(); + visual.update(|window, cx| { + origin.focus(window, cx); + open.set(true); + window.refresh(); + }); + visual.run_until_parked(); + let row = visual + .debug_bounds("dialog-opener:menu:item:0") + .expect("open menu row"); + visual.simulate_click(row.center(), gpui::Modifiers::default()); + visual.run_until_parked(); + visual.update(|window, cx| { + assert_eq!( + window.focused(cx), + Some(field.clone()), + "menu close stole modal focus" + ); + assert!(window.has_active_dialog(cx)); + }); + let nested_trigger = visual + .debug_bounds("dialog-inner-trigger") + .expect("dialog menu trigger"); + visual.simulate_click(nested_trigger.center(), gpui::Modifiers::default()); + visual.run_until_parked(); + let nested_row = visual + .debug_bounds("dialog-inner:menu:item:0") + .expect("menu inside dialog must open"); + visual.simulate_click(nested_row.center(), gpui::Modifiers::default()); + visual.run_until_parked(); + visual.update(|window, cx| { + assert_eq!( + window.focused(cx), + Some(field.clone()), + "nested menu must return to modal field" + ); + window.close_dialog(cx); + assert_eq!( + window.focused(cx), + Some(origin.clone()), + "dialog returned to hidden menu" + ); + }); + } +} + +/// A controlled popover with either a descendant or explicitly tracked content focus. +struct ClosingFocusHarness { + open: Rc>, + origin: FocusHandle, + child: FocusHandle, + other: FocusHandle, + tracked: bool, +} + +impl Render for ClosingFocusHarness { + /// Render the same ownership tree used by a retained input inside a controlled popover. + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let child = self.child.clone(); + let mut popup = Popover::new("closing-owner") + .open(self.open.get()) + .trigger_any(div().w(px(20.)).h(px(20.))) + .content(move |_, _, _| div().track_focus(&child).w(px(100.)).h(px(40.))); + if self.tracked { + popup = popup.track_focus(&self.child); + } + div() + .track_focus(&self.origin) + .child(popup) + .child(div().track_focus(&self.other)) + } +} + +/// Restoring only the popover's exact handle strands descendant/tracked input focus after close; +/// restoring no-prior focus to the removed handle also strands application hotkeys. +#[gpui::test] +fn controlled_close_releases_descendant_and_tracked_focus(cx: &mut gpui::TestAppContext) { + cx.update(crate::init); + for theme in [ThemeMode::Dark, ThemeMode::Light] { + cx.update(|cx| MoonTheme::set_mode(theme, cx)); + for tracked in [false, true] { + for (previous, transfer) in [(false, false), (true, false), (true, true)] { + let open = Rc::new(Cell::new(false)); + let window = cx.add_window({ + let open = open.clone(); + move |_, cx| ClosingFocusHarness { + open, + origin: cx.focus_handle(), + child: cx.focus_handle(), + other: cx.focus_handle(), + tracked, + } + }); + let (origin, child, other) = window + .read_with(cx, |view, _| { + (view.origin.clone(), view.child.clone(), view.other.clone()) + }) + .unwrap(); + let mut visual = VisualTestContext::from_window(window.into(), cx); + visual.run_until_parked(); + visual.update(|window, cx| { + if previous { + origin.focus(window, cx); + } else { + window.blur(); + } + open.set(true); + window.refresh(); + }); + visual.run_until_parked(); + visual.update(|window, cx| { + child.focus(window, cx); + if transfer { + other.focus(window, cx); + } + open.set(false); + window.refresh(); + }); + visual.run_until_parked(); + visual.update(|window, cx| { + assert_eq!( + window.focused(cx), + if transfer { + Some(other.clone()) + } else { + previous.then(|| origin.clone()) + }, + "close must release removed content, tracked={tracked}, previous={previous}" + ); + }); + } + } + } +} diff --git a/crates/moon-ui-components/src/progress/progress.rs b/crates/moon-ui-components/src/progress/progress.rs index 51360de..e7e9a01 100644 --- a/crates/moon-ui-components/src/progress/progress.rs +++ b/crates/moon-ui-components/src/progress/progress.rs @@ -17,6 +17,7 @@ pub struct Progress { value: f32, size: Size, loading: bool, + surface_styles: Option<(StyleRefinement, StyleRefinement)>, } impl Progress { @@ -29,6 +30,7 @@ impl Progress { style: StyleRefinement::default(), size: Size::default(), loading: false, + surface_styles: None, } } @@ -54,6 +56,12 @@ impl Progress { self.value = value.clamp(0., 100.); self } + + /// Let the Moon wrapper change track/fill paint while retaining this bar's animation state. + pub(crate) fn surface_styles(mut self, track: StyleRefinement, fill: StyleRefinement) -> Self { + self.surface_styles = Some((track, fill)); + self + } } impl Styled for Progress { @@ -99,6 +107,9 @@ impl RenderOnce for Progress { .rounded(radius) .refine_style(&self.style) .bg(color.opacity(0.2)) + .when_some(self.surface_styles.as_ref(), |this, (track, _)| { + this.refine_style(track) + }) .child( div() .absolute() @@ -108,6 +119,9 @@ impl RenderOnce for Progress { .bg(color) .rounded(radius) .refine_style(&inner_style) + .when_some(self.surface_styles.as_ref(), |this, (_, fill)| { + this.refine_style(fill) + }) .map(|this| match value { v if v >= 100. || loading => this, _ => this.rounded_r_none(), diff --git a/crates/moon-ui-components/src/resizable/mod.rs b/crates/moon-ui-components/src/resizable/mod.rs index ba90d10..8fb2924 100644 --- a/crates/moon-ui-components/src/resizable/mod.rs +++ b/crates/moon-ui-components/src/resizable/mod.rs @@ -196,6 +196,8 @@ impl ResizableState { } } + /// Retain measured bounds and constraints for dragging. Only a new initial flex basis + /// needs another layout; re-recording the geometry just painted must converge to idle. pub(crate) fn update_panel_size( &mut self, panel_ix: usize, @@ -207,13 +209,13 @@ impl ResizableState { // This check is only necessary to stop the very first panel from resizing on its own // it needs to be passed when the panel is freshly created so we get the initial size, // but its also fine when it sometimes passes later. - if self.sizes[panel_ix].as_f32() == PANEL_MIN_SIZE.as_f32() { + if self.sizes[panel_ix] == PANEL_MIN_SIZE && self.panels[panel_ix].size != Some(size) { self.sizes[panel_ix] = size; self.panels[panel_ix].size = Some(size); + cx.notify(); } self.panels[panel_ix].bounds = bounds; self.panels[panel_ix].size_range = size_range; - cx.notify(); } pub(crate) fn remove_panel(&mut self, panel_ix: usize, cx: &mut Context) { diff --git a/crates/moon-ui-components/src/root.rs b/crates/moon-ui-components/src/root.rs index fd6b93f..418d253 100644 --- a/crates/moon-ui-components/src/root.rs +++ b/crates/moon-ui-components/src/root.rs @@ -3,7 +3,7 @@ use crate::{ dialog::{ANIMATION_DURATION, Dialog}, focus_trap::FocusTrapManager, input::{Copy, InputState}, - moon::{MoonDialog, MoonTheme}, + moon::{MoonDialog, MoonSkin, MoonTheme, moon_backdrop}, native_menu::FallbackMenuOverlay, notification::{Notification, NotificationList}, sheet::Sheet, @@ -182,12 +182,19 @@ impl MoonRoot { } /// Set whether the root paints its background. + /// + /// Under the Facet skin a [`BackgroundPolicy::Opaque`] root also paints the theme's window + /// backdrop behind its content. The other policies never do: they exist to show what lies + /// behind the scene, and the backdrop is opaque. pub fn background_policy(mut self, policy: BackgroundPolicy) -> Self { self.background_policy = policy; self } /// Override root background with a packed RGB value. + /// + /// Under the Facet skin an opaque root covers this fill with the theme's window backdrop, + /// which is derived from the palette's `shell`. pub fn background(mut self, rgb_hex: u32) -> Self { self.background = Some(rgb(rgb_hex).into()); self @@ -750,6 +757,14 @@ impl Render for MoonRoot { let dialog_layer = self.render_dialog_layer_inline(window, cx); let context_menu_layer = self.render_context_menu_layer_inline(window, cx); let notification_layer = self.render_notification_layer_inline(cx); + // Only a root that owns an opaque window fill paints the Facet backdrop: the backdrop is + // opaque, and a transparent or unfilled root exists to show what lies behind the scene. + // The skin is read from the theme global before any tokens are built, so under the Flat + // skin this costs one comparison and adds no element. + let backdrop = (self.background_policy == BackgroundPolicy::Opaque + && MoonSkin::active(cx).is_facet()) + .then(|| moon_backdrop(&MoonTheme::active_tokens(cx))) + .flatten(); let inner = div() .id("root") @@ -773,6 +788,7 @@ impl Render for MoonRoot { }) .text_color(self.text_color.unwrap_or(cx.theme().foreground)) .refine_style(&self.style) + .when_some(backdrop, |this, backdrop| this.child(backdrop)) .child(TextSelectionController) .child(self.view.clone()) .when_some(sheet_layer, |this, layer| this.child(layer)) diff --git a/crates/moon-ui-components/src/select.rs b/crates/moon-ui-components/src/select.rs index 01c6523..c37a8fa 100644 --- a/crates/moon-ui-components/src/select.rs +++ b/crates/moon-ui-components/src/select.rs @@ -13,11 +13,10 @@ use crate::{ StyleSized, StyledExt, actions::{Cancel, Confirm, SelectDown, SelectUp}, button::ButtonVariant, - global_state::GlobalState, h_flex, - input::{clear_button, input_style}, + input::{clear_button, input_style, trigger_facets}, list::List, - moon::{MoonPalette, rgba_from}, + moon::{MoonPalette, MoonTheme, rgba_from}, searchable_list::{ SearchableListChange, SearchableListDelegate, SearchableListItem, SearchableListState, }, @@ -424,15 +423,9 @@ where cx.notify(); } + /// Publish dropdown visibility through its owning view. fn set_open(&mut self, open: bool, cx: &mut Context) { self.state.open = open; - - if self.state.open { - GlobalState::global_mut(cx).register_deferred_popover(&self.state.focus_handle) - } else { - GlobalState::global_mut(cx).unregister_deferred_popover(&self.state.focus_handle) - } - cx.notify(); } @@ -519,6 +512,17 @@ where .map(|style| rgba_from(style.border, style.border_alpha)) .unwrap_or_else(|| cx.theme().input); + // Under the Facet skin the trigger is a surface of its own, with its own hover and + // press ramp. A caller that set a background on it keeps that background. + let trigger_facet = trigger_facets( + &MoonTheme::active_tokens(cx), + self.trigger_variant, + outline_visible, + self.state.disabled, + ) + .filter(|_| self.state.appearance && self.state.style.background.is_none()); + let flat_ramp = !self.state.disabled && trigger_facet.is_none(); + self.state.list.update(cx, |list, cx| { list.set_searchable(searchable, cx); list.delegate_mut().size = self.state.size; @@ -555,7 +559,7 @@ where .input_size(self.state.size) .input_text_size(self.state.size) .refine_style(&self.state.style) - .when(!self.state.disabled, |this| { + .when(flat_ramp, |this| { this.when_some(trigger_style, |this, style| { this.hover(move |this| { this.bg(rgba_from(style.bg, style.hover_bg_alpha)) @@ -570,6 +574,11 @@ where }) }) }) + .when_some(trigger_facet, |this, [rest, hovered, pressed]| { + rest.paint(this) + .hover(move |this| hovered.paint(this)) + .active(move |this| pressed.paint(this)) + }) .when(outline_visible, |this| this.focused_border(cx)) .when(allow_open, |this| { this.on_click(cx.listener(Self::toggle_menu)) diff --git a/crates/moon-ui-components/src/sidebar/menu.rs b/crates/moon-ui-components/src/sidebar/menu.rs index 52a709a..1a1111c 100644 --- a/crates/moon-ui-components/src/sidebar/menu.rs +++ b/crates/moon-ui-components/src/sidebar/menu.rs @@ -3,17 +3,64 @@ use crate::{ button::{Button, ButtonVariants as _}, h_flex, menu::{ContextMenuExt, PopupMenu}, - moon::{MoonPalette, foundation::selected_background}, + moon::{ + MoonPalette, MoonTheme, MoonThemeTokens, facet_glow, foundation::selected_background, + rgba_from, + }, sidebar::SidebarItem, v_flex, }; use gpui::{ - AnyElement, App, ClickEvent, ElementId, InteractiveElement as _, IntoElement, + AnyElement, App, BoxShadow, ClickEvent, ElementId, Hsla, InteractiveElement as _, IntoElement, ParentElement as _, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled, Window, div, percentage, prelude::FluentBuilder, px, }; use std::rc::Rc; +/// Alpha of the `overlay` ink at the middle of a faceted sidebar's edge rule. +const FACET_EDGE_RULE_ALPHA: f32 = 0.1; +/// Alpha of the accent glow around the active item's bar under the Facet skin. +const FACET_ACTIVE_BAR_GLOW_ALPHA: f32 = 0.6; +/// Blur of that glow as the design gives it, in design-reference pixels. +const FACET_ACTIVE_BAR_GLOW_BLUR: f32 = 8.0; + +/// Resolve the colour of the fading rule a sidebar draws along its inner edge under the Facet +/// skin. +/// +/// A faceted sidebar paints no fill of its own, and the solid border between it and the page +/// becomes a rule that fades out toward both ends. +/// +/// Args: +/// tokens: Active theme tokens; supply the skin and the palette. +/// +/// Returns: +/// The colour at the middle of the rule, or `None` under the Flat skin, where the sidebar +/// keeps its theme fill and its solid border. +pub(super) fn sidebar_facet_edge_rule(tokens: &MoonThemeTokens) -> Option { + tokens + .skin + .is_facet() + .then(|| rgba_from(tokens.palette.overlay, FACET_EDGE_RULE_ALPHA)) +} + +/// Resolve the glow the active item's bar casts under the Facet skin. +/// +/// Args: +/// tokens: Active theme tokens; supply the skin, the palette and the UI scale. +/// +/// Returns: +/// The glow, or `None` under the Flat skin, where the bar casts none. +fn active_bar_facet_glow(tokens: &MoonThemeTokens) -> Option { + tokens.skin.is_facet().then(|| { + facet_glow( + tokens.palette.accent, + FACET_ACTIVE_BAR_GLOW_ALPHA, + FACET_ACTIVE_BAR_GLOW_BLUR, + tokens, + ) + }) +} + /// Menu for the [`super::Sidebar`] #[derive(Clone)] pub struct SidebarMenu { @@ -295,6 +342,8 @@ impl SidebarMenuItem { .as_ref() .map_or(false, |s| !is_collapsed && *s.read(cx)); let p = MoonPalette::active(cx); + let active_bar_glow = active_bar_facet_glow(&MoonTheme::active_tokens(cx)); + let faceted = active_bar_glow.is_some(); let hover_group = SharedString::from(format!("sidebar-row-{id}")); let child_count = self.children.len(); let submenu_half_gap = window.rem_size() * 0.125; @@ -341,18 +390,26 @@ impl SidebarMenuItem { .top(px(3.0)) .bottom(px(3.0)) .w(px(3.0)) - .bg(cx.theme().sidebar_primary), - ) - .child( - div() - .absolute() - .left(px(8.0)) - .top(px(12.0)) - .w(px(4.0)) - .h(px(4.0)) - .rounded_full() - .bg(cx.theme().sidebar_primary), + .bg(cx.theme().sidebar_primary) + .when_some(active_bar_glow, |bar, glow| { + bar.shadow(vec![glow]) + }), ) + // The marker dot sits where the label starts. A faceted item + // is already marked by its glowing bar, so it drops the dot + // rather than print it over the first letter. + .when(!faceted, |this| { + this.child( + div() + .absolute() + .left(px(8.0)) + .top(px(12.0)) + .w(px(4.0)) + .h(px(4.0)) + .rounded_full() + .bg(cx.theme().sidebar_primary), + ) + }) }) .when_some(self.icon.clone(), |this, icon| this.child(icon)) .when(is_collapsed, |this| { diff --git a/crates/moon-ui-components/src/sidebar/menu/tests.rs b/crates/moon-ui-components/src/sidebar/menu/tests.rs index a8aef60..4220566 100644 --- a/crates/moon-ui-components/src/sidebar/menu/tests.rs +++ b/crates/moon-ui-components/src/sidebar/menu/tests.rs @@ -342,3 +342,56 @@ fn grouped_menus_share_gap_hits_at_multiple_rem_sizes(cx: &mut TestAppContext) { assert_eq!(&*clicks.borrow(), &[1, 2]); } } + +// ---- Moon theme skin: the sidebar's edge and the active item's bar ---- + +use crate::moon::{MoonSkin, MoonThemeTokens}; + +/// Theme tokens for `palette` drawn with `skin`, with every length doubled. +fn doubled_skin_tokens(palette: MoonPalette, skin: MoonSkin) -> MoonThemeTokens { + let mut tokens = MoonThemeTokens { + palette, + skin, + ..MoonThemeTokens::default() + }; + tokens.scale.ui = 2.0; + tokens +} + +/// Catches `menu.rs:sidebar_facet_edge_rule` or `active_bar_facet_glow` ignoring the skin: +/// every flat theme's sidebar would lose its fill and solid border, and its active item would +/// grow a glow. +#[test] +fn a_flat_sidebar_keeps_its_fill_border_and_plain_active_bar() { + for palette in [MoonPalette::TERMINAL, MoonPalette::LIGHT] { + let tokens = doubled_skin_tokens(palette, MoonSkin::Flat); + assert_eq!(sidebar_facet_edge_rule(&tokens), None); + assert_eq!(active_bar_facet_glow(&tokens), None); + } +} + +/// Catches `menu.rs:sidebar_facet_edge_rule` drawing the rule at the flat border's strength, +/// and `active_bar_facet_glow` leaving the blur unscaled or giving the glow an offset: the +/// active item's bar would cast a lopsided or undersized halo at a larger UI scale. +#[test] +fn a_faceted_sidebar_fades_its_edge_and_lights_the_active_bar() { + let p = MoonPalette::OBSIDIAN; + let tokens = doubled_skin_tokens(p, MoonSkin::Facet); + + assert_eq!( + sidebar_facet_edge_rule(&tokens), + Some(rgba_from(p.overlay, 0.1)) + ); + // The glow written out: centred on the bar, no spread, 8 design pixels of blur doubled with + // the UI scale and halved into the renderer's blur radius. + assert_eq!( + active_bar_facet_glow(&tokens), + Some(BoxShadow { + color: rgba_from(p.accent, 0.6), + offset: point(px(0.0), px(0.0)), + blur_radius: px(8.0), + spread_radius: px(0.0), + inset: false, + }) + ); +} diff --git a/crates/moon-ui-components/src/sidebar/mod.rs b/crates/moon-ui-components/src/sidebar/mod.rs index 552138b..03205e6 100644 --- a/crates/moon-ui-components/src/sidebar/mod.rs +++ b/crates/moon-ui-components/src/sidebar/mod.rs @@ -401,6 +401,9 @@ impl RenderOnce for Sidebar { let layout = SidebarLayout::new(self.collapsible, self.collapsed, expanded_width, self.side); + let facet_edge_rule = + menu::sidebar_facet_edge_rule(&crate::moon::MoonTheme::active_tokens(cx)); + // Sidebar content renders at its target width immediately. A wrapper // div animates clip-width for smooth transitions without re-laying out // sidebar content each animation frame. @@ -413,6 +416,12 @@ impl RenderOnce for Sidebar { .bg(cx.theme().sidebar) .text_color(cx.theme().sidebar_foreground) .border_color(cx.theme().sidebar_border) + // Moon Facet skin: no fill, and the border keeps its pixel of width but stops + // painting; the fading rule added below takes its place. + .when(facet_edge_rule.is_some(), |this| { + this.bg(gpui::transparent_black()) + .border_color(gpui::transparent_black()) + }) .map(|this| match self.side { Side::Left => this.border_r_1(), Side::Right => this.border_l_1(), @@ -480,6 +489,22 @@ impl RenderOnce for Sidebar { .when(layout.icon_collapsed, |this| this.pt_2().px_2()) .child(footer), ) + }) + // An absolute child is placed inside its parent's border, hence the negative inset + // that puts the rule on the border's own pixel. + .when_some(facet_edge_rule, |this, color| { + this.child( + div() + .absolute() + .top_0() + .bottom_0() + .w(px(1.)) + .map(|rule| match self.side { + Side::Left => rule.right(px(-1.)), + Side::Right => rule.left(px(-1.)), + }) + .child(crate::moon::fading_rule_v(color)), + ) }); let target_width = match layout.wrapper { diff --git a/crates/moon-ui-components/src/slider.rs b/crates/moon-ui-components/src/slider.rs index 90a1ca6..02aef49 100644 --- a/crates/moon-ui-components/src/slider.rs +++ b/crates/moon-ui-components/src/slider.rs @@ -2,14 +2,15 @@ use std::ops::Range; use crate::{ AxisExt, ElementExt, StyledExt, box_shadow, h_flex, - moon::{MoonPalette, rgba_from}, + moon::{MoonFacetKind, MoonPalette, MoonSkin, MoonTheme, facet, rgba_from}, }; use gpui::{ - Along, App, AppContext as _, Axis, Bounds, Context, Corners, DefiniteLength, DragMoveEvent, - ElementId, Empty, Entity, EntityId, EventEmitter, Hsla, InteractiveElement, IntoElement, - MouseButton, MouseDownEvent, ParentElement as _, Pixels, Point, Render, RenderOnce, - ScrollDelta, ScrollWheelEvent, StatefulInteractiveElement as _, StyleRefinement, Styled, - Window, div, linear_color_stop, linear_gradient, prelude::FluentBuilder as _, px, relative, + Along, App, AppContext as _, Axis, Background, Bounds, BoxShadow, Context, Corners, + DefiniteLength, DragMoveEvent, ElementId, Empty, Entity, EntityId, EventEmitter, Hsla, + InteractiveElement, IntoElement, MouseButton, MouseDownEvent, ParentElement as _, Pixels, + Point, Render, RenderOnce, ScrollDelta, ScrollWheelEvent, StatefulInteractiveElement as _, + StyleRefinement, Styled, Window, div, linear_color_stop, linear_gradient, + prelude::FluentBuilder as _, px, relative, white, }; #[derive(Clone)] @@ -33,9 +34,117 @@ impl Render for DragSlider { const MOON_SLIDER_HEIGHT: Pixels = px(18.); const MOON_SLIDER_TRACK_HEIGHT: Pixels = px(2.); const MOON_SLIDER_THUMB_SIZE: Pixels = px(7.); +/// Thickness of the groove the track becomes under the Facet skin. +const MOON_SLIDER_GROOVE_HEIGHT: Pixels = px(4.); +/// Diameter of the thumb under the Facet skin. +const MOON_SLIDER_FACET_THUMB_SIZE: Pixels = px(10.); /// Pixel wheel distance (a trackpad's continuous scroll) that counts as one wheel notch. const WHEEL_NOTCH_PIXELS: f32 = 20.; +/// The lengths a slider is drawn with, which follow the theme's skin. +/// +/// The hit area ([`MOON_SLIDER_HEIGHT`]) is the same under every skin and is not part of this. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct SliderMetrics { + /// Thickness of the track across the slider's axis. + pub(crate) track: Pixels, + /// Thickness of the filled part, which runs along the middle of the track. + pub(crate) fill: Pixels, + /// Diameter of a thumb. + pub(crate) thumb: Pixels, +} + +impl SliderMetrics { + /// Returns the lengths `skin` draws a slider with. + /// + /// The Flat skin draws the hairline slider: a 2px track that its fill covers whole, under a + /// 7px thumb. The Facet skin sinks the track into a 4px groove, keeps the fill at 2px along + /// its middle, and raises a 10px thumb over it. + pub(crate) fn for_skin(skin: MoonSkin) -> Self { + if skin.is_facet() { + Self { + track: MOON_SLIDER_GROOVE_HEIGHT, + fill: MOON_SLIDER_TRACK_HEIGHT, + thumb: MOON_SLIDER_FACET_THUMB_SIZE, + } + } else { + Self { + track: MOON_SLIDER_TRACK_HEIGHT, + fill: MOON_SLIDER_TRACK_HEIGHT, + thumb: MOON_SLIDER_THUMB_SIZE, + } + } + } +} + +/// Returns the glow a thumb casts in its own colour, fainter on a disabled slider. +fn thumb_glow(thumb_color: Hsla, disabled: bool) -> BoxShadow { + box_shadow( + px(0.), + px(0.), + px(14.), + px(0.), + thumb_color.opacity(if disabled { 0.14 } else { 0.72 }), + ) +} + +/// What a slider thumb paints under the Facet skin, in place of its flat disc. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct SliderThumbFacet { + /// The thumb's fill: the colour at its top edge, then the one at its bottom edge. + pub(crate) fill: [Hsla; 2], + /// The thumb's shadow on the groove, followed by the glow the flat thumb has. + pub(crate) shadows: Vec, +} + +impl SliderThumbFacet { + /// Resolves a slider thumb's faceted surface. + /// + /// The thumb is lit from above: `thumb_color` mixed halfway toward white at its top edge and + /// `thumb_color` itself at the bottom. It casts a short shadow on the groove under the same + /// glow the flat thumb has. A disabled thumb fades all three by the flat thumb's factor. + /// + /// Args: + /// skin: The active theme side's skin. + /// p: The active palette, for the shadow ink. + /// thumb_color: The thumb's colour: the accent, or the text colour set on the slider. + /// disabled: Whether the slider is disabled. + /// + /// Returns: + /// The surface to paint, or `None` under the Flat skin, where the flat disc stands. + pub(crate) fn resolve( + skin: MoonSkin, + p: MoonPalette, + thumb_color: Hsla, + disabled: bool, + ) -> Option { + if !skin.is_facet() { + return None; + } + let color = thumb_color.opacity(if disabled { 0.42 } else { 1.0 }); + let drop = box_shadow( + px(0.), + px(1.), + px(2.), + px(0.), + rgba_from(p.shadow, if disabled { 0.25 } else { 0.6 }), + ); + Some(Self { + fill: [color.blend(white().opacity(0.5)), color], + shadows: vec![drop, thumb_glow(thumb_color, disabled)], + }) + } + + /// Builds the thumb's background: the gradient from the top edge's colour to the bottom's. + fn background(&self) -> Background { + linear_gradient( + 180.0, + linear_color_stop(self.fill[0], 0.0), + linear_color_stop(self.fill[1], 1.0), + ) + } +} + /// Translate one wheel event into slider notches, positive meaning "increase". /// /// A line-based delta is one discrete mouse-wheel notch whatever its size: Windows multiplies a @@ -562,39 +671,42 @@ impl Slider { radius: Corners, disabled: bool, window: &mut Window, - _cx: &mut App, + cx: &mut App, ) -> impl gpui::IntoElement { let entity_id = self.state.entity_id(); let axis = self.axis; let id = ("slider-thumb", is_start as u32); + let skin = MoonSkin::active(cx); + let metrics = SliderMetrics::for_skin(skin); + let (thumb_size, track_height) = (metrics.thumb, metrics.track); + let thumb_facet = + SliderThumbFacet::resolve(skin, MoonPalette::active(cx), thumb_color, disabled); div() .id(id) .absolute() .when(axis.is_horizontal(), |this| { - this.top(-(MOON_SLIDER_THUMB_SIZE - MOON_SLIDER_TRACK_HEIGHT) * 0.5) + this.top(-(thumb_size - track_height) * 0.5) .left(start) - .ml(-MOON_SLIDER_THUMB_SIZE * 0.5) + .ml(-thumb_size * 0.5) }) .when(axis.is_vertical(), |this| { this.bottom(start) - .left(-(MOON_SLIDER_THUMB_SIZE - MOON_SLIDER_TRACK_HEIGHT) * 0.5) - .mb(-MOON_SLIDER_THUMB_SIZE * 0.5) + .left(-(thumb_size - track_height) * 0.5) + .mb(-thumb_size * 0.5) }) .flex() .items_center() .justify_center() .flex_shrink_0() .corner_radii(radius) - .size(MOON_SLIDER_THUMB_SIZE) + .size(thumb_size) .bg(thumb_color.opacity(if disabled { 0.42 } else { 1.0 })) - .shadow(vec![box_shadow( - px(0.), - px(0.), - px(14.), - px(0.), - thumb_color.opacity(if disabled { 0.14 } else { 0.72 }), - )]) + .shadow(vec![thumb_glow(thumb_color, disabled)]) + // Under the Facet skin the thumb is lit from above and casts a shadow on the groove. + .when_some(thumb_facet, |this, thumb| { + this.bg(thumb.background()).shadow(thumb.shadows) + }) .on_mouse_down(MouseButton::Left, |_, _, cx| { cx.stop_propagation(); }) @@ -649,6 +761,13 @@ impl RenderOnce for Slider { let p = MoonPalette::active(cx); let accent = rgba_from(p.accent, 1.0); let fill_alpha = if self.disabled { 0.24 } else { 0.88 }; + let skin = MoonSkin::active(cx); + let metrics = SliderMetrics::for_skin(skin); + // Under the Facet skin the track is a groove: the skin's well, without its edge, which a + // groove this thin has no room for. + let groove = skin + .is_facet() + .then(|| facet(MoonFacetKind::Well, &MoonTheme::active_tokens(cx))); let thumb_color = self.style.text.color.unwrap_or(accent); let corner_radii = self.style.corner_radii.clone(); @@ -772,17 +891,14 @@ impl RenderOnce for Slider { div() .id("slider-bar") .relative() - .when(axis.is_horizontal(), |this| { - this.w_full().h(MOON_SLIDER_TRACK_HEIGHT) - }) - .when(axis.is_vertical(), |this| { - this.h_full().w(MOON_SLIDER_TRACK_HEIGHT) - }) + .when(axis.is_horizontal(), |this| this.w_full().h(metrics.track)) + .when(axis.is_vertical(), |this| this.h_full().w(metrics.track)) .bg(rgba_from( p.overlay, if self.disabled { 0.03 } else { 0.05 }, )) .corner_radii(radius) + .when_some(groove, |this, well| this.bg(well.fill).shadow(well.shadows)) .child( div() .absolute() @@ -792,6 +908,16 @@ impl RenderOnce for Slider { .when(axis.is_vertical(), |this| { this.w_full().bottom(bar_start).top(bar_end) }) + // A groove is thicker than its fill, which then keeps its + // own thickness and runs along the groove's middle. + .when(metrics.fill < metrics.track, |this| { + let inset = (metrics.track - metrics.fill) * 0.5; + if axis.is_horizontal() { + this.top(inset).h(metrics.fill) + } else { + this.left(inset).w(metrics.fill) + } + }) .bg(linear_gradient( if axis.is_horizontal() { 90.0 } else { 0.0 }, linear_color_stop(accent.opacity(0.0), 0.0), diff --git a/crates/moon-ui-components/src/time/calendar.rs b/crates/moon-ui-components/src/time/calendar.rs index 5ca8632..e2e5c0f 100644 --- a/crates/moon-ui-components/src/time/calendar.rs +++ b/crates/moon-ui-components/src/time/calendar.rs @@ -410,11 +410,12 @@ impl CalendarState { (year, month as u32) } - /// Returns the days of the month in a 2D vector to render on calendar. - fn days(&self) -> Vec> { + /// Return each displayed month as a separate week grid, preserving five/six-row boundaries. + fn days(&self) -> Vec>> { (0..self.number_of_months) - .flat_map(|offset| { - days_in_month(self.current_year, self.current_month as u32 + offset as u32) + .map(|offset| { + let (year, month) = self.offset_year_month(offset); + days_in_month(year, month) }) .collect() } @@ -581,6 +582,11 @@ impl Calendar { window, cx, ) + .map(|button| { + #[cfg(test)] + let button = button.debug_selector(move || format!("calendar-day-{date_id}")); + button + }) .when(is_today && !is_active, |this| { this.border_1().border_color(cx.theme().border) }) // Add border for today @@ -782,6 +788,7 @@ impl Calendar { .child(label.into()) } + /// Render independent month columns so a sixth week cannot spill into the next month. fn render_days(&self, window: &mut Window, cx: &mut App) -> impl IntoElement { let state = self.state.read(cx); let weeks = [ @@ -801,29 +808,23 @@ impl Calendar { _ => this.gap_4().text_sm(), }) .justify_between() - .children( - state - .days() - .chunks(5) - .enumerate() - .map(|(offset_month, days)| { - v_flex() - .gap_0p5() - .child( - h_flex().gap_0p5().justify_between().children( - weeks - .iter() - .map(|week| self.render_week(week.clone(), window, cx)), - ), - ) - .children(days.iter().map(|week| { - h_flex().gap_0p5().justify_between().children( - week.iter() - .map(|d| self.render_day(d, offset_month, window, cx)), - ) - })) - }), - ) + .children(state.days().iter().enumerate().map(|(offset_month, days)| { + v_flex() + .gap_0p5() + .child( + h_flex().gap_0p5().justify_between().children( + weeks + .iter() + .map(|week| self.render_week(week.clone(), window, cx)), + ), + ) + .children(days.iter().map(|week| { + h_flex().gap_0p5().justify_between().children( + week.iter() + .map(|d| self.render_day(d, offset_month, window, cx)), + ) + })) + })) } fn render_week(&self, week: impl Into, _: &mut Window, cx: &mut App) -> Div { @@ -983,32 +984,4 @@ impl RenderOnce for Calendar { } #[cfg(test)] -mod tests { - use chrono::NaiveDate; - - use super::Date; - - #[test] - fn test_date_to_string() { - let date = Date::Single(Some(NaiveDate::from_ymd_opt(2024, 8, 3).unwrap())); - assert_eq!(date.to_string(), "2024-08-03"); - - let date = Date::Single(None); - assert_eq!(date.to_string(), "nil"); - - let date = Date::Range( - Some(NaiveDate::from_ymd_opt(2024, 8, 3).unwrap()), - Some(NaiveDate::from_ymd_opt(2024, 8, 5).unwrap()), - ); - assert_eq!(date.to_string(), "2024-08-03 - 2024-08-05"); - - let date = Date::Range(Some(NaiveDate::from_ymd_opt(2024, 8, 3).unwrap()), None); - assert_eq!(date.to_string(), "2024-08-03 - nil"); - - let date = Date::Range(None, Some(NaiveDate::from_ymd_opt(2024, 8, 5).unwrap())); - assert_eq!(date.to_string(), "nil - 2024-08-05"); - - let date = Date::Range(None, None); - assert_eq!(date.to_string(), "nil"); - } -} +mod tests; diff --git a/crates/moon-ui-components/src/time/calendar/tests.rs b/crates/moon-ui-components/src/time/calendar/tests.rs new file mode 100644 index 0000000..795ebfd --- /dev/null +++ b/crates/moon-ui-components/src/time/calendar/tests.rs @@ -0,0 +1,84 @@ +use chrono::NaiveDate; + +use super::Date; + +#[test] +fn test_date_to_string() { + let date = Date::Single(Some(NaiveDate::from_ymd_opt(2024, 8, 3).unwrap())); + assert_eq!(date.to_string(), "2024-08-03"); + + let date = Date::Single(None); + assert_eq!(date.to_string(), "nil"); + + let date = Date::Range( + Some(NaiveDate::from_ymd_opt(2024, 8, 3).unwrap()), + Some(NaiveDate::from_ymd_opt(2024, 8, 5).unwrap()), + ); + assert_eq!(date.to_string(), "2024-08-03 - 2024-08-05"); + + let date = Date::Range(Some(NaiveDate::from_ymd_opt(2024, 8, 3).unwrap()), None); + assert_eq!(date.to_string(), "2024-08-03 - nil"); + + let date = Date::Range(None, Some(NaiveDate::from_ymd_opt(2024, 8, 5).unwrap())); + assert_eq!(date.to_string(), "nil - 2024-08-05"); + + let date = Date::Range(None, None); + assert_eq!(date.to_string(), "nil"); +} + +use super::{Calendar, CalendarState}; +use crate::moon::{MoonTheme, ThemeMode}; +use gpui::{AppContext as _, Context, Entity, IntoElement, Render, VisualTestContext, Window}; + +/// Render two real month columns through the public Calendar builder. +struct MonthGridHarness(Entity); +impl Render for MonthGridHarness { + /// Keep adjacent five/six-week grids mounted together to detect boundary spill. + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + Calendar::new(&self.0).number_of_months(2) + } +} + +/// Five fixed weeks lose August 30/31; flattening then chunking at five mixes September's month +/// offset. Clicking the rendered sixth-week date must emit/select the exact requested date. +#[gpui::test] +fn sixth_week_dates_remain_selectable_beside_next_month(cx: &mut gpui::TestAppContext) { + cx.update(crate::init); + for theme in [ThemeMode::Dark, ThemeMode::Light] { + cx.update(|cx| MoonTheme::set_mode(theme, cx)); + let window = cx.add_window(|window, cx| { + let state = cx.new(|cx| CalendarState::new(window, cx)); + state.update(cx, |state, cx| { + state.set_date(NaiveDate::from_ymd_opt(2026, 8, 1).unwrap(), window, cx) + }); + MonthGridHarness(state) + }); + let state = window.read_with(cx, |view, _| view.0.clone()).unwrap(); + let mut visual = VisualTestContext::from_window(window.into(), cx); + visual.run_until_parked(); + for (date, selector) in [ + ("2026-08-30", "calendar-day-2026-08-30_0"), + ("2026-08-31", "calendar-day-2026-08-31_0"), + ] { + let bounds = visual + .debug_bounds(selector) + .expect("sixth-week date must render"); + visual.simulate_click(bounds.center(), gpui::Modifiers::default()); + visual.run_until_parked(); + visual.update(|_, cx| { + assert_eq!( + state.read(cx).date(), + Date::Single(Some(date.parse().unwrap())) + ) + }); + } + let august = visual.debug_bounds("calendar-day-2026-08-01_0").unwrap(); + let september = visual + .debug_bounds("calendar-day-2026-09-01_1") + .expect("independent next month"); + assert!( + september.left() > august.right(), + "month columns must remain separate" + ); + } +} diff --git a/crates/moon-ui-components/src/time/utils.rs b/crates/moon-ui-components/src/time/utils.rs index b9f47a6..88fff29 100644 --- a/crates/moon-ui-components/src/time/utils.rs +++ b/crates/moon-ui-components/src/time/utils.rs @@ -28,154 +28,25 @@ impl NaiveDateExt for chrono::NaiveDate { } } +/// Return complete Sunday-first weeks for a month, retaining the five-row minimum. +/// +/// Month zero and months past December are normalized across year boundaries. +/// Panics when the requested year or the surrounding grid exceeds chrono's date range. pub(crate) fn days_in_month(year: i32, month: u32) -> Vec> { - let mut year = year; - let mut month = month; - if month > 12 { - year += 1; - month = 1; - } - if month < 1 { - year -= 1; - month = 12; - } - - let date = NaiveDate::from_ymd_opt(year, month, 1).unwrap(); - let num_days = date.days_in_month(); - let start_weekday = date.weekday().num_days_from_sunday(); - - // Get the days in the month, 2023-02 will returns - // "29|30|31| 1| 2| 3| 4", - // " 5| 6| 7| 8| 9|10|11", - // "12|13|14|15|16|17|18", - // "19|20|21|22|23|24|25", - // "26|27|28| 1| 2| 3| 4", - let mut days = vec![]; - for n in 0..5 { - let mut week_days = vec![]; - for weekday in 0..7 { - let (mut y, mut m) = (year, month); - - // If the day is less than the start weekday, we need to go back to the previous month. - if n == 0 && weekday < start_weekday { - m = if m == 1 { 12 } else { m - 1 }; - y = if m == 1 { year - 1 } else { y }; - } - - // If start_weekday is 3, and n is 0 and weekday is 3, then day is 1. - // If start_weekday is 3, and n is 1 and weekday is 4, then day is 9. - let day = n * 7 + weekday as i32 - start_weekday as i32; - - // If the day is greater than the number of days in the month, we need to go to the next month. - if day > num_days { - m = if m == 12 { 1 } else { m + 1 }; - y = if m == 1 { year + 1 } else { y }; - } - - #[allow(clippy::expect_fun_call)] - let date = date - .checked_add_signed(Duration::days(day as i64)) - .expect(&format!("invalid date {}-{} days {}", y, m, day)); - week_days.push(date); - } - - days.push(week_days); - } - - days + let offset = i64::from(month) - 1; + let year = year + offset.div_euclid(12) as i32; + let month = offset.rem_euclid(12) as u32 + 1; + let first = NaiveDate::from_ymd_opt(year, month, 1).unwrap(); + let leading = first.weekday().num_days_from_sunday() as i32; + let weeks = ((leading + first.days_in_month() + 6) / 7).max(5); + (0..weeks) + .map(|week| { + (0..7) + .map(|day| first + Duration::days(i64::from(week * 7 + day - leading))) + .collect() + }) + .collect() } #[cfg(test)] -mod tests { - use chrono::{Datelike, NaiveDate}; - - use super::{NaiveDateExt, days_in_month}; - - #[test] - fn test_days_in_month() { - assert_eq!( - NaiveDate::from_ymd_opt(2024, 2, 1).unwrap().days_in_month(), - 29 - ); - assert_eq!( - NaiveDate::from_ymd_opt(2023, 2, 1).unwrap().days_in_month(), - 28 - ); - assert_eq!( - NaiveDate::from_ymd_opt(2023, 1, 1).unwrap().days_in_month(), - 31 - ); - assert_eq!( - NaiveDate::from_ymd_opt(2023, 4, 1).unwrap().days_in_month(), - 30 - ); - } - - #[test] - fn test_days() { - #[track_caller] - fn assert_case(date: NaiveDate, expected: Vec<&str>) { - let out = days_in_month(date.year(), date.month()) - .iter() - .map(|week| { - week.iter() - .map(|d| { - if d.year() == date.year() && d.month() == date.month() { - format!("{:2}", d.day()) - } else if d.year() == date.year() { - format!("{}-{}", d.month(), d.day()) - } else { - format!("{}-{}-{}", d.year(), d.month(), d.day()) - } - }) - .collect::>() - .join("|") - }) - .collect::>(); - - assert_eq!(out, expected); - } - - assert_case( - NaiveDate::from_ymd_opt(2024, 8, 1).unwrap(), - vec![ - "7-28|7-29|7-30|7-31| 1| 2| 3", - " 4| 5| 6| 7| 8| 9|10", - "11|12|13|14|15|16|17", - "18|19|20|21|22|23|24", - "25|26|27|28|29|30|31", - ], - ); - assert_case( - NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(), - vec![ - "2024-12-29|2024-12-30|2024-12-31| 1| 2| 3| 4", - " 5| 6| 7| 8| 9|10|11", - "12|13|14|15|16|17|18", - "19|20|21|22|23|24|25", - "26|27|28|29|30|31|2-1", - ], - ); - - assert_case( - NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(), - vec![ - "1-28|1-29|1-30|1-31| 1| 2| 3", - " 4| 5| 6| 7| 8| 9|10", - "11|12|13|14|15|16|17", - "18|19|20|21|22|23|24", - "25|26|27|28|29|3-1|3-2", - ], - ); - assert_case( - NaiveDate::from_ymd_opt(2023, 2, 20).unwrap(), - vec![ - "1-29|1-30|1-31| 1| 2| 3| 4", - " 5| 6| 7| 8| 9|10|11", - "12|13|14|15|16|17|18", - "19|20|21|22|23|24|25", - "26|27|28|3-1|3-2|3-3|3-4", - ], - ); - } -} +mod tests; diff --git a/crates/moon-ui-components/src/time/utils/tests.rs b/crates/moon-ui-components/src/time/utils/tests.rs new file mode 100644 index 0000000..a4080fa --- /dev/null +++ b/crates/moon-ui-components/src/time/utils/tests.rs @@ -0,0 +1,113 @@ +use chrono::{Datelike, NaiveDate}; + +use super::{NaiveDateExt, days_in_month}; + +#[test] +fn test_days_in_month() { + assert_eq!( + NaiveDate::from_ymd_opt(2024, 2, 1).unwrap().days_in_month(), + 29 + ); + assert_eq!( + NaiveDate::from_ymd_opt(2023, 2, 1).unwrap().days_in_month(), + 28 + ); + assert_eq!( + NaiveDate::from_ymd_opt(2023, 1, 1).unwrap().days_in_month(), + 31 + ); + assert_eq!( + NaiveDate::from_ymd_opt(2023, 4, 1).unwrap().days_in_month(), + 30 + ); +} + +#[test] +fn test_days() { + #[track_caller] + fn assert_case(date: NaiveDate, expected: Vec<&str>) { + let out = days_in_month(date.year(), date.month()) + .iter() + .map(|week| { + week.iter() + .map(|d| { + if d.year() == date.year() && d.month() == date.month() { + format!("{:2}", d.day()) + } else if d.year() == date.year() { + format!("{}-{}", d.month(), d.day()) + } else { + format!("{}-{}-{}", d.year(), d.month(), d.day()) + } + }) + .collect::>() + .join("|") + }) + .collect::>(); + + assert_eq!(out, expected); + } + + assert_case( + NaiveDate::from_ymd_opt(2024, 8, 1).unwrap(), + vec![ + "7-28|7-29|7-30|7-31| 1| 2| 3", + " 4| 5| 6| 7| 8| 9|10", + "11|12|13|14|15|16|17", + "18|19|20|21|22|23|24", + "25|26|27|28|29|30|31", + ], + ); + assert_case( + NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(), + vec![ + "2024-12-29|2024-12-30|2024-12-31| 1| 2| 3| 4", + " 5| 6| 7| 8| 9|10|11", + "12|13|14|15|16|17|18", + "19|20|21|22|23|24|25", + "26|27|28|29|30|31|2-1", + ], + ); + + assert_case( + NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(), + vec![ + "1-28|1-29|1-30|1-31| 1| 2| 3", + " 4| 5| 6| 7| 8| 9|10", + "11|12|13|14|15|16|17", + "18|19|20|21|22|23|24", + "25|26|27|28|29|3-1|3-2", + ], + ); + assert_case( + NaiveDate::from_ymd_opt(2023, 2, 20).unwrap(), + vec![ + "1-29|1-30|1-31| 1| 2| 3| 4", + " 5| 6| 7| 8| 9|10|11", + "12|13|14|15|16|17|18", + "19|20|21|22|23|24|25", + "26|27|28|3-1|3-2|3-3|3-4", + ], + ); +} + +/// Replacing the required sixth week with five rows hides August 30/31 from date selection. +#[test] +fn every_day_is_present_once_in_calendar_weeks() { + for year in [2024, 2026, 2100] { + for month in 1..=12 { + let weeks = days_in_month(year, month); + for day in 1..=NaiveDate::from_ymd_opt(year, month, 1) + .unwrap() + .days_in_month() + { + let date = NaiveDate::from_ymd_opt(year, month, day as u32).unwrap(); + assert_eq!( + weeks.iter().flatten().filter(|&&d| d == date).count(), + 1, + "{date}" + ); + } + assert!(weeks.iter().all(|week| week.len() == 7)); + } + } +} diff --git a/crates/moon-ui-components/themes/moon-obsidian.toml b/crates/moon-ui-components/themes/moon-obsidian.toml new file mode 100644 index 0000000..b7bd390 --- /dev/null +++ b/crates/moon-ui-components/themes/moon-obsidian.toml @@ -0,0 +1,144 @@ +mode = "Dark" + +[dark] +skin = "Facet" + +[dark.palette] +shell = 0x050608 +shell_high = 0x0D0F12 +window = 0x050608 +surface = 0x0A0B0E +panel = 0x171A1F +panel_high = 0x22262D +chrome = 0x0D0F12 +tabbar = 0x0D0F12 +panel_head = 0x1A1D22 +gutter = 0x08090B +chart_bg = 0x08090B +card = 0x13151A +row_alt = 0x0D0F12 +head_row = 0x101216 +border = 0x1F2227 +border_soft = 0x1A1D21 +border_card = 0x1F2227 +border_hover = 0x3A3F47 +row_line = 0x1A1D21 +shadow = 0x000000 +overlay = 0xFFFFFF +on_accent = 0xFFFFFF +text = 0xE8E4DC +text_soft = 0xA6A198 +text_dim = 0xE8E4DC +text_muted = 0x878279 +text_faint = 0x878279 +table_head = 0x101216 +table_body = 0x0D0F12 +table_selected = 0xFFB347 +table_hover = 0x171A1F +green = 0x34C07A +green_btn = 0x1E8C5B +green_text = 0x34C07A +red = 0xF26A69 +red_text = 0xF26A69 +red_soft_bd = 0xF26A69 +orange = 0xFF8E5A +amber = 0xFFB347 +blue = 0x7FC9FF +accent = 0xFFB347 +accent_fg = 0xFFCF94 +accent_tint_a = 0.11 +yellow = 0xFFD93D + +[dark.metrics] +header_top_h = 32.0 +toolbar_h = 32.0 +status_h = 22.0 +dock_tab_h = 26.0 +table_header_h = 26.0 +table_row_h = 25.0 +button_radius = 4.0 +container_radius = 8.0 +hairline = 1.0 + +[dark.scale] +ui = 1.0 +font = 1.0 +font_delta = 0.0 +zoom = 1.0 + +[dark.typography] +font_family = "Inter" +mono_font_family = "Geist Mono" +font_size = 12.0 +mono_font_size = 11.0 +rem_size = 16.0 + +[light.palette] +shell = 0xF3F5F7 +shell_high = 0xFAFBFC +window = 0xF7F8FA +surface = 0xFFFFFF +panel = 0xF8FAFC +panel_high = 0xFFFFFF +chrome = 0xF5F7FA +tabbar = 0xF2F5F8 +panel_head = 0xF5F7FA +gutter = 0xEEF2F6 +chart_bg = 0xFFFFFF +card = 0xFFFFFF +row_alt = 0xFCFDFE +head_row = 0xF3F6F8 +border = 0xD5DBE1 +border_soft = 0xE1E5EA +border_card = 0xDCE2E8 +border_hover = 0xB8C2CC +row_line = 0xECEFF2 +shadow = 0x000000 +overlay = 0x000000 +on_accent = 0xFFFFFF +text = 0x17202A +text_soft = 0x4B5865 +text_dim = 0x2D3945 +text_muted = 0x768391 +text_faint = 0x98A3AE +table_head = 0xF3F6F8 +table_body = 0xFFFFFF +table_selected = 0x009DFF +table_hover = 0xEEF2F6 +green = 0x178A57 +green_btn = 0x178A57 +green_text = 0x0E6E45 +red = 0xD2483F +red_text = 0xB7352F +red_soft_bd = 0xE1B5B0 +orange = 0xD18A2B +amber = 0xB97824 +blue = 0x2B6F9E +accent = 0x009DFF +accent_fg = 0x0A3F68 +accent_tint_a = 0.08 +yellow = 0xB8860B + +[light.metrics] +header_top_h = 32.0 +toolbar_h = 32.0 +status_h = 22.0 +dock_tab_h = 26.0 +table_header_h = 26.0 +table_row_h = 25.0 +button_radius = 4.0 +container_radius = 8.0 +hairline = 1.0 + +[light.scale] +ui = 1.0 +font = 1.0 +font_delta = 0.0 +zoom = 1.0 + +[light.typography] +font_family = "Inter" +mono_font_family = "Geist Mono" +font_size = 12.0 +mono_font_size = 11.0 +rem_size = 16.0 diff --git a/crates/moon-ui-gallery/src/gallery.rs b/crates/moon-ui-gallery/src/gallery.rs index 3233cde..9b55ef6 100644 --- a/crates/moon-ui-gallery/src/gallery.rs +++ b/crates/moon-ui-gallery/src/gallery.rs @@ -63,7 +63,13 @@ pub(super) struct Gallery { enum GalleryStyle { Dark, Graphite, + /// The faceted dark theme: `MoonPalette::OBSIDIAN` drawn with `MoonSkin::Facet`. + Obsidian, Light, + /// The light palette drawn with `MoonSkin::Facet`: the rules Obsidian is drawn by, on a + /// light theme. Not a shipped theme — it is here to show that the skin is not tied to one + /// palette. + LightFacet, /// The dark colour mode, `MoonColors::DARK`. DarkNew, /// The light colour mode, `MoonColors::LIGHT`. @@ -72,20 +78,24 @@ enum GalleryStyle { impl GalleryStyle { /// Every style, in menu order. - const ALL: [Self; 5] = [ + const ALL: [Self; 7] = [ Self::Dark, Self::Graphite, + Self::Obsidian, Self::Light, + Self::LightFacet, Self::DarkNew, Self::LightNew, ]; - /// Returns the style a launch mode opens in: `Light` for the light mode, `Dark` otherwise, - /// since the system mode renders the dark theme. - fn for_mode(mode: ThemeMode) -> Self { - match mode { - ThemeMode::Light => Self::Light, - ThemeMode::Dark | ThemeMode::System => Self::Dark, + /// Returns the style a launch theme opens in: `Obsidian` for that theme, otherwise `Light` + /// for the light mode and `Dark` for the rest, since the system mode renders the dark theme. + fn for_theme(theme: GalleryTheme) -> Self { + match theme { + GalleryTheme::Obsidian => Self::Obsidian, + GalleryTheme::LightFacet => Self::LightFacet, + GalleryTheme::Mode(ThemeMode::Light) => Self::Light, + GalleryTheme::Mode(ThemeMode::Dark | ThemeMode::System) => Self::Dark, } } @@ -94,7 +104,9 @@ impl GalleryStyle { match self { Self::Dark => "Dark", Self::Graphite => "Graphite", + Self::Obsidian => "Obsidian", Self::Light => "Light", + Self::LightFacet => "Light (facet)", Self::DarkNew => "Dark (new)", Self::LightNew => "Light (new)", } @@ -112,10 +124,11 @@ impl GalleryStyle { /// Returns `current` switched to this style. /// - /// Only the mode and each side's palette and colour roles change, so the theme's scale, - /// metrics and typography carry over. Both sides are set for every style: switching back from - /// Graphite or a colour mode must put the legacy palettes back and drop the installed roles, - /// or the previous style's colours would stay behind the new label. + /// Only the mode and each side's skin, palette and colour roles change, so the theme's + /// scale, metrics and typography carry over. Both sides are set for every style: switching + /// back from Graphite, Obsidian or a colour mode must put the legacy palettes and the flat + /// skin back and drop the installed roles, or the previous style's colours or facets would + /// stay behind the new label. fn config(self, mut current: MoonThemeConfig) -> MoonThemeConfig { if self.is_color_mode() { let modes = MoonThemeConfig::moon_color_modes(); @@ -123,18 +136,28 @@ impl GalleryStyle { (current.light.palette, current.light.colors) = (modes.light.palette, modes.light.colors); } else { - current.dark.palette = if self == Self::Graphite { - MoonPalette::GRAPHITE - } else { - MoonPalette::TERMINAL + current.dark.palette = match self { + Self::Graphite => MoonPalette::GRAPHITE, + Self::Obsidian => MoonPalette::OBSIDIAN, + _ => MoonPalette::TERMINAL, }; current.light.palette = MoonPalette::LIGHT; current.dark.colors = None; current.light.colors = None; } + current.dark.skin = if self == Self::Obsidian { + MoonSkin::Facet + } else { + MoonSkin::Flat + }; + current.light.skin = if self == Self::LightFacet { + MoonSkin::Facet + } else { + MoonSkin::Flat + }; current.mode = match self { - Self::Light | Self::LightNew => ThemeMode::Light, - Self::Dark | Self::Graphite | Self::DarkNew => ThemeMode::Dark, + Self::Light | Self::LightFacet | Self::LightNew => ThemeMode::Light, + Self::Dark | Self::Graphite | Self::Obsidian | Self::DarkNew => ThemeMode::Dark, }; current } @@ -172,7 +195,7 @@ impl Gallery { cx: &mut Context, active_page: usize, snapshot_dir: Option, - theme_mode: ThemeMode, + theme: GalleryTheme, ) -> Self { let select_state = cx.new(|cx| { MoonSelectState::new( @@ -321,7 +344,7 @@ impl Gallery { Self { active_page: active_page.min(GALLERY_PAGES.len().saturating_sub(1)), - style: GalleryStyle::for_mode(theme_mode), + style: GalleryStyle::for_theme(theme), snapshot: snapshot_dir.map(|dir| SnapshotRun { dir, page_ix: active_page.min(GALLERY_PAGES.len().saturating_sub(1)), @@ -3562,7 +3585,11 @@ impl Render for Gallery { v_flex() .size_full() - .bg(rgba_from(p.shell, 1.0)) + // Under the Facet skin the root paints the window backdrop; an opaque fill here + // would cover it. + .when(!MoonSkin::active(cx).is_facet(), |this| { + this.bg(rgba_from(p.shell, 1.0)) + }) .text_color(rgb(p.text)) .child(self.render_header(cx)) .child(self.render_page_tabs(cx)) diff --git a/crates/moon-ui-gallery/src/gallery/tests.rs b/crates/moon-ui-gallery/src/gallery/tests.rs index 8bdc8ac..f13e14b 100644 --- a/crates/moon-ui-gallery/src/gallery/tests.rs +++ b/crates/moon-ui-gallery/src/gallery/tests.rs @@ -1,6 +1,6 @@ use super::GalleryStyle; -use crate::{COMPONENT_COVERAGE, page_index, parse_theme_mode, theme_mode_name}; -use moon_ui::{MoonColors, MoonPalette, MoonThemeConfig, ThemeMode}; +use crate::{COMPONENT_COVERAGE, GalleryTheme, page_index, parse_gallery_theme, parse_theme_mode}; +use moon_ui::{MoonColors, MoonPalette, MoonSkin, MoonThemeConfig, ThemeMode}; use serde::Deserialize; const COMPONENT_MANIFEST_JSON: &str = @@ -66,9 +66,10 @@ fn gallery_theme_cli_names_match_modes() { assert_eq!(parse_theme_mode("Light"), Some(ThemeMode::Light)); assert_eq!(parse_theme_mode("system"), Some(ThemeMode::System)); assert_eq!(parse_theme_mode("missing"), None); - assert_eq!(theme_mode_name(ThemeMode::Dark), "Dark"); - assert_eq!(theme_mode_name(ThemeMode::Light), "Light"); - assert_eq!(theme_mode_name(ThemeMode::System), "System"); + // The snapshot writer must not let a second dark/light skin overwrite the original baseline. + for name in ["dark", "light", "system", "obsidian", "light-facet"] { + assert_eq!(parse_gallery_theme(name).unwrap().snapshot_name(), name); + } } /// Catches `gallery.rs:GalleryStyle::config` leaving the previous style behind: choosing Dark after @@ -101,6 +102,119 @@ fn switching_styles_installs_each_styles_own_palette_and_mode() { assert_eq!(dark.dark.colors, None); } +/// Catches `main.rs:parse_gallery_theme` no longer recognising `obsidian`, which would open the +/// dark terminal theme under that name, or swallowing a mode name, which would change what +/// `--theme dark` and `--theme light` open. +#[test] +fn gallery_theme_cli_names_select_obsidian_or_a_mode() { + assert_eq!( + parse_gallery_theme("obsidian"), + Some(GalleryTheme::Obsidian) + ); + assert_eq!( + parse_gallery_theme("Obsidian"), + Some(GalleryTheme::Obsidian) + ); + assert_eq!( + parse_gallery_theme("dark"), + Some(GalleryTheme::Mode(ThemeMode::Dark)) + ); + assert_eq!( + parse_gallery_theme("Light"), + Some(GalleryTheme::Mode(ThemeMode::Light)) + ); + assert_eq!( + parse_gallery_theme("system"), + Some(GalleryTheme::Mode(ThemeMode::System)) + ); + assert_eq!(parse_gallery_theme("missing"), None); +} + +/// Catches `main.rs:GalleryTheme::config` installing the wrong bundled theme: `dark` and +/// `light` must stay the flat terminal theme in that mode, and only `obsidian` may bring the +/// Obsidian palette and the Facet skin, with the header's style menu opening on the same name. +#[test] +fn launch_themes_install_the_terminal_theme_by_mode_and_obsidian_by_name() { + let dark = GalleryTheme::Mode(ThemeMode::Dark).config(); + assert_eq!(dark.mode, ThemeMode::Dark); + assert_eq!(dark.dark.palette, MoonPalette::TERMINAL); + assert_eq!(dark.dark.skin, MoonSkin::Flat); + + let light = GalleryTheme::Mode(ThemeMode::Light).config(); + assert_eq!(light.mode, ThemeMode::Light); + assert_eq!(light.light.palette, MoonPalette::LIGHT); + assert_eq!(light.light.skin, MoonSkin::Flat); + + let obsidian = GalleryTheme::Obsidian.config(); + assert_eq!(obsidian.mode, ThemeMode::Dark); + assert_eq!(obsidian.dark.palette, MoonPalette::OBSIDIAN); + assert_eq!(obsidian.dark.skin, MoonSkin::Facet); + assert_eq!(GalleryTheme::Obsidian.config().mode, ThemeMode::Dark); + + for (theme, style) in [ + (GalleryTheme::Obsidian, GalleryStyle::Obsidian), + (GalleryTheme::Mode(ThemeMode::Dark), GalleryStyle::Dark), + (GalleryTheme::Mode(ThemeMode::System), GalleryStyle::Dark), + (GalleryTheme::Mode(ThemeMode::Light), GalleryStyle::Light), + (GalleryTheme::LightFacet, GalleryStyle::LightFacet), + ] { + assert_eq!(GalleryStyle::for_theme(theme), style); + } +} + +/// Catches the light faceted style losing its skin or its side: `--theme light-facet` and the +/// "Light (facet)" menu row exist to show the Facet rules on a light palette, so each must +/// install the light palette, in light mode, with the Facet skin on that side only. +#[test] +fn the_light_facet_style_draws_the_light_palette_with_the_facet_skin() { + let launched = GalleryTheme::LightFacet.config(); + let switched = GalleryStyle::LightFacet.config(MoonThemeConfig::moon_obsidian()); + for config in [launched, switched.clone()] { + assert_eq!(config.mode, ThemeMode::Light); + assert_eq!(config.light.palette, MoonPalette::LIGHT); + assert_eq!(config.light.skin, MoonSkin::Facet); + assert_eq!(config.dark.skin, MoonSkin::Flat); + } + assert_eq!(GalleryTheme::LightFacet.config().mode, ThemeMode::Light); + + let back = GalleryStyle::Light.config(switched); + assert_eq!(back.light.skin, MoonSkin::Flat); +} + +/// Catches `gallery.rs:GalleryStyle::config` leaving the Facet skin behind: choosing Graphite +/// after Obsidian would draw graphite surfaces faceted under a "Graphite" label. +#[test] +fn switching_away_from_obsidian_puts_the_flat_skin_back() { + let obsidian = GalleryStyle::Obsidian.config(MoonThemeConfig::moon_terminal()); + assert_eq!(obsidian.mode, ThemeMode::Dark); + assert_eq!(obsidian.dark.palette, MoonPalette::OBSIDIAN); + assert_eq!(obsidian.dark.skin, MoonSkin::Facet); + assert_eq!(obsidian.light.palette, MoonPalette::LIGHT); + assert_eq!(obsidian.light.skin, MoonSkin::Flat); + + for style in GalleryStyle::ALL { + if style == GalleryStyle::Obsidian { + continue; + } + let next = style.config(obsidian.clone()); + assert_eq!( + next.dark.skin, + MoonSkin::Flat, + "{}: dark side", + style.name() + ); + // The light faceted style is the one other style that draws with the skin, and only + // on the side it renders. + let light_skin = if style == GalleryStyle::LightFacet { + MoonSkin::Facet + } else { + MoonSkin::Flat + }; + assert_eq!(next.light.skin, light_skin, "{}: light side", style.name()); + assert_ne!(next.dark.palette, MoonPalette::OBSIDIAN, "{}", style.name()); + } +} + /// Catches a style's menu label drifting from the key `GalleryStyle::from_name` looks up: the /// dropdown would show the option, but selecting it would silently do nothing. #[test] diff --git a/crates/moon-ui-gallery/src/handoff.rs b/crates/moon-ui-gallery/src/handoff.rs index ec976ac..9adb9c4 100644 --- a/crates/moon-ui-gallery/src/handoff.rs +++ b/crates/moon-ui-gallery/src/handoff.rs @@ -56,7 +56,7 @@ struct CaseSnapshotRun { pub(super) struct CaseGallery { snapshot: Option, #[cfg(feature = "snapshot")] - theme_mode: ThemeMode, + theme: GalleryTheme, #[cfg(feature = "snapshot")] dialog_case_open: bool, slider_58_state: Entity, @@ -86,10 +86,10 @@ impl CaseGallery { cx: &mut Context, snapshot_dir: Option, snapshot_case_ids: Vec, - theme_mode: ThemeMode, + theme: GalleryTheme, ) -> Self { #[cfg(not(feature = "snapshot"))] - let _ = theme_mode; + let _ = theme; let first_case = HANDOFF_CASES.first().copied().unwrap_or(HandoffCase { id: "empty", @@ -209,7 +209,7 @@ impl CaseGallery { targeted, }), #[cfg(feature = "snapshot")] - theme_mode, + theme, #[cfg(feature = "snapshot")] dialog_case_open: false, slider_58_state, @@ -291,9 +291,7 @@ impl CaseGallery { return; } - let theme_dir = snapshot - .dir - .join(theme_mode_name(self.theme_mode).to_ascii_lowercase()); + let theme_dir = snapshot.dir.join(self.theme.snapshot_name()); if !snapshot.targeted && !snapshot.cleaned_dir { if let Err(err) = clear_snapshot_dir(&theme_dir) { eprintln!( diff --git a/crates/moon-ui-gallery/src/launch.rs b/crates/moon-ui-gallery/src/launch.rs index e6451ce..b4e6529 100644 --- a/crates/moon-ui-gallery/src/launch.rs +++ b/crates/moon-ui-gallery/src/launch.rs @@ -9,14 +9,12 @@ pub(super) fn run_gallery() { let snapshot_dir = args.snapshot_dir; let case_snapshot_dir = args.case_snapshot_dir; let snapshot_case_ids = args.snapshot_case_ids; - let theme_mode = args.theme_mode; + let theme = args.theme; application() .with_assets(moon_ui::MoonAssets) .run(move |cx: &mut App| { moon_ui::foundation::init(cx); - let mut theme_config = MoonThemeConfig::moon_terminal(); - theme_config.mode = theme_mode; - MoonTheme::install_config(theme_config, cx); + MoonTheme::install_config(theme.config(), cx); let p = MoonPalette::active(cx); if let Some(case_dir) = case_snapshot_dir.clone() { @@ -42,7 +40,7 @@ pub(super) fn run_gallery() { cx, Some(case_dir.clone()), snapshot_case_ids.clone(), - theme_mode, + theme, ) }); cx.new(|cx| { @@ -75,7 +73,7 @@ pub(super) fn run_gallery() { cx, initial_page, snapshot_dir.clone(), - theme_mode, + theme, ) }); cx.new(|cx| { diff --git a/crates/moon-ui-gallery/src/main.rs b/crates/moon-ui-gallery/src/main.rs index c381ea4..e80206e 100644 --- a/crates/moon-ui-gallery/src/main.rs +++ b/crates/moon-ui-gallery/src/main.rs @@ -34,16 +34,17 @@ use moon_ui::{ MoonSelectItem, MoonSelectState, MoonSelectableCard, MoonSelectorPill, MoonSelectorSegment, MoonSeparator, MoonSettingField, MoonSettingGroup, MoonSettingItem, MoonSettingPage, MoonSettings, MoonSidebar, MoonSidebarGroup, MoonSidebarMenu, MoonSidebarMenuItem, - MoonSidebarToggleButton, MoonSize, MoonSkeleton, MoonSlider, MoonSliderState, MoonSpinner, - MoonSpinnerSize, MoonStackedBar, MoonStatusBar, MoonStatusIndicator, MoonStatusItem, - MoonStepper, MoonSteps, MoonSurface, MoonSurfaceVariant, MoonTabItem, MoonTabStrip, - MoonTableCell, MoonTableColumn, MoonTableRow, MoonTableStyle, MoonTag, MoonTagRadius, - MoonTagVariant, MoonText, MoonTextArea, MoonTheme, MoonThemeConfig, MoonTimePicker, - MoonTimePickerState, MoonToggle, MoonToggleLabelSide, MoonToggleVariant, MoonTone, MoonTooltip, - MoonTooltipPlacement, MoonTooltipSize, MoonTooltipView, MoonTree, MoonTreeItem, - MoonTreeSelectionMode, MoonTreeState, MoonVirtualList, MoonVirtualListScrollHandle, - MoonWindowExt as _, MoonWindowFrame, MoonWindowFrameBrand, MoonWindowFrameControls, PanelView, - Root, TabPanel, ThemeMode, h_flex, moon_h_resizable, moon_resizable_panel, rgba_from, v_flex, + MoonSidebarToggleButton, MoonSize, MoonSkeleton, MoonSkin, MoonSlider, MoonSliderState, + MoonSpinner, MoonSpinnerSize, MoonStackedBar, MoonStatusBar, MoonStatusIndicator, + MoonStatusItem, MoonStepper, MoonSteps, MoonSurface, MoonSurfaceVariant, MoonTabItem, + MoonTabStrip, MoonTableCell, MoonTableColumn, MoonTableRow, MoonTableStyle, MoonTag, + MoonTagRadius, MoonTagVariant, MoonText, MoonTextArea, MoonTheme, MoonThemeConfig, + MoonTimePicker, MoonTimePickerState, MoonToggle, MoonToggleLabelSide, MoonToggleVariant, + MoonTone, MoonTooltip, MoonTooltipPlacement, MoonTooltipSize, MoonTooltipView, MoonTree, + MoonTreeItem, MoonTreeSelectionMode, MoonTreeState, MoonVirtualList, + MoonVirtualListScrollHandle, MoonWindowExt as _, MoonWindowFrame, MoonWindowFrameBrand, + MoonWindowFrameControls, PanelView, Root, TabPanel, ThemeMode, h_flex, moon_h_resizable, + moon_resizable_panel, rgba_from, v_flex, }; mod gallery; @@ -181,7 +182,53 @@ struct GalleryArgs { snapshot_dir: Option, case_snapshot_dir: Option, snapshot_case_ids: Vec, - theme_mode: ThemeMode, + theme: GalleryTheme, +} + +/// What a `--theme` value opens the gallery in. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum GalleryTheme { + /// `dark`, `light` or `system`: the terminal theme in that mode. + Mode(ThemeMode), + /// `obsidian`: the faceted dark theme, `MoonThemeConfig::moon_obsidian()`. + Obsidian, + /// `light-facet`: the terminal's light theme drawn with the Facet skin. + LightFacet, +} + +impl GalleryTheme { + /// What the gallery opens in when `--theme` is absent or names nothing known. + const DEFAULT: Self = Self::Mode(ThemeMode::Dark); + + /// Keep snapshot directories distinct even when two themes share the same dark/light mode. + #[cfg(any(feature = "snapshot", test))] + fn snapshot_name(self) -> &'static str { + match self { + Self::Mode(ThemeMode::Dark) => "dark", + Self::Mode(ThemeMode::Light) => "light", + Self::Mode(ThemeMode::System) => "system", + Self::Obsidian => "obsidian", + Self::LightFacet => "light-facet", + } + } + + /// Returns the theme configuration to install for this choice. + fn config(self) -> MoonThemeConfig { + match self { + Self::Mode(mode) => { + let mut config = MoonThemeConfig::moon_terminal(); + config.mode = mode; + config + } + Self::Obsidian => MoonThemeConfig::moon_obsidian(), + Self::LightFacet => { + let mut config = MoonThemeConfig::moon_terminal(); + config.mode = ThemeMode::Light; + config.light.skin = moon_ui::MoonSkin::Facet; + config + } + } + } } fn gallery_args_from_cli() -> GalleryArgs { @@ -190,7 +237,7 @@ fn gallery_args_from_cli() -> GalleryArgs { let mut snapshot_dir = None; let mut case_snapshot_dir = None; let mut snapshot_case_ids = Vec::new(); - let mut theme_mode = ThemeMode::Dark; + let mut theme = GalleryTheme::DEFAULT; while let Some(arg) = args.next() { if arg == "--page" { if let Some(page_name) = args.next() { @@ -217,11 +264,11 @@ fn gallery_args_from_cli() -> GalleryArgs { } else if let Some(cases) = arg.strip_prefix("--snapshot-cases=") { snapshot_case_ids.extend(parse_snapshot_case_ids(cases)); } else if arg == "--theme" { - if let Some(mode) = args.next() { - theme_mode = parse_theme_mode(&mode).unwrap_or(ThemeMode::Dark); + if let Some(name) = args.next() { + theme = parse_gallery_theme(&name).unwrap_or(GalleryTheme::DEFAULT); } - } else if let Some(mode) = arg.strip_prefix("--theme=") { - theme_mode = parse_theme_mode(mode).unwrap_or(ThemeMode::Dark); + } else if let Some(name) = arg.strip_prefix("--theme=") { + theme = parse_gallery_theme(name).unwrap_or(GalleryTheme::DEFAULT); } } if snapshot_dir.is_some() { @@ -232,7 +279,7 @@ fn gallery_args_from_cli() -> GalleryArgs { snapshot_dir, case_snapshot_dir, snapshot_case_ids, - theme_mode, + theme, } } @@ -250,6 +297,18 @@ fn page_index(page: &str) -> Option { .position(|candidate| candidate.eq_ignore_ascii_case(page)) } +/// Parses a `--theme` value, in any letter case: `obsidian` names that bundled theme, and +/// anything else is read as a theme mode by [`parse_theme_mode`]. +fn parse_gallery_theme(name: &str) -> Option { + if name.eq_ignore_ascii_case("obsidian") { + Some(GalleryTheme::Obsidian) + } else if name.eq_ignore_ascii_case("light-facet") { + Some(GalleryTheme::LightFacet) + } else { + parse_theme_mode(name).map(GalleryTheme::Mode) + } +} + fn parse_theme_mode(mode: &str) -> Option { match mode.to_ascii_lowercase().as_str() { "light" => Some(ThemeMode::Light), @@ -258,15 +317,3 @@ fn parse_theme_mode(mode: &str) -> Option { _ => None, } } - -/// Returns the display name of a theme mode, which the handoff snapshot run also uses for its -/// per-theme directory. Only that snapshot build reads it outside tests since the gallery header -/// switched to named styles. -#[cfg_attr(not(feature = "snapshot"), allow(dead_code))] -fn theme_mode_name(mode: ThemeMode) -> &'static str { - match mode { - ThemeMode::Light => "Light", - ThemeMode::Dark => "Dark", - ThemeMode::System => "System", - } -} diff --git a/docs/PALETTE_SPEC.md b/docs/PALETTE_SPEC.md index ce9898b..13ab387 100644 --- a/docs/PALETTE_SPEC.md +++ b/docs/PALETTE_SPEC.md @@ -94,19 +94,53 @@ application panels. ### Bundled themes -Three palettes ship in the crate, each a `MoonPalette` constant in +Four palettes ship in the crate, each a `MoonPalette` constant in `moon/tokens.rs` paired with a theme file under `crates/moon-ui-components/themes/`: -| theme | file | palette | colour set | -|---|---|---|---| -| Terminal | `moon-terminal.toml` | `MoonPalette::TERMINAL` | dark | -| Light | `moon-light.toml` | `MoonPalette::LIGHT` | light | -| Graphite | `moon-graphite.toml` | `MoonPalette::GRAPHITE` | dark | +| theme | file | palette | colour set | skin | +|---|---|---|---|---| +| Terminal | `moon-terminal.toml` | `MoonPalette::TERMINAL` | dark | flat | +| Light | `moon-light.toml` | `MoonPalette::LIGHT` | light | flat | +| Graphite | `moon-graphite.toml` | `MoonPalette::GRAPHITE` | dark | flat | +| Obsidian | `moon-obsidian.toml` | `MoonPalette::OBSIDIAN` | dark | facet | The constant and the file hold the same values on purpose — the constant is what code reads, the file is what a host installs — so a change to one is a change to both, and a test pins the pair. +The skin is the manner in which a theme side draws depth, `MoonSkin` in +`moon/skin.rs`, and it is separate from the palette. `Flat` is a solid fill and a +hairline, and is what a theme file gets when it names no skin. `Facet` lights +each surface from above — a gradient fill, a one-pixel edge, a highlight and a +shadow — with every colour derived from the palette by the rules in that module, +so it needs no palette entries of its own. A component consults the skin only to +draw the faceted look; under `Flat` it must paint exactly what it painted before +skins existed. The skin is set per side: Obsidian's light side is the flat light +theme. + +Three rules of the faceted look are easy to break from a component: + +- **A blur is written as the design gives it.** The design's shadows and glows + are CSS `box-shadow` values, where the gaussian's standard deviation is half + the blur; the renderer takes the standard deviation itself as its blur radius. + `facet`, `facet_tinted`, `facet_glow` and the backdrop lights convert on the + way in, so a component passes the design's number to them. A shadow built + directly with `box_shadow(..)` is not converted, and a design blur passed that + way renders twice as wide and far fainter than designed. +- **A small socket is not a well.** A checkbox box, a radio circle and a toggle + track take `MoonFacetKind::Socket`: the well's fill and shadows under an edge + that still reads at a dozen pixels. The plain `Well` edge is right for an + input and disappears on a control that small. +- **The ambient light belongs to the window, not to a panel.** `MoonRoot` paints + `moon_backdrop` when it owns an opaque window fill. A host whose root paints + nothing, because something shows through it, lays `moon_ambient_light` inside + an opaque band of its own, clipped to that band, and never over what shows + through. + +The rules hold on a light palette too: the gallery's "Light (facet)" style +(`--theme light-facet`) draws `MoonPalette::LIGHT` with the Facet skin. It is a +demonstration, not a bundled theme. + Two rules bind a new bundled palette: - **Which side it is on is MEASURED, never declared.** `MoonPalette::is_light` diff --git a/docs/component-api-baseline.json b/docs/component-api-baseline.json index b7c8a90..c3d023a 100644 --- a/docs/component-api-baseline.json +++ b/docs/component-api-baseline.json @@ -1499,7 +1499,7 @@ }, { "file": "crates/moon-ui-components/src/moon/dock.rs", - "signature": "pub fn set_dock_open( &mut self, placement: DockPlacement, open: bool, cx: &mut Context, ) -> bool" + "signature": "pub fn set_dock_open( &mut self, placement: DockPlacement, open: bool, window: &mut Window, cx: &mut Context, ) -> bool" }, { "file": "crates/moon-ui-components/src/moon/dock.rs", @@ -1575,7 +1575,7 @@ }, { "file": "crates/moon-ui-components/src/moon/dock.rs", - "signature": "pub fn toggle_dock(&mut self, placement: DockPlacement, cx: &mut Context) -> bool" + "signature": "pub fn toggle_dock( &mut self, placement: DockPlacement, window: &mut Window, cx: &mut Context, ) -> bool" }, { "file": "crates/moon-ui-components/src/moon/dock.rs", @@ -2777,6 +2777,10 @@ "file": "crates/moon-ui-components/src/moon/mod.rs", "signature": "pub use skeleton::MoonSkeleton;" }, + { + "file": "crates/moon-ui-components/src/moon/mod.rs", + "signature": "pub use skin::{ MoonFacet, MoonFacetKind, MoonFacetTint, MoonSkin, facet, facet_glow, facet_tinted, fading_rule_h, fading_rule_v, moon_ambient_light, moon_backdrop, };" + }, { "file": "crates/moon-ui-components/src/moon/mod.rs", "signature": "pub use slider::{MoonSlider, MoonSliderEvent, MoonSliderState};" @@ -2827,7 +2831,7 @@ }, { "file": "crates/moon-ui-components/src/moon/mod.rs", - "signature": "pub use text_area::{MoonTextArea, MoonTextAreaEvent, MoonTextAreaState};" + "signature": "pub use text_area::{MoonTextArea, MoonTextAreaEvent, MoonTextAreaSize, MoonTextAreaState};" }, { "file": "crates/moon-ui-components/src/moon/mod.rs", @@ -3973,6 +3977,62 @@ "file": "crates/moon-ui-components/src/moon/skeleton.rs", "signature": "pub struct MoonSkeleton" }, + { + "file": "crates/moon-ui-components/src/moon/skin.rs", + "signature": "pub enum MoonFacetKind" + }, + { + "file": "crates/moon-ui-components/src/moon/skin.rs", + "signature": "pub enum MoonFacetTint" + }, + { + "file": "crates/moon-ui-components/src/moon/skin.rs", + "signature": "pub enum MoonSkin" + }, + { + "file": "crates/moon-ui-components/src/moon/skin.rs", + "signature": "pub fn active(cx: &App) -> Self" + }, + { + "file": "crates/moon-ui-components/src/moon/skin.rs", + "signature": "pub fn facet(kind: MoonFacetKind, tokens: &MoonThemeTokens) -> MoonFacet" + }, + { + "file": "crates/moon-ui-components/src/moon/skin.rs", + "signature": "pub fn facet_glow(color: u32, alpha: f32, blur: f32, tokens: &MoonThemeTokens) -> BoxShadow" + }, + { + "file": "crates/moon-ui-components/src/moon/skin.rs", + "signature": "pub fn facet_tinted(tone: u32, tint: MoonFacetTint, tokens: &MoonThemeTokens) -> MoonFacet" + }, + { + "file": "crates/moon-ui-components/src/moon/skin.rs", + "signature": "pub fn fading_rule_h(color: Hsla) -> impl IntoElement" + }, + { + "file": "crates/moon-ui-components/src/moon/skin.rs", + "signature": "pub fn fading_rule_v(color: Hsla) -> impl IntoElement" + }, + { + "file": "crates/moon-ui-components/src/moon/skin.rs", + "signature": "pub fn is_facet(self) -> bool" + }, + { + "file": "crates/moon-ui-components/src/moon/skin.rs", + "signature": "pub fn moon_ambient_light(color: u32, alpha: f32, tokens: &MoonThemeTokens) -> Div" + }, + { + "file": "crates/moon-ui-components/src/moon/skin.rs", + "signature": "pub fn moon_backdrop(tokens: &MoonThemeTokens) -> Option" + }, + { + "file": "crates/moon-ui-components/src/moon/skin.rs", + "signature": "pub fn paint(self, this: S) -> S" + }, + { + "file": "crates/moon-ui-components/src/moon/skin.rs", + "signature": "pub struct MoonFacet" + }, { "file": "crates/moon-ui-components/src/moon/slider.rs", "signature": "pub fn bounds(mut self, bounds: MoonRect) -> Self" @@ -4353,6 +4413,10 @@ "file": "crates/moon-ui-components/src/moon/tab.rs", "signature": "pub fn badge(mut self, badge: impl Into) -> Self" }, + { + "file": "crates/moon-ui-components/src/moon/tab.rs", + "signature": "pub fn bottom_rule(mut self, bottom_rule: bool) -> Self" + }, { "file": "crates/moon-ui-components/src/moon/tab.rs", "signature": "pub fn bounds(mut self, bounds: MoonRect) -> Self" @@ -4869,6 +4933,10 @@ "file": "crates/moon-ui-components/src/moon/theme.rs", "signature": "pub fn moon_light() -> Self" }, + { + "file": "crates/moon-ui-components/src/moon/theme.rs", + "signature": "pub fn moon_obsidian() -> Self" + }, { "file": "crates/moon-ui-components/src/moon/theme.rs", "signature": "pub fn moon_terminal() -> Self" @@ -5133,6 +5201,10 @@ "file": "crates/moon-ui-components/src/moon/tokens.rs", "signature": "pub const LIGHT: Self = Self" }, + { + "file": "crates/moon-ui-components/src/moon/tokens.rs", + "signature": "pub const OBSIDIAN: Self = Self" + }, { "file": "crates/moon-ui-components/src/moon/tokens.rs", "signature": "pub const TERMINAL: Self = Self" diff --git a/docs/component-class-migrations.json b/docs/component-class-migrations.json index 1a9052a..3219ef1 100644 --- a/docs/component-class-migrations.json +++ b/docs/component-class-migrations.json @@ -1,6 +1,12 @@ { "version": 1, "migrations": [ + { + "concept": "calendar", + "from": "Mirror", + "to": "TrackedFork", + "reason": "The reviewed calendar correction keeps every selectable date in five/six-week months and preserves separate month grids in multi-month views. The Longbridge lifecycle remains, but these tested base-file changes are intentional donor drift, not a clean Mirror." + }, { "concept": "notification", "from": "Pending", diff --git a/docs/component-mirror-baseline.json b/docs/component-mirror-baseline.json index 9bb7107..e34ea9a 100644 --- a/docs/component-mirror-baseline.json +++ b/docs/component-mirror-baseline.json @@ -62,17 +62,17 @@ "local_paths": [ "button" ], - "local_hash": "070d99ef6e1f79a8", + "local_hash": "3bf7c056993cf169", "local_files": [ { "path": "button/button.rs", - "hash": "9c982d6195216000", - "bytes": 42342 + "hash": "bfd6bdd177a64797", + "bytes": 52705 }, { "path": "button/button/tests.rs", - "hash": "a82b0bdd58043fe7", - "bytes": 9930 + "hash": "363f18c2016d1532", + "bytes": 36536 }, { "path": "button/button_group.rs", @@ -141,18 +141,18 @@ }, { "concept": "calendar", - "class": "Mirror", + "class": "TrackedFork", "public_path": "moon_ui::MoonCalendar", "upstream_ref": "Longbridge::time::calendar@cda0fc7fd4e4809dd2a8bae0337ac43b49e9f675", "local_paths": [ "time/calendar.rs" ], - "local_hash": "cbbde77dcd5f0b76", + "local_hash": "3405d3aedda4aa94", "local_files": [ { "path": "time/calendar.rs", - "hash": "c1eb7c19d501c575", - "bytes": 33765 + "hash": "963bb9dcc995a018", + "bytes": 32871 } ], "donor_hash": "cbbde77dcd5f0b76", @@ -163,7 +163,9 @@ "bytes": 33765 } ], - "donor_changed_files": [] + "donor_changed_files": [ + "time/calendar.rs" + ] }, { "concept": "checkbox", @@ -173,12 +175,12 @@ "local_paths": [ "checkbox.rs" ], - "local_hash": "dbf68eda8a74c5d1", + "local_hash": "cc7339e30bb989a7", "local_files": [ { "path": "checkbox.rs", - "hash": "3044316dc40e01cd", - "bytes": 54366 + "hash": "218ded4eca4cf0ee", + "bytes": 56594 } ], "donor_hash": "c665cd46d9f5aa2a", @@ -227,12 +229,12 @@ "local_paths": [ "combobox.rs" ], - "local_hash": "4c7d616e46a4fae8", + "local_hash": "db135c6582f7e22b", "local_files": [ { "path": "combobox.rs", - "hash": "b87ebd3a62f14230", - "bytes": 52706 + "hash": "d0b726c2e4e1f3e7", + "bytes": 53249 } ], "donor_hash": "2324438aee108e17", @@ -307,7 +309,7 @@ "local_paths": [ "dialog" ], - "local_hash": "6edf5ce3633f7631", + "local_hash": "d2970489b66ca0ad", "local_files": [ { "path": "dialog/alert_dialog.rs", @@ -326,8 +328,8 @@ }, { "path": "dialog/dialog.rs", - "hash": "a5af807a6065db87", - "bytes": 26761 + "hash": "0b6f641649001d9e", + "bytes": 28321 }, { "path": "dialog/footer.rs", @@ -437,7 +439,7 @@ "local_paths": [ "input" ], - "local_hash": "4c1232652526f5a8", + "local_hash": "8495c855fab66c51", "local_files": [ { "path": "input/blink_cursor.rs", @@ -496,13 +498,13 @@ }, { "path": "input/element.rs", - "hash": "a3ec163629b2e82f", - "bytes": 97481 + "hash": "39d064778889f3cd", + "bytes": 97647 }, { "path": "input/element/regression_tests.rs", - "hash": "bec5af9ae7e1324d", - "bytes": 9830 + "hash": "0fde35ab25ffadda", + "bytes": 18020 }, { "path": "input/indent.rs", @@ -511,8 +513,8 @@ }, { "path": "input/input.rs", - "hash": "3b614fdff94c34a7", - "bytes": 22572 + "hash": "4ef3796b29c00462", + "bytes": 29010 }, { "path": "input/lsp/code_actions.rs", @@ -621,8 +623,8 @@ }, { "path": "input/state.rs", - "hash": "dd71e6ca18f229f3", - "bytes": 134622 + "hash": "5a72cf8bbd588e66", + "bytes": 134925 } ], "donor_hash": "018ee239acbf3f7c", @@ -839,7 +841,7 @@ "local_paths": [ "list" ], - "local_hash": "ad8914702317fa18", + "local_hash": "d7d0003ee9bb2d68", "local_files": [ { "path": "list/cache.rs", @@ -853,13 +855,13 @@ }, { "path": "list/list.rs", - "hash": "4867689acbd4bd3a", - "bytes": 26406 + "hash": "feab4ba70bc7d5fb", + "bytes": 26549 }, { "path": "list/list/tests.rs", - "hash": "af6d792dd1a5a37c", - "bytes": 8154 + "hash": "eeba5740c3ee861d", + "bytes": 9541 }, { "path": "list/list_item.rs", @@ -1050,12 +1052,12 @@ "local_paths": [ "popover.rs" ], - "local_hash": "529dd018e2c7f81b", + "local_hash": "93b6226d8ab8e37e", "local_files": [ { "path": "popover.rs", - "hash": "b3316c5b02f1d758", - "bytes": 23073 + "hash": "eecb6be006ac2b3e", + "bytes": 21041 } ], "donor_hash": "da9409c2c18e2a14", @@ -1078,7 +1080,7 @@ "local_paths": [ "progress" ], - "local_hash": "79bc8c926375335a", + "local_hash": "0337dc7e7a61b313", "local_files": [ { "path": "progress/mod.rs", @@ -1087,8 +1089,8 @@ }, { "path": "progress/progress.rs", - "hash": "e576030bca75de6c", - "bytes": 5648 + "hash": "b3e131632fefe07b", + "bytes": 6296 }, { "path": "progress/progress_circle.rs", @@ -1115,6 +1117,7 @@ } ], "donor_changed_files": [ + "progress/progress.rs", "progress/progress_circle.rs" ] }, @@ -1126,7 +1129,7 @@ "local_paths": [ "progress" ], - "local_hash": "79bc8c926375335a", + "local_hash": "0337dc7e7a61b313", "local_files": [ { "path": "progress/mod.rs", @@ -1135,8 +1138,8 @@ }, { "path": "progress/progress.rs", - "hash": "e576030bca75de6c", - "bytes": 5648 + "hash": "b3e131632fefe07b", + "bytes": 6296 }, { "path": "progress/progress_circle.rs", @@ -1163,6 +1166,7 @@ } ], "donor_changed_files": [ + "progress/progress.rs", "progress/progress_circle.rs" ] }, @@ -1174,12 +1178,12 @@ "local_paths": [ "resizable" ], - "local_hash": "43392145fd09db69", + "local_hash": "02e61424934fe294", "local_files": [ { "path": "resizable/mod.rs", - "hash": "6c0aaba0b72b5b8e", - "bytes": 12678 + "hash": "85076a14dbf93d98", + "bytes": 12891 }, { "path": "resizable/panel.rs", @@ -1224,12 +1228,12 @@ "local_paths": [ "select.rs" ], - "local_hash": "8da0aea74824b10f", + "local_hash": "d299e90ae40a663a", "local_files": [ { "path": "select.rs", - "hash": "f068cbebb4147784", - "bytes": 32242 + "hash": "85f756d8ea8aa77c", + "bytes": 32853 } ], "donor_hash": "245acdb36368c661", @@ -1407,7 +1411,7 @@ "local_paths": [ "sidebar" ], - "local_hash": "0eeeac4e89296fa8", + "local_hash": "7b14f73bf58f5f44", "local_files": [ { "path": "sidebar/footer.rs", @@ -1426,18 +1430,18 @@ }, { "path": "sidebar/menu.rs", - "hash": "c7f8c396358965a5", - "bytes": 18454 + "hash": "eaaccc159c37f4ce", + "bytes": 20892 }, { "path": "sidebar/menu/tests.rs", - "hash": "357c42078fe07cec", - "bytes": 13872 + "hash": "431a202b07ee80a5", + "bytes": 15838 }, { "path": "sidebar/mod.rs", - "hash": "f0337ffc40d90ad4", - "bytes": 25166 + "hash": "07be9fff20623e46", + "bytes": 26340 } ], "donor_hash": "1b093a2a6db1a9b6", @@ -1473,7 +1477,8 @@ "sidebar/group.rs", "sidebar/header.rs", "sidebar/menu.rs", - "sidebar/menu/tests.rs" + "sidebar/menu/tests.rs", + "sidebar/mod.rs" ] }, { @@ -1484,12 +1489,12 @@ "local_paths": [ "slider.rs" ], - "local_hash": "a5ee3e262bc1a62f", + "local_hash": "a5bce9927ab28a52", "local_files": [ { "path": "slider.rs", - "hash": "49af155ee26c56da", - "bytes": 31788 + "hash": "f643ac8cbaba6ef2", + "bytes": 37106 } ], "donor_hash": "845f6d32d82ffd16", @@ -1512,7 +1517,7 @@ "local_paths": [ "input" ], - "local_hash": "4c1232652526f5a8", + "local_hash": "8495c855fab66c51", "local_files": [ { "path": "input/blink_cursor.rs", @@ -1571,13 +1576,13 @@ }, { "path": "input/element.rs", - "hash": "a3ec163629b2e82f", - "bytes": 97481 + "hash": "39d064778889f3cd", + "bytes": 97647 }, { "path": "input/element/regression_tests.rs", - "hash": "bec5af9ae7e1324d", - "bytes": 9830 + "hash": "0fde35ab25ffadda", + "bytes": 18020 }, { "path": "input/indent.rs", @@ -1586,8 +1591,8 @@ }, { "path": "input/input.rs", - "hash": "3b614fdff94c34a7", - "bytes": 22572 + "hash": "4ef3796b29c00462", + "bytes": 29010 }, { "path": "input/lsp/code_actions.rs", @@ -1696,8 +1701,8 @@ }, { "path": "input/state.rs", - "hash": "dd71e6ca18f229f3", - "bytes": 134622 + "hash": "5a72cf8bbd588e66", + "bytes": 134925 } ], "donor_hash": "018ee239acbf3f7c", diff --git a/xtask/src/component_audit/contracts.rs b/xtask/src/component_audit/contracts.rs index 592e1bc..09d6692 100644 --- a/xtask/src/component_audit/contracts.rs +++ b/xtask/src/component_audit/contracts.rs @@ -564,7 +564,7 @@ pub(super) fn contract_checks(root: &Path) -> Result> { ContractVerifier::VisualGolden, gallery.contains("--snapshot-dir") && gallery.contains("--theme") - && gallery.contains("theme_mode_name") + && gallery.contains("snapshot_name") && snapshot_tool.contains("$Themes = @(\"Dark\", \"Light\")") && gallery.contains("snapshot_window_image(") && gallery.contains("clear_snapshot_dir")