From 0e0f9368715eaa2856d575de86d904f6b0807cbb Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:35:41 +0100 Subject: [PATCH 1/3] feat: add animated gradient backgrounds --- apps/desktop-gpui/src/editor_sidebar.rs | 133 +- .../src/editor_sidebar/animated_gradient.rs | 1435 +++++++++++++++++ apps/desktop-gpui/src/editor_window.rs | 24 +- apps/desktop-gpui/src/recording.rs | 308 ++++ apps/desktop-gpui/src/screenshot_editor.rs | 16 +- apps/desktop/src-tauri/src/lib.rs | 15 + apps/desktop/src-tauri/src/recording.rs | 93 ++ .../routes/editor/AnimatedGradientEditor.tsx | 762 +++++++++ .../src/routes/editor/ConfigSidebar.tsx | 206 ++- .../popovers/BackgroundSettingsPopover.tsx | 9 + apps/desktop/src/store.ts | 18 + .../src/utils/serialized-store.test.ts | 147 ++ apps/desktop/src/utils/serialized-store.ts | 35 + apps/desktop/src/utils/tauri.ts | 15 +- crates/project/src/animated_gradient.rs | 572 +++++++ crates/project/src/configuration.rs | 4 + crates/project/src/lib.rs | 2 + .../rendering-skia/src/layers/background.rs | 23 + .../animated-gradient-backgrounds.json | 20 + .../examples/background-benchmark.rs | 291 ++++ .../rendering/src/layers/animated_gradient.rs | 620 +++++++ crates/rendering/src/layers/background.rs | 33 +- crates/rendering/src/layers/mod.rs | 2 + crates/rendering/src/lib.rs | 1 + .../src/shaders/animated-gradient.wgsl | 190 +++ 25 files changed, 4911 insertions(+), 63 deletions(-) create mode 100644 apps/desktop-gpui/src/editor_sidebar/animated_gradient.rs create mode 100644 apps/desktop/src/routes/editor/AnimatedGradientEditor.tsx create mode 100644 apps/desktop/src/utils/serialized-store.test.ts create mode 100644 apps/desktop/src/utils/serialized-store.ts create mode 100644 crates/project/src/animated_gradient.rs create mode 100644 crates/rendering/examples/animated-gradient-backgrounds.json create mode 100644 crates/rendering/examples/background-benchmark.rs create mode 100644 crates/rendering/src/layers/animated_gradient.rs create mode 100644 crates/rendering/src/shaders/animated-gradient.wgsl diff --git a/apps/desktop-gpui/src/editor_sidebar.rs b/apps/desktop-gpui/src/editor_sidebar.rs index 366ec90dcc6..057f37a4951 100644 --- a/apps/desktop-gpui/src/editor_sidebar.rs +++ b/apps/desktop-gpui/src/editor_sidebar.rs @@ -21,8 +21,7 @@ //! Every control here writes a real `ProjectConfiguration` key path through the //! **same** path a timeline edit takes -- [`EditorWindow::project_changed`]: //! history, then `project_config` + `preview_tx` so the picture follows the -//! change, then the 250ms debounced `ProjectConfiguration::write`. Nothing in -//! this module writes to disk itself. +//! change, then the 250ms debounced `ProjectConfiguration::write`. //! //! Three things needed native code, and all three are the shipping behaviour //! rather than an approximation of it: @@ -43,8 +42,8 @@ use std::{ }; use cap_project::{ - BackgroundSource, BorderConfiguration, Color, CornerStyle, DisplayNotch, NotchConfiguration, - ProjectConfiguration, ShadowConfiguration, + AnimatedGradientParameter, BackgroundSource, BorderConfiguration, Color, CornerStyle, + DisplayNotch, NotchConfiguration, ProjectConfiguration, ShadowConfiguration, }; use gpui::{ AnyElement, Bounds, Context, FontWeight, Hsla, InteractiveElement, IntoElement, MouseDownEvent, @@ -63,6 +62,8 @@ use crate::{ ui::{self, CollapsibleState, SliderTrack}, }; +mod animated_gradient; + // --------------------------------------------------------------------------- // The catalogue: every constant the background section reads // --------------------------------------------------------------------------- @@ -289,6 +290,7 @@ pub enum SourceTab { Image, Color, Gradient, + AnimatedGradient, None, } @@ -296,7 +298,7 @@ impl SourceTab { /// `BACKGROUND_SOURCES_ROW_ONE` / `_TWO` (`:236-246`). pub const ROWS: [[SourceTab; 3]; 2] = [ [Self::Desktop, Self::Wallpaper, Self::Image], - [Self::Color, Self::Gradient, Self::None], + [Self::Color, Self::Gradient, Self::AnimatedGradient], ]; pub fn label(self) -> &'static str { @@ -306,6 +308,7 @@ impl SourceTab { Self::Image => "Image", Self::Color => "Color", Self::Gradient => "Gradient", + Self::AnimatedGradient => "Animated", Self::None => "None", } } @@ -336,6 +339,7 @@ pub fn source_tab_for(source: &BackgroundSource) -> SourceTab { BackgroundSource::Image { .. } => SourceTab::Image, BackgroundSource::Color { .. } => SourceTab::Color, BackgroundSource::Gradient { .. } => SourceTab::Gradient, + BackgroundSource::AnimatedGradient { .. } => SourceTab::AnimatedGradient, } } @@ -344,6 +348,18 @@ pub fn is_none_background(config: &ProjectConfiguration) -> bool { config.background.padding == 0. && config.background.rounding == 0. } +fn hide_background(config: &mut ProjectConfiguration) -> bool { + config.background.padding = 0.; + config.background.rounding = 0.; + if matches!( + config.background.source, + BackgroundSource::AnimatedGradient { .. } + ) { + config.background.source = BackgroundSource::default(); + } + true +} + /// The tab the panel opens on: "None" wins over the underlying source, and it /// is sticky -- nudging padding out of zero must not swap the panel back and /// move the very slider being dragged (`:1804-1811`). @@ -683,6 +699,8 @@ impl BgSlider { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum SliderKey { Bg(BgSlider), + AnimatedGradient(AnimatedGradientParameter), + AnimatedGradientStop(usize), Grade(GradeTarget, GradeSlider), Camera(CameraSlider), Audio(AudioSlider), @@ -705,6 +723,7 @@ pub enum ColorTarget { BackgroundColor, GradientFrom, GradientTo, + AnimatedGradientStop(usize), BorderColor, CaptionColor, CaptionBackground, @@ -720,7 +739,11 @@ impl ColorTarget { pub fn is_hex_string(self) -> bool { !matches!( self, - Self::BackgroundColor | Self::GradientFrom | Self::GradientTo | Self::BorderColor + Self::BackgroundColor + | Self::GradientFrom + | Self::GradientTo + | Self::AnimatedGradientStop(_) + | Self::BorderColor ) } } @@ -735,6 +758,7 @@ pub enum ColorPickerDrag { /// The sidebar's own state -- everything `ConfigSidebar`'s signals hold that is /// not in the project config. pub struct SidebarState { + animated_gradient: animated_gradient::AnimatedGradientState, /// `state.selectedTab` (`:563-573`). pub tab: SidebarTab, /// `backgroundSourceTab` (`:1799-1802`). @@ -829,6 +853,7 @@ pub struct SidebarState { impl SidebarState { pub fn new(config: &ProjectConfiguration) -> Self { Self { + animated_gradient: animated_gradient::AnimatedGradientState::new(), tab: SidebarTab::Background, source_tab: initial_source_tab(config), wallpaper_theme: 0, @@ -1006,10 +1031,12 @@ impl EditorWindow { if reason != "color" { self.end_color_history(); } + let previous_animated_gradient = self.animated_gradient_config().cloned(); if !change(&mut self.project) { return; } self.project_changed(window, cx); + self.remember_animated_gradient_selection(previous_animated_gradient, window, cx); self.note_sidebar_edit(reason); } @@ -1119,6 +1146,11 @@ impl EditorWindow { pub(crate) fn slider_limits(&self, slider: SliderKey) -> (f32, f32, f32) { match slider { SliderKey::Bg(slider) => self.bg_slider_limits(slider), + SliderKey::AnimatedGradient(parameter) => { + let control = parameter.control(); + (control.min, control.max, control.step) + } + SliderKey::AnimatedGradientStop(_) => (0., 100., 1.), SliderKey::Grade(_, slider) => slider.limits(), SliderKey::Camera(slider) => slider.limits(), SliderKey::Audio(slider) => slider.limits(), @@ -1132,6 +1164,13 @@ impl EditorWindow { pub(crate) fn slider_value(&self, slider: SliderKey) -> f32 { match slider { SliderKey::Bg(slider) => self.bg_slider_value(slider), + SliderKey::AnimatedGradient(parameter) => self + .animated_gradient_config() + .map_or(0., |config| parameter.get(config)), + SliderKey::AnimatedGradientStop(index) => self + .animated_gradient_config() + .and_then(|config| config.color_stops.get(index)) + .map_or(0., |stop| stop.position), // Every grade slider is `Math.round(value * 100)` in the UI and // `v / 100` back into the config (`ColorCorrectionSection.tsx:181`). SliderKey::Grade(target, slider) => (slider.read(self.grade(target)) * 100.).round(), @@ -1154,6 +1193,12 @@ impl EditorWindow { ) { match slider { SliderKey::Bg(slider) => self.apply_bg_slider(slider, value, window, cx), + SliderKey::AnimatedGradient(parameter) => { + self.apply_animated_gradient_parameter(parameter, value, window, cx) + } + SliderKey::AnimatedGradientStop(index) => { + self.apply_animated_gradient_stop_position(index, value, window, cx) + } SliderKey::Grade(target, slider) => { self.set_grade_value(target, slider, value / 100., window, cx) } @@ -1572,6 +1617,10 @@ impl EditorWindow { BackgroundSource::Gradient { to, .. } => Some(*to), _ => None, }, + ColorTarget::AnimatedGradientStop(index) => self + .animated_gradient_config() + .and_then(|config| config.color_stops.get(index)) + .map(|stop| stop.color), ColorTarget::BorderColor => Some( self.project .background @@ -1646,6 +1695,19 @@ impl EditorWindow { border.color = color; background.border = Some(border); } + ColorTarget::AnimatedGradientStop(index) => { + let BackgroundSource::AnimatedGradient { config } = &mut background.source + else { + return false; + }; + let Some(stop) = config.color_stops.get_mut(index) else { + return false; + }; + if stop.color == color { + return false; + } + stop.color = color; + } _ => unreachable!("hex-string targets go through set_hex_color"), } true @@ -2073,20 +2135,13 @@ impl EditorWindow { /// The source-tab row's `onChange` (`:2189-2263`), verbatim. fn select_source_tab(&mut self, tab: SourceTab, window: &mut Window, cx: &mut Context) { + self.refresh_animated_gradient_library(); + self.close_color_picker(cx); let from_none = self.sidebar.source_tab == SourceTab::None; self.sidebar.source_tab = tab; if tab == SourceTab::None { - self.edit_background( - "source-none", - |project| { - project.background.padding = 0.; - project.background.rounding = 0.; - true - }, - window, - cx, - ); + self.edit_background("source-none", hide_background, window, cx); self.ensure_preview(window, cx); return; } @@ -2100,6 +2155,17 @@ impl EditorWindow { return; } + if tab == SourceTab::AnimatedGradient { + let config = self + .animated_gradient_config() + .cloned() + .or_else(|| self.sidebar.animated_gradient.library.last_used.clone()) + .unwrap_or_default(); + self.ensure_background_presentation(from_none); + self.select_animated_gradient(config, window, cx); + return; + } + // Batched with the source write below, exactly as the source's // `batch()` around `ensureBackgroundPresentation` + `setProject` is. self.ensure_background_presentation(from_none); @@ -2515,6 +2581,7 @@ impl EditorWindow { .flex_col() .gap(px(8.)) .children(rows) + .child(self.render_source_trigger(SourceTab::None, cx)) // `my-5 w-full border-t border-dashed border-gray-5` .child( div() @@ -2527,6 +2594,9 @@ impl EditorWindow { SourceTab::Image => self.render_image_pane(cx).into_any_element(), SourceTab::Color => self.render_color_pane(cx).into_any_element(), SourceTab::Gradient => self.render_gradient_pane(cx).into_any_element(), + SourceTab::AnimatedGradient => { + self.render_animated_gradient_pane(cx).into_any_element() + } SourceTab::None => div().into_any_element(), }), ) @@ -2557,10 +2627,14 @@ impl EditorWindow { .text_color(Hsla::from(theme.gray_12)) }) .when(!selected, |this| { - this.border_color(gpui::transparent_black()) - .text_color(Hsla::from(theme.gray_11)) - .cursor_pointer() - .hover(|this| this.border_color(Hsla::from(theme.gray_7))) + this.border_color(if item == SourceTab::None { + Hsla::from(theme.gray_5) + } else { + gpui::transparent_black() + }) + .text_color(Hsla::from(theme.gray_11)) + .cursor_pointer() + .hover(|this| this.border_color(Hsla::from(theme.gray_7))) }) .child(self.render_source_icon(item)) .child(item.label()) @@ -2599,6 +2673,7 @@ impl EditorWindow { )) .into_any_element() } + SourceTab::AnimatedGradient => self.render_animated_gradient_icon(), SourceTab::Color => { let value = match source { BackgroundSource::Color { value, .. } => *value, @@ -3786,6 +3861,7 @@ pub(crate) fn format_slider_value(value: f32, unit: &str) -> String { match unit { "" => format!("{value:.1}"), "deg" => format!("{}\u{b0}", value.round() as i32), + "int" => format!("{}", value.round() as i32), "x100%" => format!("{}%", (value * 100.).round() as i32), "pct" => format!("{:.1}%", value * 100.), unit => format!("{value:.1}{unit}"), @@ -3946,6 +4022,23 @@ mod tests { assert_eq!(wallpapers_for_theme("orange").len(), 9); } + #[test] + fn animated_gradient_has_a_distinct_source_tab() { + let source = BackgroundSource::AnimatedGradient { + config: cap_project::AnimatedGradientConfig::default(), + }; + assert_eq!(source_tab_for(&source), SourceTab::AnimatedGradient); + assert_eq!(SourceTab::AnimatedGradient.label(), "Animated"); + assert!(SourceTab::ROWS[1].contains(&SourceTab::AnimatedGradient)); + assert!( + !SourceTab::ROWS + .iter() + .flatten() + .any(|tab| *tab == SourceTab::None) + ); + assert!(!ColorTarget::AnimatedGradientStop(0).is_hex_string()); + } + #[test] fn built_in_wallpapers_remain_available_from_the_development_checkout() { assert!(wallpaper_path("macOS/sequoia-dark").is_some()); diff --git a/apps/desktop-gpui/src/editor_sidebar/animated_gradient.rs b/apps/desktop-gpui/src/editor_sidebar/animated_gradient.rs new file mode 100644 index 00000000000..8fa60dab8ef --- /dev/null +++ b/apps/desktop-gpui/src/editor_sidebar/animated_gradient.rs @@ -0,0 +1,1435 @@ +use cap_project::{ + AnimatedGradientCatalog, AnimatedGradientConfig, AnimatedGradientControl, + AnimatedGradientLibrary, AnimatedGradientPreset, AnimatedGradientStop, + animated_gradient_catalog, +}; +use gpui::{AppContext as _, MouseButton}; + +use super::*; +use crate::theme::Theme; + +const STORE_SECTION: &str = "animated_gradients"; +const MAX_STOPS: usize = 5; +const MIN_STOPS: usize = 2; +const MAX_SAVED: usize = 100; +const STOP_INPUT_IDS: [&str; MAX_STOPS] = [ + "animated-gradient-stop-0", + "animated-gradient-stop-1", + "animated-gradient-stop-2", + "animated-gradient-stop-3", + "animated-gradient-stop-4", +]; +const SWATCH_COLUMNS: usize = 7; +const SWATCH_GAP: f32 = 8.; +const BAR_HEIGHT: f32 = 40.; +const HANDLE_SIZE: f32 = 20.; +const SELECTED_HANDLE_SIZE: f32 = 24.; + +#[derive(Clone)] +struct PendingSelection { + last_used: Option, + selected: bool, +} + +impl PendingSelection { + fn merge(self, previous: Option<&Self>) -> Self { + Self { + selected: self.selected, + last_used: self + .last_used + .or_else(|| previous.and_then(|pending| pending.last_used.clone())), + } + } +} + +fn pending_selection( + source: &BackgroundSource, + previous: Option, + source_tab: SourceTab, +) -> PendingSelection { + let current = match source { + BackgroundSource::AnimatedGradient { config } => Some(config.normalized()), + _ => None, + }; + PendingSelection { + selected: current.is_some() && source_tab != SourceTab::None, + last_used: current.or_else(|| previous.map(|config| config.normalized())), + } +} + +pub(super) struct AnimatedGradientState { + pub(super) library: AnimatedGradientLibrary, + catalog: AnimatedGradientCatalog, + pending: Option, + save_task: Option>, + name_input: Option>, + error: Option, + persistence_failed: bool, + selected_stop: usize, + save_open: bool, + fine_tune_open: CollapsibleState, + fine_tune_group: usize, +} + +impl AnimatedGradientState { + pub(super) fn new() -> Self { + Self { + library: read_library(), + catalog: animated_gradient_catalog(), + pending: None, + save_task: None, + name_input: None, + error: None, + persistence_failed: false, + selected_stop: 0, + save_open: false, + fine_tune_open: CollapsibleState::new(false), + fine_tune_group: 0, + } + } + + fn flush_selection(&mut self) { + let Some(pending) = self.pending.as_ref() else { + return; + }; + let config_saved = pending + .last_used + .as_ref() + .is_none_or(|config| write_setting("lastUsed", config)); + let selection_saved = write_setting("selected", &pending.selected); + if config_saved && selection_saved { + self.pending = None; + self.persistence_failed = false; + } else { + self.persistence_failed = true; + tracing::warn!("failed to remember animated gradient selection"); + } + } +} + +fn control_groups(catalog: &AnimatedGradientCatalog) -> Vec<(&str, Vec<&AnimatedGradientControl>)> { + let mut groups: Vec<(&str, Vec<&AnimatedGradientControl>)> = Vec::new(); + for control in &catalog.controls { + if control.key == AnimatedGradientParameter::MotionSpeed { + continue; + } + match groups.iter_mut().find(|(name, _)| *name == control.group) { + Some((_, controls)) => controls.push(control), + None => groups.push((&control.group, vec![control])), + } + } + groups +} + +impl Drop for AnimatedGradientState { + fn drop(&mut self) { + self.flush_selection(); + } +} + +fn read_library() -> AnimatedGradientLibrary { + serde_json::from_value(serde_json::Value::Object(crate::store::store_section( + STORE_SECTION, + ))) + .unwrap_or_default() +} + +fn write_setting(key: &str, value: &impl serde::Serialize) -> bool { + match serde_json::to_value(value) { + Ok(value) => crate::store::set_store_setting(STORE_SECTION, key, value), + Err(error) => { + tracing::warn!("serializing animated gradient setting failed: {error}"); + false + } + } +} + +fn stop_limits(config: &AnimatedGradientConfig, index: usize) -> (f32, f32) { + let min = index + .checked_sub(1) + .and_then(|previous| config.color_stops.get(previous)) + .map_or(0., |stop| stop.position) + .clamp(0., 100.); + let max = config + .color_stops + .get(index + 1) + .map_or(100., |stop| stop.position) + .clamp(min, 100.); + (min, max) +} + +fn color_at(stops: &[AnimatedGradientStop], position: f32) -> Color { + let (Some(first), Some(last)) = (stops.first(), stops.last()) else { + return [128, 128, 128]; + }; + if position <= first.position { + return first.color; + } + if position >= last.position { + return last.color; + } + for pair in stops.windows(2) { + if position < pair[0].position || position > pair[1].position { + continue; + } + let span = pair[1].position - pair[0].position; + let t = if span <= 0. { + 0. + } else { + (position - pair[0].position) / span + }; + return std::array::from_fn(|channel| { + let left = f32::from(pair[0].color[channel].min(255)); + let right = f32::from(pair[1].color[channel].min(255)); + (left + (right - left) * t).round() as u16 + }); + } + last.color +} + +fn insert_stop(config: &mut AnimatedGradientConfig, position: f32) -> Option { + if config.color_stops.len() >= MAX_STOPS { + return None; + } + let position = position.clamp(0., 100.).round(); + let stop = AnimatedGradientStop { + color: color_at(&config.color_stops, position), + position, + }; + let index = config + .color_stops + .iter() + .position(|existing| existing.position > position) + .unwrap_or(config.color_stops.len()); + config.color_stops.insert(index, stop); + Some(index) +} + +fn largest_gap_position(config: &AnimatedGradientConfig) -> f32 { + config + .color_stops + .windows(2) + .max_by(|a, b| (a[1].position - a[0].position).total_cmp(&(b[1].position - b[0].position))) + .map_or(50., |pair| { + ((pair[0].position + pair[1].position) / 2.).round() + }) +} + +fn add_stop(config: &mut AnimatedGradientConfig) -> Option { + insert_stop(config, largest_gap_position(config)) +} + +fn remove_stop(config: &mut AnimatedGradientConfig, index: usize) -> bool { + if config.color_stops.len() <= MIN_STOPS || index >= config.color_stops.len() { + return false; + } + config.color_stops.remove(index); + true +} + +fn slider_unit(key: AnimatedGradientParameter) -> &'static str { + match key { + AnimatedGradientParameter::Direction => "deg", + AnimatedGradientParameter::FlowScale | AnimatedGradientParameter::GrainSize => "", + _ => "int", + } +} + +fn format_control_value(control: &AnimatedGradientControl, value: f32) -> String { + match control.key { + AnimatedGradientParameter::Direction => format!("{}\u{b0}", value.round() as i32), + AnimatedGradientParameter::MotionSpeed if value == 0. => "Still".into(), + _ if control.step < 1. => format!("{value:.1}"), + _ => format!("{}", value.round() as i32), + } +} + +enum Band { + Solid(Color), + Blend(Color, Color), +} + +fn palette(config: &AnimatedGradientConfig, radius: Pixels) -> gpui::Div { + let stops = &config.color_stops; + let mut bands = Vec::new(); + if let Some(first) = stops.first() + && first.position > 0. + { + bands.push((first.position / 100., Band::Solid(first.color))); + } + for pair in stops.windows(2) { + let width = (pair[1].position - pair[0].position).max(0.) / 100.; + bands.push((width, Band::Blend(pair[0].color, pair[1].color))); + } + if let Some(last) = stops.last() + && last.position < 100. + { + bands.push(((100. - last.position) / 100., Band::Solid(last.color))); + } + let count = bands.len(); + div() + .flex() + .w_full() + .h_full() + .children(bands.into_iter().enumerate().map(|(index, (width, band))| { + let band_div = div().h_full().w(gpui::relative(width)); + let band_div = match band { + Band::Solid(color) => band_div.bg(color_to_hsla(color)), + Band::Blend(from, to) => band_div.bg(linear_gradient( + 90., + linear_color_stop(color_to_hsla(from), 0.), + linear_color_stop(color_to_hsla(to), 1.), + )), + }; + band_div + .when(index == 0, |this| this.rounded_l(radius)) + .when(index + 1 == count, |this| this.rounded_r(radius)) + })) +} + +fn swatch_cell() -> f32 { + ((CONTENT_WIDTH - SWATCH_GAP * (SWATCH_COLUMNS as f32 - 1.)) / SWATCH_COLUMNS as f32).floor() +} + +fn swatch_rows(items: Vec) -> gpui::Div { + let mut rows = Vec::new(); + let mut items = items.into_iter().peekable(); + while items.peek().is_some() { + let row = items.by_ref().take(SWATCH_COLUMNS).collect::>(); + rows.push(div().flex().flex_row().gap(px(SWATCH_GAP)).children(row)); + } + div().flex().flex_col().gap(px(SWATCH_GAP)).children(rows) +} + +fn header_button( + theme: &Theme, + id: &'static str, + icon: &'static str, + label: &'static str, + tooltip: &'static str, + disabled: bool, + pressed: bool, + on_click: impl Fn(&gpui::ClickEvent, &mut Window, &mut gpui::App) + 'static, +) -> gpui::Stateful { + let theme = *theme; + let foreground = Hsla::from(if pressed { + theme.gray_12 + } else { + theme.gray_11 + }); + div() + .id(id) + .flex() + .items_center() + .gap(px(4.)) + .h(px(28.)) + .px(px(8.)) + .rounded(px(6.)) + .text_size(px(12.)) + .text_color(foreground) + .when(pressed, |this| this.bg(Hsla::from(theme.gray_3))) + .when(disabled, |this| this.opacity(0.4)) + .when(!disabled, |this| { + this.cursor_pointer() + .hover(|this| { + this.bg(Hsla::from(theme.gray_3)) + .text_color(Hsla::from(theme.gray_12)) + }) + .on_click(on_click) + }) + .tooltip(move |_window, cx| ui::Tooltip::new(&theme, tooltip).view(cx)) + .child( + svg() + .path(icon) + .size(px(14.)) + .flex_shrink_0() + .text_color(foreground), + ) + .child(label) +} + +fn icon_button( + theme: &Theme, + id: impl Into, + icon: &'static str, + tooltip: &'static str, + disabled: bool, + on_click: impl Fn(&gpui::ClickEvent, &mut Window, &mut gpui::App) + 'static, +) -> gpui::Stateful { + let theme = *theme; + div() + .id(id.into()) + .flex() + .items_center() + .justify_center() + .size(px(26.)) + .rounded(px(6.)) + .text_color(Hsla::from(theme.gray_10)) + .when(disabled, |this| this.opacity(0.3)) + .when(!disabled, |this| { + this.cursor_pointer() + .hover(|this| { + this.bg(Hsla::from(theme.gray_3)) + .text_color(Hsla::from(theme.gray_12)) + }) + .on_click(on_click) + }) + .tooltip(move |_window, cx| ui::Tooltip::new(&theme, tooltip).view(cx)) + .child( + svg() + .path(icon) + .size(px(14.)) + .text_color(Hsla::from(theme.gray_10)), + ) +} + +impl EditorWindow { + pub(crate) fn animated_gradient_config(&self) -> Option<&AnimatedGradientConfig> { + match &self.project.background.source { + BackgroundSource::AnimatedGradient { config } => Some(config), + _ => None, + } + } + + pub(crate) fn remember_animated_gradient_selection( + &mut self, + previous: Option, + window: &mut Window, + cx: &mut Context, + ) { + let selection = pending_selection( + &self.project.background.source, + previous, + self.sidebar.source_tab, + ); + let state = &mut self.sidebar.animated_gradient; + let selection = selection.merge(state.pending.as_ref()); + state.library.selected = selection.selected; + if let Some(config) = &selection.last_used { + state.library.last_used = Some(config.clone()); + } + state.pending = Some(selection); + state.save_task = Some(cx.spawn_in(window, async move |this, cx| { + cx.background_executor() + .timer(std::time::Duration::from_millis(350)) + .await; + this.update(cx, |this, cx| { + this.sidebar.animated_gradient.flush_selection(); + cx.notify(); + }) + .ok(); + })); + } + + pub(crate) fn flush_animated_gradient_selection(&mut self) { + self.sidebar.animated_gradient.flush_selection(); + } + + pub(crate) fn refresh_animated_gradient_library(&mut self) { + self.flush_animated_gradient_selection(); + if !self.sidebar.animated_gradient.persistence_failed { + self.sidebar.animated_gradient.library = read_library(); + } + } + + pub(crate) fn prepare_animated_gradient_fields( + &mut self, + window: &mut Window, + cx: &mut Context, + ) { + if self.sidebar.source_tab != SourceTab::AnimatedGradient { + return; + } + let count = self + .animated_gradient_config() + .map_or(0, |config| config.color_stops.len()); + for index in 0..count.min(MAX_STOPS) { + self.ensure_hex_input(ColorTarget::AnimatedGradientStop(index), window, cx); + } + if self.sidebar.animated_gradient.name_input.is_some() { + return; + } + let input = cx.new(|cx| { + let mut input = ui::TextInputState::single_line(window, cx); + input.set_placeholder("Name this gradient"); + input + }); + self.push_text_subscription(cx.subscribe_in( + &input, + window, + |this: &mut Self, _, event: &ui::TextInputEvent, window, cx| match event { + ui::TextInputEvent::Confirmed => { + this.save_animated_gradient_preset(cx); + this.focus_root(window, cx); + } + ui::TextInputEvent::Cancelled => { + this.close_animated_gradient_save(cx); + this.focus_root(window, cx); + } + ui::TextInputEvent::Changed => cx.notify(), + ui::TextInputEvent::Blurred => {} + }, + )); + self.sidebar.animated_gradient.name_input = Some(input); + } + + pub(super) fn select_animated_gradient( + &mut self, + config: AnimatedGradientConfig, + window: &mut Window, + cx: &mut Context, + ) { + self.refresh_animated_gradient_library(); + self.close_color_picker(cx); + self.sidebar.source_tab = SourceTab::AnimatedGradient; + self.edit_background( + "animated-gradient", + move |project| { + project.background.source = BackgroundSource::AnimatedGradient { + config: config.normalized(), + }; + true + }, + window, + cx, + ); + } + + fn edit_animated_gradient( + &mut self, + change: impl FnOnce(&mut AnimatedGradientConfig) -> bool, + window: &mut Window, + cx: &mut Context, + ) { + self.edit_background( + "animated-gradient-setting", + |project| match &mut project.background.source { + BackgroundSource::AnimatedGradient { config } => change(config), + _ => false, + }, + window, + cx, + ); + } + + pub(super) fn apply_animated_gradient_parameter( + &mut self, + parameter: AnimatedGradientParameter, + value: f32, + window: &mut Window, + cx: &mut Context, + ) { + self.edit_animated_gradient( + |config| { + let previous = parameter.get(config); + parameter.set(config, value); + parameter.get(config) != previous + }, + window, + cx, + ); + } + + pub(super) fn apply_animated_gradient_stop_position( + &mut self, + index: usize, + value: f32, + window: &mut Window, + cx: &mut Context, + ) { + self.edit_animated_gradient( + |config| { + let (min, max) = stop_limits(config, index); + let Some(stop) = config.color_stops.get_mut(index) else { + return false; + }; + let position = value.round().clamp(min, max); + if stop.position == position { + return false; + } + stop.position = position; + true + }, + window, + cx, + ); + } + + fn selected_animated_gradient_stop(&self) -> usize { + let count = self + .animated_gradient_config() + .map_or(0, |config| config.color_stops.len()); + self.sidebar + .animated_gradient + .selected_stop + .min(count.saturating_sub(1)) + } + + fn begin_animated_gradient_stop_drag(&mut self, index: usize, cx: &mut Context) { + self.close_color_picker(cx); + self.sidebar.animated_gradient.selected_stop = index; + let history = &mut self.history; + self.sidebar + .slider_drag + .begin(SliderKey::AnimatedGradientStop(index), || history.pause()); + cx.notify(); + } + + fn add_animated_gradient_stop_at( + &mut self, + event: &MouseDownEvent, + window: &mut Window, + cx: &mut Context, + ) { + let Some(bounds) = self + .sidebar + .track_bounds(SliderKey::AnimatedGradientStop(0)) + else { + return; + }; + let Some(fraction) = ui::fraction_from_x(event.position.x, bounds) else { + return; + }; + let mut inserted = None; + self.close_color_picker(cx); + self.edit_animated_gradient( + |config| { + inserted = insert_stop(config, fraction * 100.); + inserted.is_some() + }, + window, + cx, + ); + if let Some(index) = inserted { + self.begin_animated_gradient_stop_drag(index, cx); + } + } + + fn add_animated_gradient_stop(&mut self, window: &mut Window, cx: &mut Context) { + let mut inserted = None; + self.close_color_picker(cx); + self.edit_animated_gradient( + |config| { + inserted = add_stop(config); + inserted.is_some() + }, + window, + cx, + ); + if let Some(index) = inserted { + self.sidebar.animated_gradient.selected_stop = index; + cx.notify(); + } + } + + fn remove_animated_gradient_stop( + &mut self, + index: usize, + window: &mut Window, + cx: &mut Context, + ) { + self.close_color_picker(cx); + self.edit_animated_gradient(|config| remove_stop(config, index), window, cx); + self.sidebar.animated_gradient.selected_stop = index.saturating_sub(1); + cx.notify(); + } + + fn reset_animated_gradient_fine_tune(&mut self, window: &mut Window, cx: &mut Context) { + let defaults = self + .sidebar + .animated_gradient + .catalog + .default_config + .clone(); + self.edit_animated_gradient( + |config| { + let mut changed = false; + for parameter in AnimatedGradientParameter::ALL { + if *parameter == AnimatedGradientParameter::MotionSpeed { + continue; + } + let previous = parameter.get(config); + parameter.set(config, parameter.get(&defaults)); + changed |= parameter.get(config) != previous; + } + changed + }, + window, + cx, + ); + } + + fn open_animated_gradient_save(&mut self, window: &mut Window, cx: &mut Context) { + self.sidebar.animated_gradient.save_open = true; + self.sidebar.animated_gradient.error = None; + if let Some(input) = self.sidebar.animated_gradient.name_input.clone() { + input.update(cx, |input, cx| input.focus_and_select_all(window, cx)); + } + cx.notify(); + } + + fn close_animated_gradient_save(&mut self, cx: &mut Context) { + self.sidebar.animated_gradient.save_open = false; + if let Some(input) = self.sidebar.animated_gradient.name_input.clone() { + input.update(cx, |input, cx| input.set_text("", cx)); + } + cx.notify(); + } + + fn save_animated_gradient_preset(&mut self, cx: &mut Context) { + let Some(config) = self.animated_gradient_config().cloned() else { + return; + }; + let Some(input) = self.sidebar.animated_gradient.name_input.clone() else { + return; + }; + let name = input.read(cx).text().to_string(); + let mut library = read_library(); + if library.save_preset(&name, &config).is_none() { + self.sidebar.animated_gradient.error = Some(if name.trim().is_empty() { + "Enter a name for this gradient.".to_string() + } else { + format!("You can save up to {MAX_SAVED} gradients. Delete one to add another.") + }); + } else if write_setting("presets", &library.presets) { + self.sidebar.animated_gradient.library.presets = library.presets; + self.sidebar.animated_gradient.error = None; + self.close_animated_gradient_save(cx); + } else { + self.sidebar.animated_gradient.error = + Some("Could not save this gradient. Try again.".into()); + } + cx.notify(); + } + + fn delete_animated_gradient_preset(&mut self, id: &str, cx: &mut Context) { + let mut library = read_library(); + library.presets.retain(|preset| preset.id != id); + if write_setting("presets", &library.presets) { + self.sidebar.animated_gradient.library.presets = library.presets; + self.sidebar.animated_gradient.error = None; + } else { + self.sidebar.animated_gradient.error = + Some("Could not delete this gradient. Try again.".into()); + } + cx.notify(); + } + + pub(super) fn render_animated_gradient_icon(&self) -> AnyElement { + let config = self.animated_gradient_config().cloned().unwrap_or_default(); + div() + .size(px(14.)) + .child(palette(&config, px(3.))) + .into_any_element() + } + + pub(super) fn render_animated_gradient_pane(&self, cx: &mut Context) -> AnyElement { + let Some(config) = self.animated_gradient_config() else { + return div().into_any_element(); + }; + let theme = self.theme; + let state = &self.sidebar.animated_gradient; + let error = state.error.clone().or_else(|| { + state + .persistence_failed + .then(|| "Could not remember your animated gradient settings.".to_string()) + }); + + div() + .flex() + .flex_col() + .gap(px(20.)) + .children(error.map(|error| { + div() + .text_size(px(12.)) + .text_color(Hsla::from(theme.red_11)) + .child(error) + })) + .child(self.render_animated_gradient_presets(cx)) + .child(dashed_divider(Hsla::from(theme.gray_5))) + .child(self.render_animated_gradient_colours(config, cx)) + .child(dashed_divider(Hsla::from(theme.gray_5))) + .child(self.render_animated_gradient_motion(config, cx)) + .child(dashed_divider(Hsla::from(theme.gray_5))) + .child(self.render_animated_gradient_fine_tune(config, cx)) + .into_any_element() + } + + fn render_animated_gradient_presets(&self, cx: &mut Context) -> AnyElement { + let theme = self.theme; + let state = &self.sidebar.animated_gradient; + let cell = swatch_cell(); + let save_disabled = state.library.presets.len() >= MAX_SAVED; + let save_open = state.save_open; + + let shuffle = div() + .id("animated-gradient-randomize") + .flex() + .items_center() + .justify_center() + .size(px(cell)) + .rounded(px(8.)) + .bg(Hsla::from(theme.gray_2)) + .border_1() + .border_color(Hsla::from(theme.gray_8)) + .text_color(Hsla::from(theme.gray_10)) + .cursor_pointer() + .hover(|this| this.opacity(0.8)) + .tooltip(move |_window, cx| ui::Tooltip::new(&theme, "Randomize").view(cx)) + .child( + svg() + .path("icons/shuffle.svg") + .size(px(16.)) + .text_color(Hsla::from(theme.gray_10)), + ) + .on_click(cx.listener(|this, _, window, cx| { + this.select_animated_gradient(AnimatedGradientConfig::random(), window, cx); + })); + + let mut swatches = vec![shuffle.into_any_element()]; + swatches.extend(state.catalog.templates.iter().map(|preset| { + self.render_animated_gradient_swatch(preset, cell, false, cx) + .into_any_element() + })); + let saved = state + .library + .presets + .iter() + .map(|preset| { + self.render_animated_gradient_swatch(preset, cell, true, cx) + .into_any_element() + }) + .collect::>(); + + let save_row = state.save_open.then(|| { + let input = state.name_input.clone(); + let name_empty = input + .as_ref() + .is_none_or(|input| input.read(cx).text().trim().is_empty()); + div() + .flex() + .items_center() + .gap(px(8.)) + .children(input.map(|input| { + ui::TextInput::plain(&theme, "animated-gradient-name", &input) + .height(px(32.)) + .flex(true) + })) + .child( + div() + .id("animated-gradient-save") + .flex() + .items_center() + .h(px(32.)) + .px(px(12.)) + .rounded(px(8.)) + .bg(Hsla::from(theme.gray_3)) + .text_size(px(12.)) + .font_weight(FontWeight::MEDIUM) + .text_color(Hsla::from(theme.gray_12)) + .when(name_empty, |this| this.opacity(0.4)) + .when(!name_empty, |this| { + this.cursor_pointer() + .hover(|this| this.bg(Hsla::from(theme.gray_4))) + .on_click(cx.listener(|this, _, window, cx| { + this.save_animated_gradient_preset(cx); + this.focus_root(window, cx); + })) + }) + .child("Save"), + ) + .child(icon_button( + &theme, + "animated-gradient-save-cancel", + "icons/x.svg", + "Cancel", + false, + cx.listener(|this, _, window, cx| { + this.close_animated_gradient_save(cx); + this.focus_root(window, cx); + }), + )) + .into_any_element() + }); + + ui::Field::plain(&theme, "Presets") + .value( + header_button( + &theme, + "animated-gradient-save-toggle", + "icons/save.svg", + "Save", + "Save the current gradient", + save_disabled, + save_open, + cx.listener(move |this, _, window, cx| { + if save_open { + this.close_animated_gradient_save(cx); + this.focus_root(window, cx); + } else { + this.open_animated_gradient_save(window, cx); + } + }), + ) + .into_any_element(), + ) + .child( + div() + .flex() + .flex_col() + .gap(px(12.)) + .child(swatch_rows(swatches)) + .children(save_row) + .when(!saved.is_empty(), |this| { + this.child( + div() + .text_size(px(11.)) + .text_color(Hsla::from(theme.gray_10)) + .child("Saved"), + ) + .child(swatch_rows(saved)) + }), + ) + .into_any_element() + } + + fn render_animated_gradient_swatch( + &self, + preset: &AnimatedGradientPreset, + cell: f32, + saved: bool, + cx: &mut Context, + ) -> gpui::Div { + let theme = self.theme; + let selected = self.animated_gradient_config() == Some(&preset.config); + let config = preset.config.clone(); + let name = SharedString::from(preset.name.clone()); + let id = preset.id.clone(); + let swatch = div() + .id(SharedString::from(format!("animated-gradient-preset-{id}"))) + .size_full() + .rounded(px(8.)) + .cursor_pointer() + .when(selected, |this| { + this.border_2() + .border_color(Hsla::from(theme.gray_500_legacy)) + }) + .when(!selected, |this| this.hover(|this| this.opacity(0.8))) + .child(palette(&config, px(if selected { 6. } else { 8. }))) + .tooltip(move |_window, cx| ui::Tooltip::new(&theme, name.clone()).view(cx)) + .on_click(cx.listener(move |this, _, window, cx| { + this.select_animated_gradient(config.clone(), window, cx); + })); + div() + .relative() + .size(px(cell)) + .child(swatch) + .when(saved && selected, |this| { + let delete_id = id.clone(); + this.child( + div() + .id(SharedString::from(format!("animated-gradient-delete-{id}"))) + .absolute() + .top(px(-6.)) + .right(px(-6.)) + .flex() + .items_center() + .justify_center() + .size(px(20.)) + .rounded_full() + .bg(Hsla::from(theme.gray_12)) + .text_color(Hsla::from(theme.gray_1)) + .shadow_sm() + .cursor_pointer() + .hover(|this| this.bg(Hsla::from(theme.red_11))) + .tooltip(move |_window, cx| ui::Tooltip::new(&theme, "Delete").view(cx)) + .child( + svg() + .path("icons/x.svg") + .size(px(12.)) + .text_color(Hsla::from(theme.gray_1)), + ) + .on_click(cx.listener(move |this, _, _, cx| { + this.delete_animated_gradient_preset(&delete_id, cx); + })), + ) + }) + } + + fn render_animated_gradient_colours( + &self, + config: &AnimatedGradientConfig, + cx: &mut Context, + ) -> AnyElement { + let theme = self.theme; + let selected = self.selected_animated_gradient_stop(); + let count = config.color_stops.len(); + let tracks = (0..MAX_STOPS) + .map(|index| self.sidebar.track(SliderKey::AnimatedGradientStop(index))) + .collect::>(); + + let handles = config + .color_stops + .iter() + .take(MAX_STOPS) + .enumerate() + .map(|(index, stop)| { + let is_selected = index == selected; + let size = if is_selected { + SELECTED_HANDLE_SIZE + } else { + HANDLE_SIZE + }; + let core = div() + .size_full() + .rounded_full() + .bg(color_to_hsla(stop.color)); + div() + .id(SharedString::from(format!( + "animated-gradient-handle-{index}" + ))) + .absolute() + .top(px((BAR_HEIGHT - size) / 2.)) + .left(gpui::relative(stop.position / 100.)) + .ml(px(-size / 2.)) + .size(px(size)) + .rounded_full() + .p(px(2.)) + .shadow_sm() + .cursor_pointer() + .map(|this| { + if is_selected { + this.bg(Hsla::from(theme.blue_9)).child( + div() + .size_full() + .rounded_full() + .p(px(2.)) + .bg(gpui::white()) + .child(core), + ) + } else { + this.bg(gpui::white()).child(core) + } + }) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _, _, cx| { + cx.stop_propagation(); + this.begin_animated_gradient_stop_drag(index, cx); + }), + ) + .into_any_element() + }) + .collect::>(); + + let bar = div() + .id("animated-gradient-bar") + .relative() + .w_full() + .h(px(BAR_HEIGHT)) + .child( + div() + .absolute() + .inset_0() + .rounded(px(8.)) + .border_1() + .border_color(Hsla::from(theme.gray_5)) + .child( + canvas( + move |bounds, _window, _cx| { + for track in &tracks { + track.set(Some(bounds)); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .size_full(), + ) + .child(palette(config, px(7.))), + ) + .children(handles) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, event: &MouseDownEvent, window, cx| { + this.add_animated_gradient_stop_at(event, window, cx); + }), + ); + + let stop_row = config.color_stops.get(selected).map(|stop| { + div() + .flex() + .items_center() + .gap(px(12.)) + .child(self.render_rgb_input( + STOP_INPUT_IDS[selected], + ColorTarget::AnimatedGradientStop(selected), + stop.color, + cx, + )) + .child( + div() + .ml_auto() + .text_size(px(12.)) + .text_color(Hsla::from(theme.gray_11)) + .child(format!("{}%", stop.position.round() as i32)), + ) + .child(icon_button( + &theme, + "animated-gradient-remove-stop", + "icons/trash.svg", + "Remove colour", + count <= MIN_STOPS, + cx.listener(move |this, _, window, cx| { + this.remove_animated_gradient_stop(selected, window, cx); + }), + )) + }); + + ui::Field::plain(&theme, "Colours") + .value( + header_button( + &theme, + "animated-gradient-add-stop", + "icons/plus.svg", + "Add", + "Add a colour", + count >= MAX_STOPS, + false, + cx.listener(|this, _, window, cx| { + this.add_animated_gradient_stop(window, cx); + }), + ) + .into_any_element(), + ) + .child( + div() + .flex() + .flex_col() + .gap(px(12.)) + .child(div().px(px(SELECTED_HANDLE_SIZE / 2.)).child(bar)) + .children(stop_row), + ) + .into_any_element() + } + + fn render_animated_gradient_motion( + &self, + config: &AnimatedGradientConfig, + cx: &mut Context, + ) -> AnyElement { + let theme = self.theme; + let control = AnimatedGradientParameter::MotionSpeed.control(); + let value = AnimatedGradientParameter::MotionSpeed.get(config); + ui::Subfield::plain(&theme, "Motion") + .gap(px(16.)) + .child( + div() + .flex() + .flex_1() + .min_w_0() + .items_center() + .gap(px(12.)) + .child(self.slider_flex( + SliderKey::AnimatedGradient(AnimatedGradientParameter::MotionSpeed), + slider_unit(AnimatedGradientParameter::MotionSpeed), + cx, + )) + .child( + div() + .w(px(40.)) + .flex_shrink_0() + .text_right() + .text_size(px(12.)) + .text_color(Hsla::from(theme.gray_11)) + .child(format_control_value(&control, value)), + ), + ) + .into_any_element() + } + + fn render_animated_gradient_fine_tune( + &self, + config: &AnimatedGradientConfig, + cx: &mut Context, + ) -> AnyElement { + let theme = self.theme; + let state = &self.sidebar.animated_gradient; + let open = state.fine_tune_open.is_open(); + let groups = control_groups(&state.catalog); + let active = state.fine_tune_group.min(groups.len().saturating_sub(1)); + + let tabs = ui::SegmentedControl::icons( + &theme, + "animated-gradient-groups", + groups + .iter() + .enumerate() + .map(|(index, (name, _))| ui::SegmentOption::new(name.to_string(), index == active)) + .collect(), + ) + .item_padding(px(6.), px(4.)) + .on_select(cx.listener(|this, index: &usize, _, cx| { + this.sidebar.animated_gradient.fine_tune_group = *index; + cx.notify(); + })); + + let rows = groups + .get(active) + .map(|(_, controls)| controls.as_slice()) + .unwrap_or_default() + .iter() + .map(|control| { + let value = control.key.get(config); + div() + .flex() + .items_center() + .gap(px(12.)) + .child( + div() + .w(px(96.)) + .flex_shrink_0() + .truncate() + .text_size(px(12.)) + .text_color(Hsla::from(theme.gray_11)) + .child(control.label.clone()), + ) + .child(self.slider_flex( + SliderKey::AnimatedGradient(control.key), + slider_unit(control.key), + cx, + )) + .child( + div() + .w(px(40.)) + .flex_shrink_0() + .text_right() + .text_size(px(12.)) + .text_color(Hsla::from(theme.gray_11)) + .child(format_control_value(control, value)), + ) + .into_any_element() + }) + .collect::>(); + + div() + .flex() + .flex_col() + .child( + div() + .flex() + .items_center() + .child( + div() + .id("animated-gradient-fine-tune") + .flex() + .flex_1() + .items_center() + .gap(px(6.)) + .text_size(px(14.)) + .font_weight(FontWeight::MEDIUM) + .text_color(Hsla::from(theme.gray_12)) + .cursor_pointer() + .child("Fine-tune") + .child( + svg() + .path(if open { + "icons/chevron-up.svg" + } else { + "icons/chevron-down.svg" + }) + .size(px(14.)) + .text_color(Hsla::from(theme.gray_10)), + ) + .on_click(cx.listener(|this, _, window, cx| { + this.sidebar.animated_gradient.fine_tune_open.toggle(); + this.animate_collapsibles(window, cx); + })), + ) + .when(open, |this| { + this.child(header_button( + &theme, + "animated-gradient-reset", + "icons/rotate-ccw.svg", + "Reset", + "Reset fine-tune settings", + false, + false, + cx.listener(|this, _, window, cx| { + this.reset_animated_gradient_fine_tune(window, cx); + }), + )) + }), + ) + .child(collapsible( + &state.fine_tune_open, + div() + .flex() + .flex_col() + .gap(px(12.)) + .pt(px(16.)) + .child(tabs) + .child(div().flex().flex_col().gap(px(4.)).children(rows)) + .into_any_element(), + )) + .into_any_element() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn inactive_selection_does_not_rewrite_another_editors_last_used_gradient() { + let selection = pending_selection(&BackgroundSource::default(), None, SourceTab::Color); + assert!(!selection.selected); + assert!(selection.last_used.is_none()); + } + + #[test] + fn switching_away_remembers_the_outgoing_gradient() { + let gradient = AnimatedGradientConfig::from_seed(77); + let selection = pending_selection( + &BackgroundSource::default(), + Some(gradient.clone()), + SourceTab::Color, + ); + assert!(!selection.selected); + assert_eq!(selection.last_used, Some(gradient)); + } + + #[test] + fn later_edits_retain_the_pending_gradient_without_reselecting_it() { + let gradient = AnimatedGradientConfig::from_seed(77); + let pending = pending_selection( + &BackgroundSource::default(), + Some(gradient.clone()), + SourceTab::None, + ); + let selection = pending_selection(&BackgroundSource::default(), None, SourceTab::Color) + .merge(Some(&pending)); + assert!(!selection.selected); + assert_eq!(selection.last_used, Some(gradient)); + } + + #[test] + fn selecting_none_disables_animation_and_remembers_its_config() { + let gradient = AnimatedGradientConfig::from_seed(17); + let mut project = ProjectConfiguration::default(); + project.background.source = BackgroundSource::AnimatedGradient { + config: gradient.clone(), + }; + project.background.padding = 10.; + project.background.rounding = 15.; + assert!(hide_background(&mut project)); + assert!(is_none_background(&project)); + assert!(matches!( + project.background.source, + BackgroundSource::Color { + value: [255, 255, 255], + alpha: 255 + } + )); + let selection = pending_selection( + &project.background.source, + Some(gradient.clone()), + SourceTab::None, + ); + assert!(!selection.selected); + assert_eq!(selection.last_used, Some(gradient)); + + project.background.source = BackgroundSource::Wallpaper { + path: Some("keep.jpg".into()), + }; + assert!(hide_background(&mut project)); + assert!( + matches!(project.background.source, BackgroundSource::Wallpaper { path: Some(path) } if path == "keep.jpg") + ); + } + + #[test] + fn adding_and_removing_colours_preserves_limits_and_order() { + let mut config = AnimatedGradientConfig::default(); + assert!(add_stop(&mut config).is_none()); + assert!(remove_stop(&mut config, 2)); + assert!(remove_stop(&mut config, 2)); + assert!(remove_stop(&mut config, 1)); + assert!(!remove_stop(&mut config, 1)); + assert!(!remove_stop(&mut config, 7)); + assert_eq!(add_stop(&mut config), Some(1)); + assert_eq!(config.color_stops.len(), 3); + assert_eq!(config.color_stops[1].position, 50.); + assert_eq!(stop_limits(&config, 1), (0., 100.)); + assert!(add_stop(&mut config).is_some()); + assert!(add_stop(&mut config).is_some()); + assert!(add_stop(&mut config).is_none()); + assert!( + config + .color_stops + .windows(2) + .all(|pair| pair[0].position <= pair[1].position) + ); + } + + #[test] + fn clicking_the_bar_inserts_a_blended_stop_in_order() { + let mut config = AnimatedGradientConfig { + color_stops: vec![ + AnimatedGradientStop { + color: [0, 0, 0], + position: 0., + }, + AnimatedGradientStop { + color: [200, 100, 0], + position: 100., + }, + ], + ..Default::default() + }; + assert_eq!(insert_stop(&mut config, 25.), Some(1)); + assert_eq!(config.color_stops[1].position, 25.); + assert_eq!(config.color_stops[1].color, [50, 25, 0]); + assert_eq!(insert_stop(&mut config, 10.4), Some(1)); + assert_eq!(config.color_stops[1].position, 10.); + assert_eq!(insert_stop(&mut config, 100.), Some(4)); + assert_eq!(config.color_stops[4].color, [200, 100, 0]); + assert!(insert_stop(&mut config, 60.).is_none()); + assert_eq!(color_at(&config.color_stops, -5.), [0, 0, 0]); + } + + #[test] + fn position_limits_keep_colour_identity_stable() { + let config = AnimatedGradientConfig::default(); + assert_eq!(stop_limits(&config, 0), (0., 25.)); + assert_eq!(stop_limits(&config, 2), (25., 75.)); + assert_eq!(stop_limits(&config, 4), (75., 100.)); + } + + #[test] + fn control_values_read_like_the_solid_editor() { + let direction = AnimatedGradientParameter::Direction.control(); + let motion = AnimatedGradientParameter::MotionSpeed.control(); + let scale = AnimatedGradientParameter::FlowScale.control(); + let strength = AnimatedGradientParameter::FlowStrength.control(); + assert_eq!(format_control_value(&direction, 45.4), "45\u{b0}"); + assert_eq!(format_control_value(&motion, 0.), "Still"); + assert_eq!(format_control_value(&motion, 30.), "30"); + assert_eq!(format_control_value(&scale, 2.), "2.0"); + assert_eq!(format_control_value(&strength, 55.), "55"); + } + + #[test] + fn fine_tune_groups_exclude_motion() { + let catalog = animated_gradient_catalog(); + let groups = control_groups(&catalog); + assert_eq!(groups.len(), 4); + assert!(groups.iter().all(|(name, _)| *name != "Animation")); + assert!( + groups + .iter() + .flat_map(|(_, controls)| controls) + .all(|control| control.key != AnimatedGradientParameter::MotionSpeed) + ); + } +} diff --git a/apps/desktop-gpui/src/editor_window.rs b/apps/desktop-gpui/src/editor_window.rs index b5bd74046a1..0a8fcad7dbe 100644 --- a/apps/desktop-gpui/src/editor_window.rs +++ b/apps/desktop-gpui/src/editor_window.rs @@ -1741,6 +1741,7 @@ impl EditorWindow { // The sidebar's own signals are seeded from the config the instance // actually loaded, not the pre-flight's: `backgroundSourceTab`'s // initial value reads `background.padding`/`rounding` (`CS:1799-1802`). + self.flush_animated_gradient_selection(); self.sidebar = crate::editor_sidebar::SidebarState::new(&self.project); self.sidebar_loaded(window, cx); cx.notify(); @@ -2006,11 +2007,26 @@ impl EditorWindow { window: &mut Window, cx: &mut Context, ) { + let previous_animated_gradient = self.animated_gradient_config().cloned(); + let animated_background_changed = self.animated_gradient_config() + != match &config.background.source { + cap_project::BackgroundSource::AnimatedGradient { config } => Some(config), + _ => None, + } + || crate::editor_sidebar::is_none_background(&self.project) + != crate::editor_sidebar::is_none_background(&config); self.project = config; self.rebuild_timeline(); - self.sync_background_source_tab(); + if self.animated_gradient_config().is_some() { + self.sidebar.source_tab = crate::editor_sidebar::initial_source_tab(&self.project); + } else { + self.sync_background_source_tab(); + } self.publish_project(); self.schedule_save(window, cx); + if animated_background_changed { + self.remember_animated_gradient_selection(previous_animated_gradient, window, cx); + } // A segment the undo removed must not stay selected. if let Some(selection) = &self.selection && let Some(timeline) = self.project.timeline.as_ref() @@ -5466,10 +5482,15 @@ impl EditorWindow { return; }; self.presets_menu = None; + self.refresh_animated_gradient_library(); + let previous_animated_gradient = self.animated_gradient_config().cloned(); self.edit_project("apply-preset", window, cx, move |project| { *project = next.clone(); true }); + self.sidebar.source_tab = crate::editor_sidebar::initial_source_tab(&self.project); + self.close_color_picker(cx); + self.remember_animated_gradient_selection(previous_animated_gradient, window, cx); } /// The submenu's store mutations: everything but Apply and the two @@ -8279,6 +8300,7 @@ impl Render for EditorWindow { // only renders on invalidation, so syncing before creating would leave // a brand-new box empty until something else asked for a frame. self.prepare_sidebar_fields(window, cx); + self.prepare_animated_gradient_fields(window, cx); self.sync_hex_inputs(window, cx); self.sync_picker_hex(window, cx); self.sync_crop_container(window); diff --git a/apps/desktop-gpui/src/recording.rs b/apps/desktop-gpui/src/recording.rs index a8f647b86cd..48ab9c63aed 100644 --- a/apps/desktop-gpui/src/recording.rs +++ b/apps/desktop-gpui/src/recording.rs @@ -149,6 +149,7 @@ impl ActiveRecording { let mut instant_upload = self.instant_upload; match self.handle { Handle::Studio(handle) => { + let capture_target = handle.capture_target.clone(); let completed = handle.stop().await?; let project_path = completed.project_path.clone(); let needs_remux = matches!( @@ -177,6 +178,15 @@ impl ActiveRecording { write_bundle_thumbnail(&project_path, &display_path); } apply_camera_blur_to_project_config(&project_path, current_camera_blur()); + let library = serde_json::from_value(serde_json::Value::Object( + crate::store::store_section("animated_gradients"), + )) + .unwrap_or_default(); + apply_animated_gradient_to_project_config( + &project_path, + &capture_target, + &library, + ); }) .await .context("studio post-finalize task")?; @@ -495,6 +505,97 @@ fn blur_mode_json(blur: crate::store::BlurMode) -> &'static str { } } +fn apply_animated_gradient_to_project_config( + project_dir: &std::path::Path, + capture_target: &ScreenCaptureTarget, + library: &cap_project::AnimatedGradientLibrary, +) -> bool { + if matches!(capture_target, ScreenCaptureTarget::CameraOnly) || !library.selected { + return false; + } + let Some(gradient) = library.last_used.as_ref() else { + return false; + }; + let path = project_dir.join("project-config.json"); + let bytes = match std::fs::read(&path) { + Ok(bytes) => bytes, + Err(error) => { + tracing::warn!(path = %path.display(), "could not read new project background: {error}"); + return false; + } + }; + let Ok(mut config) = serde_json::from_slice::(&bytes) else { + tracing::warn!(path = %path.display(), "project config did not parse; keeping its background"); + return false; + }; + if !apply_initial_animated_gradient(&mut config, gradient) { + return false; + } + let Ok(serialized) = serde_json::to_vec_pretty(&config) else { + return false; + }; + let temp = path.with_extension(format!( + "animated-gradient-{}.tmp", + crate::store::new_uuid_v4() + )); + if let Err(error) = + std::fs::write(&temp, serialized).and_then(|()| std::fs::rename(&temp, &path)) + { + tracing::warn!(path = %path.display(), "could not remember new project background: {error}"); + let _ = std::fs::remove_file(&temp); + return false; + } + true +} + +fn apply_initial_animated_gradient( + project: &mut serde_json::Value, + config: &cap_project::AnimatedGradientConfig, +) -> bool { + let Some(background) = project + .get_mut("background") + .and_then(serde_json::Value::as_object_mut) + else { + return false; + }; + let padding = match background.get("padding") { + Some(value) => match value.as_f64() { + Some(value) if value >= 0. => value, + _ => return false, + }, + None => 0., + }; + let Some(source) = background + .get_mut("source") + .and_then(serde_json::Value::as_object_mut) + else { + return false; + }; + if source.get("type").and_then(serde_json::Value::as_str) != Some("color") + || source.get("value") != Some(&serde_json::json!([255, 255, 255])) + || source.get("alpha").and_then(serde_json::Value::as_u64) != Some(255) + { + return false; + } + let Ok(config) = serde_json::to_value(config.normalized()) else { + return false; + }; + let _ = source.insert( + "type".into(), + serde_json::Value::String("animatedGradient".into()), + ); + let _ = source.insert("config".into(), config); + let _ = source.remove("value"); + let _ = source.remove("alpha"); + if padding == 0. { + let _ = background.insert( + "padding".into(), + serde_json::json!(crate::editor_sidebar::DEFAULT_BACKGROUND_PADDING), + ); + } + true +} + /// Copy the camera preview's blur toggle into the finished project's /// configuration -- the bridge at /// `apps/desktop/src-tauri/src/recording.rs:3889-3891`: @@ -1317,6 +1418,213 @@ mod tests { dir } + fn test_display_target() -> ScreenCaptureTarget { + ScreenCaptureTarget::Display { + id: "1".parse().unwrap(), + } + } + + #[test] + fn animated_gradient_preference_preserves_camera_only_presentation() { + let dir = temp_project("animated-camera-only"); + let path = dir.join("project-config.json"); + let original = serde_json::to_vec(&cap_project::ProjectConfiguration::default()).unwrap(); + std::fs::write(&path, &original).unwrap(); + let library = cap_project::AnimatedGradientLibrary { + selected: true, + last_used: Some(cap_project::AnimatedGradientConfig::default()), + ..Default::default() + }; + assert!(!apply_animated_gradient_to_project_config( + &dir, + &ScreenCaptureTarget::CameraOnly, + &library, + )); + assert_eq!(std::fs::read(&path).unwrap(), original); + assert!(apply_animated_gradient_to_project_config( + &dir, + &test_display_target(), + &library, + )); + let written: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(written["background"]["source"]["type"], "animatedGradient"); + assert_eq!( + written["background"]["padding"], + crate::editor_sidebar::DEFAULT_BACKGROUND_PADDING + ); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn animated_gradient_preference_is_inactive_without_selection_and_config() { + let dir = temp_project("animated-inactive"); + let path = dir.join("project-config.json"); + let original = serde_json::to_vec(&cap_project::ProjectConfiguration::default()).unwrap(); + std::fs::write(&path, &original).unwrap(); + for library in [ + cap_project::AnimatedGradientLibrary { + selected: false, + last_used: Some(cap_project::AnimatedGradientConfig::default()), + ..Default::default() + }, + cap_project::AnimatedGradientLibrary { + selected: true, + ..Default::default() + }, + ] { + assert!(!apply_animated_gradient_to_project_config( + &dir, + &test_display_target(), + &library + )); + assert_eq!(std::fs::read(&path).unwrap(), original); + } + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn animated_gradient_preference_applies_normalized_config_and_visible_padding() { + let dir = temp_project("animated-new-project"); + let path = dir.join("project-config.json"); + cap_project::ProjectConfiguration::default() + .write(&dir) + .unwrap(); + let mut gradient = cap_project::AnimatedGradientConfig::from_seed(73); + gradient.motion_speed = 800.; + gradient.flow_scale = 0.; + let library = cap_project::AnimatedGradientLibrary { + selected: true, + last_used: Some(gradient.clone()), + ..Default::default() + }; + assert!(apply_animated_gradient_to_project_config( + &dir, + &test_display_target(), + &library + )); + let written: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(written["background"]["source"]["type"], "animatedGradient"); + assert_eq!( + written["background"]["source"]["config"], + serde_json::to_value(gradient.normalized()).unwrap() + ); + assert_eq!( + written["background"]["padding"], + crate::editor_sidebar::DEFAULT_BACKGROUND_PADDING + ); + assert!(!apply_animated_gradient_to_project_config( + &dir, + &test_display_target(), + &library + )); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn animated_gradient_preference_preserves_unknown_fields_and_edits() { + let dir = temp_project("animated-preserve"); + let path = dir.join("project-config.json"); + let original = serde_json::json!({ + "background": { + "source": {"type": "color", "value": [255, 255, 255], "alpha": 255, "futureSourceField": 42}, + "padding": 18.0, + "rounding": 27.0, + "futureBackgroundField": {"preserved": true} + }, + "timeline": {"segments": [{"recordingClip": 0, "start": 0.0, "end": 3.0}]}, + "clips": [{"aFutureClipField": [1, 2, 3]}], + "camera": {"backgroundBlur": {"mode": "heavy"}}, + "aFieldFromANewerBuild": 42 + }); + std::fs::write(&path, serde_json::to_vec(&original).unwrap()).unwrap(); + let gradient = cap_project::AnimatedGradientConfig::default(); + let library = cap_project::AnimatedGradientLibrary { + selected: true, + last_used: Some(gradient.clone()), + ..Default::default() + }; + assert!(apply_animated_gradient_to_project_config( + &dir, + &test_display_target(), + &library + )); + let written: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + let mut expected = original; + expected["background"]["source"] = serde_json::json!({ + "type": "animatedGradient", + "config": gradient.normalized(), + "futureSourceField": 42 + }); + assert_eq!(written, expected); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn animated_gradient_preference_leaves_malformed_projects_untouched() { + let dir = temp_project("animated-malformed"); + let path = dir.join("project-config.json"); + let library = cap_project::AnimatedGradientLibrary { + selected: true, + last_used: Some(cap_project::AnimatedGradientConfig::default()), + ..Default::default() + }; + assert!(!apply_animated_gradient_to_project_config( + &dir, + &test_display_target(), + &library + )); + assert!(!path.exists()); + for original in [ + "{ not valid JSON", + "[]", + "{}", + "{\"background\":[]}", + "{\"background\":{\"source\":null}}", + "{\"background\":{\"source\":{\"type\":\"color\",\"value\":[255,255,255],\"alpha\":255},\"padding\":\"invalid\"}}", + ] { + std::fs::write(&path, original).unwrap(); + assert!(!apply_animated_gradient_to_project_config( + &dir, + &test_display_target(), + &library + )); + assert_eq!(std::fs::read_to_string(&path).unwrap(), original); + } + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn animated_gradient_preference_does_not_replace_custom_sources() { + let dir = temp_project("animated-custom"); + let path = dir.join("project-config.json"); + let library = cap_project::AnimatedGradientLibrary { + selected: true, + last_used: Some(cap_project::AnimatedGradientConfig::default()), + ..Default::default() + }; + for source in [ + serde_json::json!({"type": "wallpaper", "path": "macOS/tahoe-dark"}), + serde_json::json!({"type": "image", "path": "custom.png"}), + serde_json::json!({"type": "color", "value": [254, 255, 255], "alpha": 255}), + serde_json::json!({"type": "color", "value": [255, 255, 255], "alpha": 0}), + serde_json::json!({"type": "gradient", "from": [255, 0, 0], "to": [0, 0, 255]}), + serde_json::json!({"type": "animatedGradient", "config": cap_project::AnimatedGradientConfig::from_seed(99)}), + ] { + let original = serde_json::to_vec( + &serde_json::json!({"background": {"source": source, "padding": 0}}), + ) + .unwrap(); + std::fs::write(&path, &original).unwrap(); + assert!(!apply_animated_gradient_to_project_config( + &dir, + &test_display_target(), + &library + )); + assert_eq!(std::fs::read(&path).unwrap(), original); + } + std::fs::remove_dir_all(dir).unwrap(); + } + #[test] fn stopped_instant_metadata_preserves_recovery_and_upload_state() { let dir = temp_project("instant-stopped"); diff --git a/apps/desktop-gpui/src/screenshot_editor.rs b/apps/desktop-gpui/src/screenshot_editor.rs index 562265e85da..0f44043c26e 100644 --- a/apps/desktop-gpui/src/screenshot_editor.rs +++ b/apps/desktop-gpui/src/screenshot_editor.rs @@ -486,7 +486,9 @@ impl BgTab { fn for_source(source: &BackgroundSource) -> Self { match source { BackgroundSource::Color { .. } => Self::Color, - BackgroundSource::Gradient { .. } => Self::Gradient, + BackgroundSource::Gradient { .. } | BackgroundSource::AnimatedGradient { .. } => { + Self::Gradient + } BackgroundSource::Wallpaper { .. } => Self::Wallpaper, BackgroundSource::Image { .. } => Self::Image, } @@ -784,7 +786,7 @@ pub(crate) fn has_no_visible_background(source: &BackgroundSource) -> bool { match source { BackgroundSource::Color { alpha, .. } => *alpha == 0, BackgroundSource::Wallpaper { path } | BackgroundSource::Image { path } => path.is_none(), - BackgroundSource::Gradient { .. } => false, + BackgroundSource::Gradient { .. } | BackgroundSource::AnimatedGradient { .. } => false, } } @@ -3532,6 +3534,16 @@ impl ScreenshotEditorWindow { fn render_gradient_tab(&self, cx: &mut Context) -> AnyElement { let theme = self.theme; + if matches!( + self.project.background.source, + BackgroundSource::AnimatedGradient { .. } + ) { + return div() + .text_size(px(12.)) + .text_color(theme.gray_11) + .child("Animated Gradient is rendered as a still image for screenshots. Choose another background to replace it.") + .into_any_element(); + } let (from, to, angle) = match &self.project.background.source { BackgroundSource::Gradient { from, to, angle, .. diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 692dd7327e5..e5292a3551e 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -5000,9 +5000,23 @@ fn configure_camera_blur_recovery( } } +#[tauri::command] +#[specta::specta] +fn animated_gradient_catalog() -> cap_project::AnimatedGradientCatalog { + cap_project::animated_gradient_catalog() +} + +#[tauri::command] +#[specta::specta] +fn random_animated_gradient() -> cap_project::AnimatedGradientConfig { + cap_project::AnimatedGradientConfig::random() +} + fn specta_builder() -> tauri_specta::Builder { tauri_specta::Builder::new() .commands(tauri_specta::collect_commands![ + animated_gradient_catalog, + random_animated_gradient, set_mic_input, set_camera_input, set_native_camera_preview_enabled, @@ -5205,6 +5219,7 @@ fn specta_builder() -> tauri_specta::Builder { ]) .error_handling(tauri_specta::ErrorHandlingMode::Throw) .typ::() + .typ::() .typ::() .typ::() .typ::() diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 54c0d36dca2..1e0743a6b3a 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -49,6 +49,7 @@ use std::{ use tauri::{AppHandle, Manager, path::BaseDirectory}; use tauri_plugin_dialog::{DialogExt, MessageDialogBuilder}; use tauri_plugin_global_shortcut::GlobalShortcutExt; +use tauri_plugin_store::StoreExt; use tauri_specta::Event; use tracing::*; @@ -3880,6 +3881,19 @@ fn project_config_from_recording( let using_default_config = default_config.is_none(); let mut config = default_config.unwrap_or_default(); + if using_default_config { + let library = app + .store("store") + .ok() + .and_then(|store| store.get("animated_gradients")) + .and_then(|value| serde_json::from_value(value).ok()); + apply_animated_gradient_default( + &mut config, + library.as_ref(), + using_default_config, + capture_target, + ); + } config.cursor.size = cap_project::CursorConfiguration::default().size; apply_recording_presentation_defaults( app, @@ -4003,6 +4017,24 @@ fn apply_recording_presentation_defaults( ); } +fn apply_animated_gradient_default( + config: &mut ProjectConfiguration, + library: Option<&cap_project::AnimatedGradientLibrary>, + using_default_config: bool, + capture_target: Option<&ScreenCaptureTarget>, +) { + if using_default_config + && !matches!(capture_target, Some(ScreenCaptureTarget::CameraOnly)) + && let Some(library) = library + && library.selected + && let Some(gradient) = &library.last_used + { + config.background.source = cap_project::BackgroundSource::AnimatedGradient { + config: gradient.normalized(), + }; + } +} + const DEFAULT_SCREEN_RECORDING_BACKGROUND_ROUNDING_PERCENT: f64 = 7.5; fn apply_screen_recording_presentation_defaults( @@ -4257,6 +4289,67 @@ mod tests { use super::*; use tempfile::tempdir; + #[test] + fn animated_gradient_default_is_remembered_without_overwriting_explicit_presets() { + let library = cap_project::AnimatedGradientLibrary { + selected: true, + last_used: Some(cap_project::AnimatedGradientConfig::from_seed(42)), + ..Default::default() + }; + let mut project = ProjectConfiguration::default(); + apply_animated_gradient_default(&mut project, Some(&library), false, None); + assert!(matches!( + project.background.source, + cap_project::BackgroundSource::Color { .. } + )); + apply_animated_gradient_default(&mut project, Some(&library), true, None); + apply_screen_recording_presentation_defaults( + &mut project, + None, + true, + Some("wallpaper.jpg".into()), + ); + let cap_project::BackgroundSource::AnimatedGradient { config } = project.background.source + else { + panic!("Expected remembered gradient"); + }; + assert_eq!(Some(config), library.last_used); + assert_eq!(project.background.padding, 10.0); + } + + #[test] + fn deselected_or_missing_animated_gradient_keeps_recording_defaults() { + let mut project = ProjectConfiguration::default(); + let library = cap_project::AnimatedGradientLibrary { + last_used: Some(cap_project::AnimatedGradientConfig::default()), + ..Default::default() + }; + apply_animated_gradient_default(&mut project, Some(&library), true, None); + apply_animated_gradient_default(&mut project, None, true, None); + assert!(matches!( + project.background.source, + cap_project::BackgroundSource::Color { .. } + )); + } + + #[test] + fn animated_gradient_default_preserves_camera_only_presentation() { + let library = cap_project::AnimatedGradientLibrary { + selected: true, + last_used: Some(cap_project::AnimatedGradientConfig::default()), + ..Default::default() + }; + let mut project = ProjectConfiguration::default(); + let original = serde_json::to_value(&project.background).unwrap(); + apply_animated_gradient_default( + &mut project, + Some(&library), + true, + Some(&ScreenCaptureTarget::CameraOnly), + ); + assert_eq!(serde_json::to_value(project.background).unwrap(), original); + } + #[test] fn recording_start_preflight_requires_authentication_for_instant_recordings() { assert_eq!( diff --git a/apps/desktop/src/routes/editor/AnimatedGradientEditor.tsx b/apps/desktop/src/routes/editor/AnimatedGradientEditor.tsx new file mode 100644 index 00000000000..4ddb7d6a2dd --- /dev/null +++ b/apps/desktop/src/routes/editor/AnimatedGradientEditor.tsx @@ -0,0 +1,762 @@ +import { Collapsible as KCollapsible } from "@kobalte/core/collapsible"; +import { createQuery } from "@tanstack/solid-query"; +import { cx } from "cva"; +import { + createMemo, + createSignal, + For, + Index, + type JSX, + onCleanup, + Show, +} from "solid-js"; +import { produce } from "solid-js/store"; +import { animatedGradientsStore } from "~/store"; +import type { OrganizationBrandColorSwatch } from "~/utils/organization-branding"; +import { + type AnimatedGradientConfig, + type AnimatedGradientControl, + type AnimatedGradientPreset, + commands, +} from "~/utils/tauri"; +import IconCapChevronDown from "~icons/cap/chevron-down"; +import IconLucidePlus from "~icons/lucide/plus"; +import IconLucideRotateCcw from "~icons/lucide/rotate-ccw"; +import IconLucideSave from "~icons/lucide/save"; +import IconLucideShuffle from "~icons/lucide/shuffle"; +import IconLucideTrash2 from "~icons/lucide/trash-2"; +import IconLucideX from "~icons/lucide/x"; +import { BrandColorsDropdown } from "./BrandColorsDropdown"; +import { hexToRgb, RgbInput } from "./color-utils"; +import { useEditorContext } from "./context"; +import { Field, Input, Slider, Subfield } from "./ui"; + +const MAX_STOPS = 5; +const MIN_STOPS = 2; +const MAX_SAVED = 100; +const MOTION_KEY: AnimatedGradientControl["key"] = "motionSpeed"; + +export function copyAnimatedGradientConfig( + config: AnimatedGradientConfig, +): AnimatedGradientConfig { + return { + ...config, + colorStops: config.colorStops.map((stop) => ({ + position: stop.position, + color: [stop.color[0], stop.color[1], stop.color[2]], + })), + }; +} + +class SavedGradientLimitError extends Error {} + +type Stop = AnimatedGradientConfig["colorStops"][number]; + +function palettePreview(config: AnimatedGradientConfig) { + const stops = config.colorStops.map( + (stop) => `rgb(${stop.color.join(",")}) ${stop.position}%`, + ); + return `linear-gradient(90deg, ${stops.join(",")})`; +} + +function controlValue(control: AnimatedGradientControl, value: number) { + if (control.key === "direction") return `${Math.round(value)}°`; + if (control.key === MOTION_KEY && value === 0) return "Still"; + return control.step < 1 ? value.toFixed(1) : String(Math.round(value)); +} + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} + +function colorAt(stops: Stop[], position: number): Stop["color"] { + const first = stops[0]; + const last = stops[stops.length - 1]; + if (!first || !last) return [128, 128, 128]; + if (position <= first.position) return [...first.color]; + if (position >= last.position) return [...last.color]; + for (let index = 0; index < stops.length - 1; index++) { + const left = stops[index]; + const right = stops[index + 1]; + if (position < left.position || position > right.position) continue; + const span = right.position - left.position; + const t = span <= 0 ? 0 : (position - left.position) / span; + return [0, 1, 2].map((channel) => + Math.round( + left.color[channel] + (right.color[channel] - left.color[channel]) * t, + ), + ) as Stop["color"]; + } + return [...last.color]; +} + +function insertStop(stops: Stop[], position: number) { + if (stops.length >= MAX_STOPS) return null; + const stop: Stop = { position, color: colorAt(stops, position) }; + let index = stops.findIndex((existing) => existing.position > position); + if (index === -1) index = stops.length; + stops.splice(index, 0, stop); + return index; +} + +function largestGapPosition(stops: Stop[]) { + let position = 50; + let largestGap = -1; + for (let index = 0; index < stops.length - 1; index++) { + const gap = stops[index + 1].position - stops[index].position; + if (gap > largestGap) { + largestGap = gap; + position = Math.round( + (stops[index].position + stops[index + 1].position) / 2, + ); + } + } + return position; +} + +function HeaderButton(props: { + icon: JSX.Element; + label: string; + title?: string; + disabled?: boolean; + pressed?: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +function ControlRow(props: { + control: AnimatedGradientControl; + value: number; + onChange: (value: number) => void; +}) { + return ( +
+ + {props.control.label} + + controlValue(props.control, value)} + aria-label={props.control.label} + onChange={([next]) => props.onChange(next)} + /> + + {controlValue(props.control, props.value)} + +
+ ); +} + +export function AnimatedGradientEditor(props: { + brandColorSwatches?: OrganizationBrandColorSwatch[]; +}) { + const { project, setProject, projectHistory } = useEditorContext(); + const catalog = createQuery(() => ({ + queryKey: ["animated-gradient-catalog"], + queryFn: () => commands.animatedGradientCatalog(), + staleTime: Number.POSITIVE_INFINITY, + })); + const library = animatedGradientsStore.createQuery(); + const [presetName, setPresetName] = createSignal(""); + const [saveOpen, setSaveOpen] = createSignal(false); + const [randomizing, setRandomizing] = createSignal(false); + const [saving, setSaving] = createSignal(false); + const [deletingId, setDeletingId] = createSignal(null); + const [error, setError] = createSignal(null); + const [selectedStop, setSelectedStop] = createSignal(0); + const [fineTuneOpen, setFineTuneOpen] = createSignal(false); + const [groupName, setGroupName] = createSignal(null); + let disposed = false; + let revision = 0; + let barRef!: HTMLDivElement; + onCleanup(() => { + disposed = true; + }); + + const config = createMemo(() => { + const source = project.background.source; + return source.type === "animatedGradient" ? source.config : null; + }); + const stops = () => config()?.colorStops ?? []; + const stopIndex = createMemo(() => + clamp(selectedStop(), 0, Math.max(0, stops().length - 1)), + ); + const savedPresets = () => library.data?.presets ?? []; + const serializedConfig = createMemo(() => JSON.stringify(config())); + const isSelected = (preset: AnimatedGradientPreset) => + serializedConfig() === JSON.stringify(preset.config); + const motionControl = createMemo(() => + catalog.data?.controls.find((control) => control.key === MOTION_KEY), + ); + const groups = createMemo(() => { + const grouped = new Map(); + for (const control of catalog.data?.controls ?? []) { + if (control.key === MOTION_KEY) continue; + const controls = grouped.get(control.group) ?? []; + controls.push(control); + grouped.set(control.group, controls); + } + return Array.from(grouped, ([name, controls]) => ({ name, controls })); + }); + const activeGroup = createMemo( + () => groups().find((group) => group.name === groupName()) ?? groups()[0], + ); + + const updateConfig = (update: (value: AnimatedGradientConfig) => void) => { + if (disposed || !config()) return; + revision += 1; + setProject( + "background", + "source", + produce((source) => { + if (source.type === "animatedGradient") update(source.config); + }), + ); + }; + + const applyConfig = (value: AnimatedGradientConfig) => { + const next = copyAnimatedGradientConfig(value); + updateConfig((current) => Object.assign(current, next)); + }; + + const randomize = async () => { + if (randomizing() || !config()) return; + const requestedRevision = revision; + const requestedConfig = serializedConfig(); + setRandomizing(true); + setError(null); + try { + const next = await commands.randomAnimatedGradient(); + if ( + !disposed && + requestedRevision === revision && + requestedConfig === serializedConfig() + ) + applyConfig(next); + } catch { + if (!disposed) setError("Could not create a random gradient. Try again."); + } finally { + if (!disposed) setRandomizing(false); + } + }; + + const addStop = (position: number) => { + let inserted: number | null = null; + updateConfig((current) => { + inserted = insertStop(current.colorStops, position); + }); + if (inserted !== null) setSelectedStop(inserted); + return inserted; + }; + + const removeStop = (index: number) => { + updateConfig((current) => { + if (current.colorStops.length > MIN_STOPS) + current.colorStops.splice(index, 1); + }); + setSelectedStop(Math.max(0, index - 1)); + }; + + const moveStop = (index: number, position: number) => { + updateConfig((current) => { + const stop = current.colorStops[index]; + if (!stop) return; + stop.position = clamp( + Math.round(position), + current.colorStops[index - 1]?.position ?? 0, + current.colorStops[index + 1]?.position ?? 100, + ); + }); + }; + + const barPosition = (event: PointerEvent) => { + const rect = barRef.getBoundingClientRect(); + if (rect.width <= 0) return null; + return Math.round( + clamp((event.clientX - rect.left) / rect.width, 0, 1) * 100, + ); + }; + + const dragStop = (index: number, event: PointerEvent) => { + const target = event.currentTarget as HTMLElement; + target.setPointerCapture(event.pointerId); + let resume: (() => void) | null = null; + const move = (moveEvent: PointerEvent) => { + const position = barPosition(moveEvent); + if (position === null) return; + if (!resume) resume = projectHistory.pause(); + moveStop(index, position); + }; + const end = () => { + target.removeEventListener("pointermove", move); + target.removeEventListener("pointerup", end); + target.removeEventListener("pointercancel", end); + resume?.(); + resume = null; + }; + target.addEventListener("pointermove", move); + target.addEventListener("pointerup", end); + target.addEventListener("pointercancel", end); + }; + + const resetFineTune = () => { + const defaults = catalog.data?.defaultConfig; + if (!defaults) return; + updateConfig((current) => { + for (const group of groups()) + for (const control of group.controls) + current[control.key] = defaults[control.key]; + }); + }; + + const closeSave = () => { + setSaveOpen(false); + setPresetName(""); + }; + + const savePreset = async () => { + const currentConfig = config(); + const name = Array.from(presetName().trim()).slice(0, 80).join(""); + if (!currentConfig || !name || saving() || deletingId()) return; + const savedConfig = copyAnimatedGradientConfig(currentConfig); + setSaving(true); + setError(null); + try { + await animatedGradientsStore.update((current) => { + if (current.presets.length >= MAX_SAVED) + throw new SavedGradientLimitError(); + return { + ...current, + presets: [ + ...current.presets, + { id: crypto.randomUUID(), name, config: savedConfig }, + ], + }; + }); + if (disposed) return; + closeSave(); + await library.refetch(); + } catch (error) { + if (!disposed) + setError( + error instanceof SavedGradientLimitError + ? `You can save up to ${MAX_SAVED} gradients. Delete one to add another.` + : "Could not save this gradient. Try again.", + ); + } finally { + if (!disposed) setSaving(false); + } + }; + + const deletePreset = async (id: string) => { + if (saving() || deletingId()) return; + setDeletingId(id); + setError(null); + try { + await animatedGradientsStore.update((current) => ({ + ...current, + presets: current.presets.filter((preset) => preset.id !== id), + })); + if (disposed) return; + await library.refetch(); + } catch { + if (!disposed) setError("Could not delete this gradient. Try again."); + } finally { + if (!disposed) setDeletingId(null); + } + }; + + const swatchClass = + "aspect-square w-full rounded-lg ring-offset-2 ring-offset-gray-200 transition-all duration-200 hover:scale-105 hover:opacity-80"; + + return ( + + {(current) => ( +
+ + + + + } + label="Save" + title="Save the current gradient" + pressed={saveOpen()} + disabled={ + saving() || + library.isPending || + library.isError || + savedPresets().length >= MAX_SAVED + } + onClick={() => (saveOpen() ? closeSave() : setSaveOpen(true))} + /> + } + > +
+
+ + + {(preset) => ( +
+ +
+ queueMicrotask(() => element.focus())} + class="min-w-0 flex-1" + placeholder="Name this gradient" + aria-label="Saved gradient name" + maxLength={80} + value={presetName()} + disabled={saving()} + onInput={(event) => + setPresetName(event.currentTarget.value) + } + onKeyDown={(event) => { + if (event.key === "Escape") { + event.preventDefault(); + closeSave(); + } + if (event.key !== "Enter") return; + event.preventDefault(); + void savePreset(); + }} + /> + + +
+
+ 0}> + Saved +
+ + {(preset) => ( +
+ + +
+ )} +
+
+
+
+
+ +
+ + } + label="Add" + title="Add a colour" + disabled={current().colorStops.length >= MAX_STOPS} + onClick={() => + addStop(largestGapPosition(current().colorStops)) + } + /> + } + > +
+
+
{ + if (event.button !== 0) return; + const position = barPosition(event); + if (position === null) return; + const index = addStop(position); + if (index !== null) dragStop(index, event); + }} + > +
+ + {(stop, index) => { + const selected = () => stopIndex() === index; + return ( + + ); + }} + +
+
+ + {(stop) => ( + <> +
+ + updateConfig((value) => { + const target = value.colorStops[stopIndex()]; + if (target) target.color = color; + }) + } + /> + + {Math.round(stop().position)}% + + +
+ { + const color = hexToRgb(hex); + if (!color) return; + updateConfig((value) => { + const target = value.colorStops[stopIndex()]; + if (target) + target.color = [color[0], color[1], color[2]]; + }); + }} + /> + + )} +
+
+ + +
+ + + {(control) => ( + +
+ controlValue(control(), value)} + aria-label="Motion speed" + onChange={([next]) => + updateConfig((value) => { + value[MOTION_KEY] = next; + }) + } + /> + + {controlValue(control(), current()[MOTION_KEY])} + +
+
+ )} +
+ +
+ + +
+ + Fine-tune + + + + } + label="Reset" + title="Reset fine-tune settings" + onClick={resetFineTune} + /> + +
+ +
+
+ + {(group) => ( + + )} + +
+
+ + {(control) => ( + + updateConfig((value) => { + value[control.key] = next; + }) + } + /> + )} + +
+
+
+
+
+ )} + + ); +} diff --git a/apps/desktop/src/routes/editor/ConfigSidebar.tsx b/apps/desktop/src/routes/editor/ConfigSidebar.tsx index 0f0ded8136e..59f0fb9c939 100644 --- a/apps/desktop/src/routes/editor/ConfigSidebar.tsx +++ b/apps/desktop/src/routes/editor/ConfigSidebar.tsx @@ -13,6 +13,7 @@ import { Tabs as KTabs } from "@kobalte/core/tabs"; import { createElementBounds } from "@solid-primitives/bounds"; import { createEventListenerMap } from "@solid-primitives/event-listener"; import { createWritableMemo } from "@solid-primitives/memo"; +import { createQuery } from "@tanstack/solid-query"; import { convertFileSrc } from "@tauri-apps/api/core"; import { appDataDir, resolveResource } from "@tauri-apps/api/path"; import { @@ -35,6 +36,7 @@ import { type JSX, lazy, on, + onCleanup, onMount, type ParentProps, Show, @@ -49,7 +51,7 @@ import gradientBg from "~/assets/illustrations/gradient.webp"; import imageBg from "~/assets/illustrations/image.webp"; import transparentBg from "~/assets/illustrations/transparent.webp"; import { Toggle } from "~/components/Toggle"; -import { generalSettingsStore } from "~/store"; +import { animatedGradientsStore, generalSettingsStore } from "~/store"; import { listSystemFonts } from "~/utils/fonts"; import { normalizeOpaqueHexColor } from "~/utils/hex-color"; import { @@ -58,6 +60,8 @@ import { type OrganizationBrandColorSwatch, } from "~/utils/organization-branding"; import { + type AnimatedGradientConfig, + type AnimatedGradientLibrary, type BackgroundBlurMode, type BackgroundSource, type CameraShape, @@ -104,6 +108,10 @@ import IconLucideType from "~icons/lucide/type"; import IconLucideVideo from "~icons/lucide/video"; import IconLucideVolume2 from "~icons/lucide/volume-2"; import IconLucideWind from "~icons/lucide/wind"; +import { + AnimatedGradientEditor, + copyAnimatedGradientConfig, +} from "./AnimatedGradientEditor"; import { AudioLibraryPanel } from "./AudioLibrary"; import { AUDIO_TRACK_BG_CLASS, @@ -223,6 +231,7 @@ const BACKGROUND_SOURCES = { image: "Image", color: "Color", gradient: "Gradient", + animatedGradient: "Animated", none: "None", } satisfies Record; @@ -231,6 +240,7 @@ const BACKGROUND_ICONS = { image: transparentBg, color: colorBg, gradient: gradientBg, + animatedGradient: gradientBg, } satisfies Record; const BACKGROUND_SOURCES_ROW_ONE = [ @@ -242,7 +252,7 @@ const BACKGROUND_SOURCES_ROW_ONE = [ const BACKGROUND_SOURCES_ROW_TWO = [ "color", "gradient", - "none", + "animatedGradient", ] satisfies Array; const BACKGROUND_IMAGE_ACCEPT = @@ -1800,13 +1810,86 @@ function BackgroundConfig(props: { createSignal( isNoneBackground() ? "none" : projectBackgroundSourceTab(), ); + const animatedGradientCatalog = createQuery(() => ({ + queryKey: ["animated-gradient-catalog"], + queryFn: () => commands.animatedGradientCatalog(), + staleTime: Number.POSITIVE_INFINITY, + })); + const animatedGradientLibrary = animatedGradientsStore.createQuery(); + let lastAnimatedGradient: AnimatedGradientConfig | null = + project.background.source.type === "animatedGradient" + ? copyAnimatedGradientConfig(project.background.source.config) + : null; + let pendingGradientPreference: Partial< + Pick + > | null = null; + let gradientPreferenceTimer: ReturnType | undefined; + let previousSourceType = project.background.source.type; + let previouslySelected = + previousSourceType === "animatedGradient" && + backgroundSourceTab() !== "none"; + + const flushGradientPreference = () => { + clearTimeout(gradientPreferenceTimer); + gradientPreferenceTimer = undefined; + const preference = pendingGradientPreference; + pendingGradientPreference = null; + if (!preference) return; + void animatedGradientsStore.set(preference).catch(() => { + toast.error("Could not remember your animated gradient settings"); + }); + }; + const gradientPreferenceFingerprint = createMemo(() => { + const source = project.background.source; + const value = + source.type === "wallpaper" || source.type === "image" + ? source.type + : JSON.stringify(source); + return `${backgroundSourceTab() === "none"}:${value}`; + }); + createEffect( + on( + gradientPreferenceFingerprint, + () => { + const source = project.background.source; + const selected = + source.type === "animatedGradient" && + backgroundSourceTab() !== "none"; + const selectionChanged = + previousSourceType !== source.type || previouslySelected !== selected; + previousSourceType = source.type; + previouslySelected = selected; + if (source.type === "animatedGradient") { + lastAnimatedGradient = copyAnimatedGradientConfig(source.config); + pendingGradientPreference = { + lastUsed: lastAnimatedGradient, + selected, + }; + } else { + pendingGradientPreference = { + ...pendingGradientPreference, + selected, + }; + } + clearTimeout(gradientPreferenceTimer); + if (selectionChanged) flushGradientPreference(); + else gradientPreferenceTimer = setTimeout(flushGradientPreference, 200); + }, + { defer: true }, + ), + ); + onCleanup(flushGradientPreference); // "None" is a sticky selection: nudging the padding/rounding sliders must not - // swap the panel back to the underlying source tab (that reflow moves the very - // slider being dragged), so only re-sync when the user isn't sitting on "None". + // reflow the panel under the slider. Undo/presets restoring an animated source + // must expose its controls again because None removes that source entirely. createEffect( on(projectBackgroundSourceTab, (tab) => { - if (backgroundSourceTab() !== "none") setBackgroundSourceTab(tab); + if ( + backgroundSourceTab() !== "none" || + (tab === "animatedGradient" && !isNoneBackground()) + ) + setBackgroundSourceTab(tab); }), ); @@ -2052,6 +2135,9 @@ function BackgroundConfig(props: { }; const renderBackgroundSourceIcon = (item: BackgroundSourceTab) => { + if (item === "animatedGradient") { + return ; + } if (item === "none") { return ; } @@ -2119,8 +2205,14 @@ function BackgroundConfig(props: { }) => ( @@ -2129,26 +2221,9 @@ function BackgroundConfig(props: { ); - const backgrounds: { - [K in BackgroundSource["type"]]: Extract; - } = { - wallpaper: { - type: "wallpaper", - path: null, - }, - image: { - type: "image", - path: null, - }, - color: { - type: "color", - value: DEFAULT_GRADIENT_FROM, - }, - gradient: { - type: "gradient", - from: DEFAULT_GRADIENT_FROM, - to: DEFAULT_GRADIENT_TO, - }, + let colorBackground: Extract = { + type: "color", + value: DEFAULT_GRADIENT_FROM, }; const setColorBackgroundSource = (color: string) => { @@ -2156,13 +2231,13 @@ function BackgroundConfig(props: { if (!rgbValue) return; const [r, g, b, a] = rgbValue; - backgrounds.color = { + colorBackground = { type: "color", value: [r, g, b], alpha: a, }; - setProject("background", "source", backgrounds.color); + setProject("background", "source", colorBackground); }; const setBackgroundBorderColor = (color: string) => { @@ -2188,15 +2263,38 @@ function BackgroundConfig(props: { value={backgroundSourceTab()} onChange={(v) => { const tab = v as BackgroundSourceTab; + if ( + tab === "animatedGradient" && + (!animatedGradientCatalog.data || + animatedGradientLibrary.isPending || + animatedGradientLibrary.isError) + ) + return; const fromNone = backgroundSourceTab() === "none"; - setBackgroundSourceTab(tab); if (tab === "none") { batch(() => { + const source = project.background.source; + if (source.type === "animatedGradient") { + lastAnimatedGradient = copyAnimatedGradientConfig( + source.config, + ); + pendingGradientPreference = { + lastUsed: lastAnimatedGradient, + selected: false, + }; + setProject("background", "source", { + type: "color", + value: [255, 255, 255], + alpha: 255, + }); + } + setBackgroundSourceTab(tab); setProject("background", "padding", 0); setProject("background", "rounding", 0); }); return; } + setBackgroundSourceTab(tab); if (tab === "desktop") { const desktopBackground = currentDesktopBackground(); if (desktopBackground) { @@ -2207,6 +2305,18 @@ function BackgroundConfig(props: { } ensureBackgroundPresentation(fromNone); switch (tab) { + case "animatedGradient": { + const config = + lastAnimatedGradient ?? + animatedGradientLibrary.data?.lastUsed ?? + animatedGradientCatalog.data?.defaultConfig; + if (!config) return; + setProject("background", "source", { + type: "animatedGradient", + config: copyAnimatedGradientConfig(config), + }); + break; + } case "image": { setProject("background", "source", { type: "image", @@ -2277,7 +2387,32 @@ function BackgroundConfig(props: { )}
+ + +
+ + Could not load animated gradients. + + +
+
{/** Dashed divider */}
@@ -2584,17 +2719,13 @@ function BackgroundConfig(props: { if (!rgbValue) return; const [r, g, b, a] = rgbValue; - backgrounds.color = { + colorBackground = { type: "color", value: [r, g, b], alpha: a, }; - setProject( - "background", - "source", - backgrounds.color, - ); + setProject("background", "source", colorBackground); }} />
+ + + diff --git a/apps/desktop/src/routes/screenshot-editor/popovers/BackgroundSettingsPopover.tsx b/apps/desktop/src/routes/screenshot-editor/popovers/BackgroundSettingsPopover.tsx index 4023be1224e..80a98437bd0 100644 --- a/apps/desktop/src/routes/screenshot-editor/popovers/BackgroundSettingsPopover.tsx +++ b/apps/desktop/src/routes/screenshot-editor/popovers/BackgroundSettingsPopover.tsx @@ -43,6 +43,7 @@ const BACKGROUND_SOURCES = { image: "Image", color: "Color", gradient: "Gradient", + animatedGradient: "Animated", } satisfies Record; const BACKGROUND_SOURCES_LIST = [ @@ -276,6 +277,8 @@ export function BackgroundSettingsPopover() { to: DEFAULT_GRADIENT_TO, }; break; + default: + return; } // Try to preserve existing if type matches @@ -554,6 +557,12 @@ export function BackgroundSettingsPopover() { }} + +

+ This screenshot uses a still frame of the animated gradient. + Choose another background above to replace it. +

+
diff --git a/apps/desktop/src/store.ts b/apps/desktop/src/store.ts index c2b30eb6c56..8de73239fc5 100644 --- a/apps/desktop/src/store.ts +++ b/apps/desktop/src/store.ts @@ -7,7 +7,9 @@ import { RECORDING_START_SAFETY_DEFAULTS, type RecordingStartSafetySettings, } from "~/utils/general-settings"; +import { createSerializedStore } from "~/utils/serialized-store"; import type { + AnimatedGradientLibrary, AuthStore, HotkeysStore, PresetsStore, @@ -101,6 +103,22 @@ function declareStore(name: string, defaults?: T) { } export const presetsStore = declareStore("presets"); +const animatedGradientDefaults: AnimatedGradientLibrary = { + presets: [], + lastUsed: null, + selected: false, +}; +const animatedGradientStore = declareStore( + "animated_gradients", + animatedGradientDefaults, +); +export const animatedGradientsStore = { + ...animatedGradientStore, + ...createSerializedStore( + animatedGradientStore, + animatedGradientDefaults, + ), +}; export const authStore = declareStore("auth"); export const automationsStore = declareStore("automations"); export const userProfileStore = declareStore("user_profile"); diff --git a/apps/desktop/src/utils/serialized-store.test.ts b/apps/desktop/src/utils/serialized-store.test.ts new file mode 100644 index 00000000000..d6a23e57849 --- /dev/null +++ b/apps/desktop/src/utils/serialized-store.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from "vitest"; +import { createSerializedStore } from "./serialized-store"; + +type Library = { + presets: { id: string; name: string }[]; + lastUsed: { seed: number } | null; + selected: boolean; +}; + +const defaults: Library = { presets: [], lastUsed: null, selected: false }; + +function createFakeStore(initial?: Library) { + let value = initial; + let activeWrites = 0; + let maxActiveWrites = 0; + let failNextWrite = false; + const store = { + get: async () => { + await Promise.resolve(); + return value ? structuredClone(value) : undefined; + }, + set: async (next: Library) => { + activeWrites += 1; + maxActiveWrites = Math.max(maxActiveWrites, activeWrites); + await Promise.resolve(); + activeWrites -= 1; + if (failNextWrite) { + failNextWrite = false; + throw new Error("Write failed"); + } + value = structuredClone(next); + }, + }; + return { + store, + value: () => value, + maxActiveWrites: () => maxActiveWrites, + failNextWrite: () => { + failNextWrite = true; + }, + }; +} + +describe("createSerializedStore", () => { + it("merges queued preferences without dropping saved presets", async () => { + const fake = createFakeStore(); + const store = createSerializedStore(fake.store, defaults); + const preset = { id: "first", name: "First" }; + + await Promise.all([ + store.set({ selected: true }), + store.update((current) => ({ + ...current, + presets: [...current.presets, preset], + })), + store.set({ lastUsed: { seed: 17 } }), + ]); + + expect(fake.value()).toEqual({ + presets: [preset], + lastUsed: { seed: 17 }, + selected: true, + }); + expect(fake.maxActiveWrites()).toBe(1); + }); + + it("applies preset additions and deletion to the latest queued state", async () => { + const fake = createFakeStore({ ...defaults, selected: true }); + const store = createSerializedStore(fake.store, defaults); + const add = (id: string) => + store.update((current) => ({ + ...current, + presets: [...current.presets, { id, name: id }], + })); + + await Promise.all([ + add("first"), + add("second"), + store.update((current) => ({ + ...current, + presets: current.presets.filter((preset) => preset.id !== "first"), + })), + store.set({ lastUsed: { seed: 42 } }), + ]); + + expect(await store.get()).toEqual({ + presets: [{ id: "second", name: "second" }], + lastUsed: { seed: 42 }, + selected: true, + }); + }); + + it("keeps the queue usable after a failed write", async () => { + const fake = createFakeStore(); + const store = createSerializedStore(fake.store, defaults); + fake.failNextWrite(); + + const failed = store.set({ selected: true }); + const recovered = store.set({ lastUsed: { seed: 9 } }); + await expect(failed).rejects.toThrow("Write failed"); + await recovered; + await store.flush(); + + expect(fake.value()).toEqual({ ...defaults, lastUsed: { seed: 9 } }); + }); + + it("does not write rejected updates and preserves ordering after rejection", async () => { + const fake = createFakeStore(); + const store = createSerializedStore(fake.store, defaults); + const rejected = store.update(() => { + throw new Error("Preset limit reached"); + }); + const accepted = store.set({ selected: true }); + + await expect(rejected).rejects.toThrow("Preset limit reached"); + await accepted; + expect(await store.get()).toEqual({ ...defaults, selected: true }); + }); + + it("waits for queued asynchronous updates before reading", async () => { + const fake = createFakeStore(); + const store = createSerializedStore(fake.store, defaults); + const written = store.update(async (current) => { + await Promise.resolve(); + return { ...current, selected: true }; + }); + const read = store.get(); + + await expect(read).resolves.toEqual({ ...defaults, selected: true }); + await written; + }); + + it("isolates defaults from an update that mutates then fails", async () => { + const fake = createFakeStore(); + const initial: Library = { presets: [], lastUsed: null, selected: false }; + const store = createSerializedStore(fake.store, initial); + await expect( + store.update((current) => { + current.presets.push({ id: "unsaved", name: "Unsaved" }); + throw new Error("Invalid preset"); + }), + ).rejects.toThrow("Invalid preset"); + + expect(initial.presets).toEqual([]); + expect(await store.get()).toEqual(defaults); + }); +}); diff --git a/apps/desktop/src/utils/serialized-store.ts b/apps/desktop/src/utils/serialized-store.ts new file mode 100644 index 00000000000..a54ed91badc --- /dev/null +++ b/apps/desktop/src/utils/serialized-store.ts @@ -0,0 +1,35 @@ +export function createSerializedStore( + store: { + get(): Promise; + set(value: T): Promise; + }, + defaults: T, +) { + let pending = Promise.resolve(); + + const enqueue = (action: () => Promise) => { + const result = pending.then(action); + pending = result.then( + () => undefined, + () => undefined, + ); + return result; + }; + + const read = async () => + structuredClone({ ...defaults, ...(await store.get()) }); + const update = (change: (current: T) => T | Promise) => + enqueue(async () => { + const current = await read(); + const next = await change(current); + await store.set(next); + return next; + }); + + return { + get: () => enqueue(read), + set: (value: Partial) => update((current) => ({ ...current, ...value })), + update, + flush: () => pending, + }; +} diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index dbb2a5a7538..82300820c71 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -5,6 +5,12 @@ export const commands = { +async animatedGradientCatalog() : Promise { + return await TAURI_INVOKE("animated_gradient_catalog"); +}, +async randomAnimatedGradient() : Promise { + return await TAURI_INVOKE("random_animated_gradient"); +}, async setMicInput(label: string | null) : Promise { return await TAURI_INVOKE("set_mic_input", { label }); }, @@ -591,6 +597,13 @@ videoImportProgress: "video-import-progress" /** user-defined types **/ export type Action = { type: "copyToClipboard"; source?: ClipboardSource } | { type: "saveToLocation"; dir: string; filenameTemplate?: string | null } | { type: "export"; profile: ExportProfile; destination?: ExportDestination } | { type: "upload"; organizationId?: string | null; copyLink?: boolean; openInBrowser?: boolean } | { type: "revealInFileManager" } | { type: "openFile" } | { type: "runCommand"; program: string; args?: string[]; cwd?: string | null; env?: { [key in string]: string }; useShell?: boolean } | { type: "webhook"; url: string; method?: string; headers?: { [key in string]: string }; bodyTemplate?: string | null } | { type: "recognizeTextToClipboard" } | { type: "notify"; titleTemplate?: string; bodyTemplate?: string } | { type: "openEditor" } | { type: "skipEditor" } | { type: "applyPreset"; name: string } | { type: "deleteLocalFiles" } +export type AnimatedGradientCatalog = { defaultConfig: AnimatedGradientConfig; templates: AnimatedGradientPreset[]; controls: AnimatedGradientControl[] } +export type AnimatedGradientConfig = { colorStops: AnimatedGradientStop[]; direction: number; flowScale: number; flowStrength: number; curvature: number; detail: number; relief: number; light: number; shade: number; ripples: number; grainAmount: number; grainSize: number; exposure: number; contrast: number; vibrance: number; motionSpeed: number; seed: number } +export type AnimatedGradientControl = { key: AnimatedGradientParameter; label: string; group: string; min: number; max: number; step: number } +export type AnimatedGradientLibrary = { presets: AnimatedGradientPreset[]; lastUsed: AnimatedGradientConfig | null; selected: boolean } +export type AnimatedGradientParameter = "direction" | "flowScale" | "flowStrength" | "curvature" | "detail" | "relief" | "light" | "shade" | "ripples" | "grainAmount" | "grainSize" | "exposure" | "contrast" | "vibrance" | "motionSpeed" +export type AnimatedGradientPreset = { id: string; name: string; config: AnimatedGradientConfig } +export type AnimatedGradientStop = { color: [number, number, number]; position: number } export type Annotation = { id: string; type: AnnotationType; x: number; y: number; width: number; height: number; strokeColor: string; strokeWidth: number; fillColor: string; opacity: number; rotation: number; text: string | null; maskType?: MaskType | null; maskLevel?: number | null; points?: ([number, number])[] | null } export type AnnotationType = "arrow" | "circle" | "rectangle" | "text" | "mask" | "draw" export type AppTheme = "system" | "light" | "dark" @@ -669,7 +682,7 @@ frame: FrameConfiguration | null; * something the capture really did hide, and the two are independent. */ notch: NotchConfiguration | null } -export type BackgroundSource = { type: "wallpaper"; path: string | null } | { type: "image"; path: string | null } | { type: "color"; value: [number, number, number]; alpha?: number } | { type: "gradient"; from: [number, number, number]; to: [number, number, number]; angle?: number; noise_intensity?: number | null; noise_scale?: number | null; animated?: boolean | null; animation_speed?: number | null } +export type BackgroundSource = { type: "wallpaper"; path: string | null } | { type: "image"; path: string | null } | { type: "color"; value: [number, number, number]; alpha?: number } | { type: "gradient"; from: [number, number, number]; to: [number, number, number]; angle?: number; noise_intensity?: number | null; noise_scale?: number | null; animated?: boolean | null; animation_speed?: number | null } | { type: "animatedGradient"; config: AnimatedGradientConfig } export type BorderConfiguration = { enabled: boolean; width: number; color: [number, number, number]; opacity: number } export type Camera = { hide: boolean; mirror: boolean; position: CameraPosition; /** diff --git a/crates/project/src/animated_gradient.rs b/crates/project/src/animated_gradient.rs new file mode 100644 index 00000000000..f747035abc3 --- /dev/null +++ b/crates/project/src/animated_gradient.rs @@ -0,0 +1,572 @@ +use serde::{Deserialize, Serialize}; +use specta::Type; + +use crate::Color; + +#[derive(Type, Serialize, Deserialize, Clone, Copy, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AnimatedGradientStop { + pub color: Color, + pub position: f32, +} + +#[derive(Type, Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(default, rename_all = "camelCase")] +pub struct AnimatedGradientConfig { + pub color_stops: Vec, + pub direction: f32, + pub flow_scale: f32, + pub flow_strength: f32, + pub curvature: f32, + pub detail: f32, + pub relief: f32, + pub light: f32, + pub shade: f32, + pub ripples: f32, + pub grain_amount: f32, + pub grain_size: f32, + pub exposure: f32, + pub contrast: f32, + pub vibrance: f32, + pub motion_speed: f32, + pub seed: u32, +} + +impl Default for AnimatedGradientConfig { + fn default() -> Self { + Self { + color_stops: stops([0xff6b35, 0xf7c59f, 0xe891b9, 0x2e4057, 0x1a1a2e]), + direction: 45.0, + flow_scale: 2.0, + flow_strength: 55.0, + curvature: 70.0, + detail: 2.0, + relief: 60.0, + light: 50.0, + shade: 55.0, + ripples: 60.0, + grain_amount: 8.0, + grain_size: 1.0, + exposure: 0.0, + contrast: 100.0, + vibrance: 100.0, + motion_speed: 30.0, + seed: 0, + } + } +} + +macro_rules! parameters { + ($(($variant:ident, $field:ident, $label:literal, $group:literal, $min:expr, $max:expr, $step:expr)),+ $(,)?) => { + #[derive(Type, Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Hash)] + #[serde(rename_all = "camelCase")] + pub enum AnimatedGradientParameter { + $($variant),+ + } + + impl AnimatedGradientParameter { + pub const ALL: &'static [Self] = &[$(Self::$variant),+]; + + pub fn control(self) -> AnimatedGradientControl { + let (label, group, min, max, step) = match self { + $(Self::$variant => ($label, $group, $min, $max, $step)),+ + }; + AnimatedGradientControl { + key: self, + label: label.into(), + group: group.into(), + min, + max, + step, + } + } + + pub fn get(self, config: &AnimatedGradientConfig) -> f32 { + match self { + $(Self::$variant => config.$field),+ + } + } + + pub fn set(self, config: &mut AnimatedGradientConfig, value: f32) { + let control = self.control(); + let value = if value.is_finite() { + value.clamp(control.min, control.max) + } else { + self.get(&AnimatedGradientConfig::default()) + }; + let value = (value / control.step).round() * control.step; + match self { + $(Self::$variant => config.$field = value),+ + } + } + } + }; +} + +parameters![ + (Direction, direction, "Direction", "Flow", 0.0, 360.0, 1.0), + (FlowScale, flow_scale, "Flow Scale", "Flow", 0.5, 5.0, 0.1), + ( + FlowStrength, + flow_strength, + "Flow Strength", + "Flow", + 0.0, + 100.0, + 1.0 + ), + (Curvature, curvature, "Curvature", "Flow", 0.0, 100.0, 1.0), + (Detail, detail, "Detail", "Flow", 1.0, 6.0, 1.0), + (Relief, relief, "Relief", "Lighting", 0.0, 100.0, 1.0), + (Light, light, "Highlights", "Lighting", 0.0, 100.0, 1.0), + (Shade, shade, "Shading", "Lighting", 0.0, 100.0, 1.0), + ( + Ripples, + ripples, + "Ripple Size", + "Lighting", + 10.0, + 100.0, + 1.0 + ), + ( + GrainAmount, + grain_amount, + "Grain Amount", + "Texture", + 0.0, + 30.0, + 1.0 + ), + ( + GrainSize, + grain_size, + "Grain Size", + "Texture", + 0.5, + 3.0, + 0.1 + ), + (Exposure, exposure, "Exposure", "Colour", -50.0, 50.0, 1.0), + (Contrast, contrast, "Contrast", "Colour", 50.0, 200.0, 1.0), + (Vibrance, vibrance, "Vibrance", "Colour", 0.0, 200.0, 1.0), + ( + MotionSpeed, + motion_speed, + "Motion Speed", + "Animation", + 0.0, + 100.0, + 1.0 + ), +]; + +#[derive(Type, Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct AnimatedGradientControl { + pub key: AnimatedGradientParameter, + pub label: String, + pub group: String, + pub min: f32, + pub max: f32, + pub step: f32, +} + +impl AnimatedGradientConfig { + pub fn normalized(&self) -> Self { + let mut config = self.clone(); + for parameter in AnimatedGradientParameter::ALL { + parameter.set(&mut config, parameter.get(self)); + } + config.color_stops.truncate(5); + if config.color_stops.len() < 2 { + config.color_stops = Self::default().color_stops; + } + for stop in &mut config.color_stops { + stop.color = stop.color.map(|channel| channel.min(255)); + stop.position = if stop.position.is_finite() { + stop.position.clamp(0.0, 100.0) + } else { + 0.0 + }; + } + config + .color_stops + .sort_by(|a, b| a.position.total_cmp(&b.position)); + config + } + + pub fn random() -> Self { + let bytes = uuid::Uuid::new_v4().into_bytes(); + Self::from_seed(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])) + } + + pub fn from_seed(seed: u32) -> Self { + let mut random = GradientRandom(u64::from(seed)); + let hue = random.range(0.0, 360.0); + let span = random.range(65.0, 260.0); + let count = 3 + (random.next() % 3) as usize; + let color_stops = (0..count) + .map(|index| { + let t = index as f32 / (count - 1) as f32; + let h = (hue + span * t + random.range(-12.0, 12.0)).rem_euclid(360.0); + let s = random.range(0.55, 0.92); + let l = (0.76 - t * 0.55 + random.range(-0.08, 0.08)).clamp(0.12, 0.88); + AnimatedGradientStop { + color: hsl_color(h, s, l), + position: if index == 0 || index == count - 1 { + t * 100.0 + } else { + t * 100.0 + random.range(-7.0, 7.0) + }, + } + }) + .collect(); + Self { + color_stops, + direction: random.range(0.0, 360.0), + flow_scale: random.range(0.7, 3.2), + flow_strength: random.range(25.0, 75.0), + curvature: random.range(30.0, 90.0), + detail: random.range(1.0, 4.0), + relief: random.range(30.0, 85.0), + light: random.range(20.0, 75.0), + shade: random.range(25.0, 80.0), + ripples: random.range(30.0, 90.0), + grain_amount: random.range(3.0, 15.0), + grain_size: random.range(0.6, 2.0), + exposure: random.range(-8.0, 8.0), + contrast: random.range(90.0, 125.0), + vibrance: random.range(85.0, 130.0), + motion_speed: random.range(20.0, 65.0), + seed, + } + .normalized() + } +} + +pub(crate) fn deserialize_config<'de, D>( + deserializer: D, +) -> Result +where + D: serde::Deserializer<'de>, +{ + AnimatedGradientConfig::deserialize(deserializer).map(|config| config.normalized()) +} + +fn deserialize_optional_config<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + Option::::deserialize(deserializer) + .map(|config| config.map(|config| config.normalized())) +} + +struct GradientRandom(u64); + +impl GradientRandom { + fn next(&mut self) -> u32 { + self.0 = self.0.wrapping_add(0x9e3779b97f4a7c15); + let mut value = self.0; + value = (value ^ (value >> 30)).wrapping_mul(0xbf58476d1ce4e5b9); + value = (value ^ (value >> 27)).wrapping_mul(0x94d049bb133111eb); + (value ^ (value >> 31)) as u32 + } + + fn range(&mut self, min: f32, max: f32) -> f32 { + min + (max - min) * (self.next() as f64 / u32::MAX as f64) as f32 + } +} + +fn hsl_color(hue: f32, saturation: f32, lightness: f32) -> Color { + let amplitude = saturation * lightness.min(1.0 - lightness); + [0.0, 8.0, 4.0].map(|n| { + let k = (n + hue / 30.0) % 12.0; + ((lightness - amplitude * (k - 3.0).min(9.0 - k).clamp(-1.0, 1.0)) * 255.0).round() as u16 + }) +} + +fn stops(palette: [u32; 5]) -> Vec { + palette + .into_iter() + .enumerate() + .map(|(index, color)| AnimatedGradientStop { + color: [ + ((color >> 16) & 255) as u16, + ((color >> 8) & 255) as u16, + (color & 255) as u16, + ], + position: index as f32 * 25.0, + }) + .collect() +} + +#[derive(Type, Serialize, Deserialize, Clone, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AnimatedGradientPreset { + pub id: String, + pub name: String, + #[serde(deserialize_with = "deserialize_config")] + pub config: AnimatedGradientConfig, +} + +#[derive(Type, Serialize, Deserialize, Clone, Debug, Default, PartialEq)] +#[serde(default, rename_all = "camelCase")] +pub struct AnimatedGradientLibrary { + pub presets: Vec, + #[serde(deserialize_with = "deserialize_optional_config")] + pub last_used: Option, + pub selected: bool, +} + +impl AnimatedGradientLibrary { + pub fn save_preset(&mut self, name: &str, config: &AnimatedGradientConfig) -> Option { + let name = name.trim(); + if name.is_empty() || self.presets.len() >= 100 { + return None; + } + let id = uuid::Uuid::new_v4().to_string(); + self.presets.push(AnimatedGradientPreset { + id: id.clone(), + name: name.chars().take(80).collect(), + config: config.normalized(), + }); + Some(id) + } +} + +#[derive(Type, Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct AnimatedGradientCatalog { + pub default_config: AnimatedGradientConfig, + pub templates: Vec, + pub controls: Vec, +} + +pub fn animated_gradient_catalog() -> AnimatedGradientCatalog { + let palettes = [ + ( + "Afterglow", + [0xff6b35, 0xf7c59f, 0xe891b9, 0x2e4057, 0x1a1a2e], + ), + ("Tidal", [0x081c35, 0x165b83, 0x51b6d6, 0xc7eef2, 0xffd49b]), + ( + "Northern Lights", + [0x091322, 0x155450, 0x3ac8a0, 0xa4eed4, 0x7154b8], + ), + ( + "Electric Iris", + [0x23085d, 0x4737d7, 0x8b62f1, 0xee98d2, 0xffcfdf], + ), + ( + "Rose Quartz", + [0x542c56, 0xa65d88, 0xea9cae, 0xffd2c2, 0xffead5], + ), + ( + "Solar Flare", + [0x260c23, 0x971c49, 0xef5a43, 0xffad58, 0xffe7a3], + ), + ( + "Glacier", + [0xf0ffff, 0xbce9fa, 0x63b9de, 0x27628c, 0x11253e], + ), + ( + "Jade Silk", + [0x102f2e, 0x286854, 0x70af83, 0xc2dca6, 0xf8efd5], + ), + ( + "Moonstone", + [0x181a36, 0x515470, 0x9195b1, 0xd1c7d7, 0xffe6dd], + ), + ( + "Hot Pink", + [0x21134b, 0x612585, 0xc33599, 0xfa77b1, 0xffced9], + ), + ( + "Desert Glass", + [0x263a42, 0x737b76, 0xd1ad84, 0xf3d3a7, 0xfaeee0], + ), + ( + "Deep Current", + [0x040c23, 0x152a64, 0x265e9f, 0x44afb0, 0xb0ead2], + ), + ]; + AnimatedGradientCatalog { + default_config: AnimatedGradientConfig::default(), + templates: palettes + .into_iter() + .enumerate() + .map(|(index, (name, palette))| AnimatedGradientPreset { + id: format!("template-{index}"), + name: name.into(), + config: AnimatedGradientConfig { + color_stops: stops(palette), + direction: (45.0 + index as f32 * 29.0) % 360.0, + flow_scale: 1.3 + (index % 4) as f32 * 0.4, + curvature: 45.0 + (index % 3) as f32 * 18.0, + relief: 40.0 + (index % 4) as f32 * 13.0, + seed: index as u32 * 137, + ..Default::default() + } + .normalized(), + }) + .collect(), + controls: AnimatedGradientParameter::ALL + .iter() + .map(|key| key.control()) + .collect(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_and_library_round_trip() { + let config: AnimatedGradientConfig = serde_json::from_str("{}").unwrap(); + assert_eq!(config, AnimatedGradientConfig::default()); + assert_eq!( + serde_json::from_str::("{}").unwrap(), + AnimatedGradientLibrary::default() + ); + let mut library = AnimatedGradientLibrary { + last_used: Some(config.clone()), + selected: true, + ..Default::default() + }; + assert!(library.save_preset(" My gradient ", &config).is_some()); + assert!(library.save_preset(" ", &config).is_none()); + assert_eq!(library.presets[0].name, "My gradient"); + let json = serde_json::to_string(&library).unwrap(); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + library + ); + } + + #[test] + fn persisted_backgrounds_and_presets_are_normalized_before_editing() { + let invalid = serde_json::json!({ + "colorStops": [ + {"color": [999, 255, 0], "position": 120}, + {"color": [0, 0, 255], "position": -20} + ], + "detail": 2.4, + "flowScale": 0, + "motionSpeed": 900 + }); + let source: crate::BackgroundSource = serde_json::from_value(serde_json::json!({ + "type": "animatedGradient", "config": invalid + })) + .unwrap(); + let crate::BackgroundSource::AnimatedGradient { config } = source else { + panic!("Expected animated gradient"); + }; + assert_eq!(config, config.normalized()); + assert_eq!(config.color_stops[0].position, 0.0); + assert_eq!(config.color_stops[1].position, 100.0); + assert_eq!(config.color_stops[1].color, [255, 255, 0]); + let library: AnimatedGradientLibrary = serde_json::from_value(serde_json::json!({ + "presets": [{"id": "test", "name": "Test", "config": invalid}], + "lastUsed": invalid, + "selected": true + })) + .unwrap(); + assert_eq!(library.last_used.as_ref(), Some(&config)); + assert_eq!(library.presets[0].config, config); + } + + #[test] + fn background_variant_round_trips_without_changing_legacy_defaults() { + let source: crate::BackgroundSource = + serde_json::from_str(r#"{"type":"animatedGradient","config":{}}"#).unwrap(); + let crate::BackgroundSource::AnimatedGradient { config } = &source else { + panic!("Expected animated gradient"); + }; + assert_eq!(config, &AnimatedGradientConfig::default()); + let json = serde_json::to_value(&source).unwrap(); + assert_eq!(json["type"], "animatedGradient"); + let round_trip: crate::BackgroundSource = serde_json::from_value(json).unwrap(); + assert_eq!( + serde_json::to_value(round_trip).unwrap(), + serde_json::to_value(source).unwrap() + ); + let legacy: crate::BackgroundSource = + serde_json::from_str(r#"{"type":"gradient","from":[0,0,0],"to":[255,255,255]}"#) + .unwrap(); + assert!(matches!( + legacy, + crate::BackgroundSource::Gradient { + angle: 90, + animated: None, + .. + } + )); + assert!(matches!( + crate::BackgroundSource::default(), + crate::BackgroundSource::Color { + value: [255, 255, 255], + alpha: 255 + } + )); + } + + #[test] + fn invalid_settings_are_bounded_and_stops_sorted() { + let mut config = AnimatedGradientConfig { + color_stops: vec![ + AnimatedGradientStop { + color: [800, 20, 30], + position: 150.0, + }, + AnimatedGradientStop { + color: [10, 20, 30], + position: -15.0, + }, + ], + flow_scale: f32::NAN, + grain_size: 0.0, + detail: 1000.0, + motion_speed: -40.0, + ..Default::default() + } + .normalized(); + assert_eq!(config.flow_scale, 2.0); + assert_eq!(config.grain_size, 0.5); + assert_eq!(config.detail, 6.0); + assert_eq!(config.motion_speed, 0.0); + assert_eq!(config.color_stops[0].position, 0.0); + assert_eq!(config.color_stops[1].color[0], 255); + config.color_stops.clear(); + assert_eq!(config.normalized().color_stops.len(), 5); + } + + #[test] + fn seeded_randomizer_is_reproducible_and_varied() { + let mut results = std::collections::HashSet::new(); + for seed in 0..256 { + let config = AnimatedGradientConfig::from_seed(seed); + assert_eq!(config, AnimatedGradientConfig::from_seed(seed)); + assert_eq!(config, config.normalized()); + assert!((3..=5).contains(&config.color_stops.len())); + assert!(results.insert(serde_json::to_string(&config).unwrap())); + } + } + + #[test] + fn templates_and_control_keys_match_the_serialized_model() { + let catalog = animated_gradient_catalog(); + assert_eq!(catalog.templates.len(), 12); + for template in catalog.templates { + assert_eq!(template.config, template.config.normalized()); + } + let config = serde_json::to_value(AnimatedGradientConfig::default()).unwrap(); + for control in catalog.controls { + let key = serde_json::to_value(control.key).unwrap(); + assert!(config.get(key.as_str().unwrap()).unwrap().is_number()); + } + } +} diff --git a/crates/project/src/configuration.rs b/crates/project/src/configuration.rs index 078c6aa6bab..83c710b0005 100644 --- a/crates/project/src/configuration.rs +++ b/crates/project/src/configuration.rs @@ -53,6 +53,10 @@ pub enum BackgroundSource { #[serde(default)] animation_speed: Option, }, + AnimatedGradient { + #[serde(deserialize_with = "crate::animated_gradient::deserialize_config")] + config: crate::AnimatedGradientConfig, + }, } fn default_gradient_angle() -> u16 { diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index ec10fd37063..0e09fde35e6 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -1,8 +1,10 @@ +mod animated_gradient; mod configuration; pub mod cursor; pub mod keyboard; mod meta; +pub use animated_gradient::*; pub use configuration::*; pub use cursor::*; pub use keyboard::*; diff --git a/crates/rendering-skia/src/layers/background.rs b/crates/rendering-skia/src/layers/background.rs index 0db3e137e81..345d9c4da43 100644 --- a/crates/rendering-skia/src/layers/background.rs +++ b/crates/rendering-skia/src/layers/background.rs @@ -30,6 +30,14 @@ impl From for Background { BackgroundSource::Gradient { from, to, angle, .. } => Background::Gradient { from, to, angle }, + BackgroundSource::AnimatedGradient { config } => { + let config = config.normalized(); + Background::Gradient { + from: config.color_stops[0].color, + to: config.color_stops[config.color_stops.len() - 1].color, + angle: config.direction.round() as u16, + } + } BackgroundSource::Image { path } => { if let Some(path) = path { Background::Image { @@ -326,6 +334,21 @@ impl Default for BackgroundLayer { mod tests { use super::*; + #[test] + fn animated_gradient_has_a_static_palette_fallback() { + let source = BackgroundSource::AnimatedGradient { + config: cap_project::AnimatedGradientConfig::default(), + }; + assert!(matches!( + Background::from(source), + Background::Gradient { + from: [255, 107, 53], + to: [26, 26, 46], + angle: 45 + } + )); + } + #[test] fn test_background_from_source() { // Test color conversion diff --git a/crates/rendering/examples/animated-gradient-backgrounds.json b/crates/rendering/examples/animated-gradient-backgrounds.json new file mode 100644 index 00000000000..8cac353241a --- /dev/null +++ b/crates/rendering/examples/animated-gradient-backgrounds.json @@ -0,0 +1,20 @@ +[ + { + "name": "animated-default", + "source": { "type": "animatedGradient", "config": {} } + }, + { + "name": "animated-detailed", + "source": { + "type": "animatedGradient", + "config": { "detail": 6, "flowScale": 5, "grainAmount": 30 } + } + }, + { + "name": "animated-frozen", + "source": { + "type": "animatedGradient", + "config": { "motionSpeed": 0 } + } + } +] diff --git a/crates/rendering/examples/background-benchmark.rs b/crates/rendering/examples/background-benchmark.rs new file mode 100644 index 00000000000..b395da879d0 --- /dev/null +++ b/crates/rendering/examples/background-benchmark.rs @@ -0,0 +1,291 @@ +use anyhow::{Context, Result, ensure}; +use cap_project::{ + AspectRatio, BackgroundSource, CursorEvents, ProjectConfiguration, RecordingMeta, + RecordingMetaInner, SingleSegment, StudioRecordingMeta, VideoMeta, XY, +}; +use cap_rendering::{ + DecodedSegmentFrames, FrameRenderer, ProjectUniforms, RenderOptions, RenderVideoConstants, + RendererLayers, ZoomTransformTimeline, decoder::DecodedFrame, +}; +use clap::Parser; +use serde::{Deserialize, Serialize}; +use std::{path::PathBuf, sync::Arc, time::Instant}; + +#[derive(Parser)] +struct Args { + #[arg(long, default_value_t = 1920)] + width: u32, + #[arg(long, default_value_t = 1080)] + height: u32, + #[arg(long, default_value_t = 180)] + frames: u32, + #[arg(long, default_value_t = 20)] + warmup: u32, + #[arg(long, default_value_t = 3)] + repetitions: u32, + #[arg(long)] + backgrounds: Option, + #[arg(long)] + snapshots: Option, +} + +#[derive(Deserialize)] +struct Case { + name: String, + source: BackgroundSource, +} + +#[derive(Clone, Copy, Serialize)] +#[serde(rename_all = "kebab-case")] +enum Mode { + PlaybackRgba, + #[cfg(target_os = "macos")] + PlaybackBgraSurface, + ExportNv12, +} + +#[derive(Serialize)] +struct Measurement<'a> { + background: &'a str, + mode: Mode, + repetition: u32, + width: u32, + height: u32, + received: usize, + mean_ms: f64, + p50_ms: f64, + p95_ms: f64, + max_ms: f64, +} + +fn percentile(samples: &[f64], fraction: f64) -> f64 { + samples[((samples.len() - 1) as f64 * fraction).round() as usize] +} + +fn default_cases() -> Vec { + vec![ + Case { + name: "solid".into(), + source: BackgroundSource::Color { + value: [33, 40, 66], + alpha: 255, + }, + }, + Case { + name: "static-gradient".into(), + source: BackgroundSource::Gradient { + from: [31, 54, 190], + to: [237, 116, 194], + angle: 45, + noise_intensity: None, + noise_scale: None, + animated: None, + animation_speed: None, + }, + }, + ] +} + +fn segment(source: &DecodedFrame, frame: u32) -> DecodedSegmentFrames { + DecodedSegmentFrames { + screen_frame: Some(source.clone()), + camera_frame: None, + segment_time: frame as f32 / 60.0, + recording_time: frame as f32 / 60.0, + segment_has_camera: false, + } +} + +#[tokio::main] +async fn main() -> Result<()> { + let args = Args::parse(); + ensure!(args.frames > 0 && args.repetitions > 0); + ensure!(args.width > 0 && args.height > 0); + let mut cases = default_cases(); + if let Some(path) = &args.backgrounds { + cases.extend(serde_json::from_slice::>(&std::fs::read(path)?)?); + } + let meta = StudioRecordingMeta::SingleSegment { + segment: SingleSegment { + display: VideoMeta { + path: "synthetic.png".into(), + fps: 60, + start_time: Some(0.0), + device_id: None, + }, + camera: None, + audio: None, + cursor: None, + }, + }; + let recording_meta = RecordingMeta { + platform: None, + project_path: PathBuf::new(), + pretty_name: "Background benchmark".into(), + sharing: None, + inner: RecordingMetaInner::Studio(Box::new(meta.clone())), + upload: None, + }; + let screen_size = XY::new(1280, 720); + let constants = RenderVideoConstants::new_with_options( + RenderOptions { + screen_size, + camera_size: None, + preserve_screen_alpha: false, + }, + recording_meta, + meta, + ) + .await?; + eprintln!( + "adapter={} software={} dimensions={}x{} frames={} warmup={} repetitions={}", + constants.adapter_name(), + constants.is_software_adapter, + args.width, + args.height, + args.frames, + args.warmup, + args.repetitions, + ); + let mut pixels = vec![0; (screen_size.x * screen_size.y * 4) as usize]; + for (i, pixel) in pixels.chunks_exact_mut(4).enumerate() { + pixel.copy_from_slice(&[ + 30 + (i % screen_size.x as usize / 16) as u8, + 40 + (i / screen_size.x as usize / 8) as u8, + 80, + 255, + ]); + } + let source = DecodedFrame::new_with_arc(Arc::new(pixels), screen_size.x, screen_size.y); + let cursor = CursorEvents::default(); + let modes = [ + Mode::PlaybackRgba, + #[cfg(target_os = "macos")] + Mode::PlaybackBgraSurface, + Mode::ExportNv12, + ]; + for repetition in 0..args.repetitions { + for offset in 0..cases.len() { + let case = &cases[(offset + repetition as usize) % cases.len()]; + let mut project = ProjectConfiguration { + aspect_ratio: Some(AspectRatio::Wide), + ..Default::default() + }; + project.background.source = case.source.clone(); + project.background.padding = 20.0; + let duration = (args.frames + args.warmup) as f64 / 60.0; + let mut zoom = + ZoomTransformTimeline::from_project(&project, &cursor, duration, screen_size); + zoom.ensure_precomputed_until(duration as f32); + for mode in modes { + let mut renderer = FrameRenderer::new(&constants); + #[cfg(target_os = "macos")] + renderer.enable_nv12_surface_output(); + let mut layers = RendererLayers::new_with_options( + &constants.device, + &constants.queue, + constants.is_software_adapter, + ); + let mut samples = Vec::new(); + for frame_number in 0..args.frames + args.warmup { + let frame = segment(&source, frame_number); + let uniforms = ProjectUniforms::new( + &constants, + &project, + frame_number, + 60, + XY::new(args.width, args.height), + &cursor, + &frame, + duration, + &zoom, + ); + let start = Instant::now(); + let received_number = match mode { + Mode::PlaybackRgba => { + let output = renderer + .render_immediate(frame, uniforms, &cursor, true, &mut layers) + .await?; + std::hint::black_box(&output.data); + output.frame_number + } + #[cfg(target_os = "macos")] + Mode::PlaybackBgraSurface => { + let output = renderer + .render_immediate_bgra_surface( + frame, + uniforms, + &cursor, + true, + &mut layers, + ) + .await?; + std::hint::black_box(&output.pixel_buffer); + output.frame_number + } + Mode::ExportNv12 => { + let output = renderer + .render_immediate_nv12(frame, uniforms, &cursor, true, &mut layers) + .await?; + std::hint::black_box(&output.data); + output.frame_number + } + }; + constants.device.poll(wgpu::PollType::Wait)?; + let elapsed = start.elapsed().as_secs_f64() * 1000.0; + ensure!(received_number == frame_number, "Unexpected frame ordering"); + if frame_number >= args.warmup { + samples.push(elapsed); + } + } + samples.sort_by(f64::total_cmp); + let measurement = Measurement { + background: &case.name, + mode, + repetition, + width: args.width, + height: args.height, + received: samples.len(), + mean_ms: samples.iter().sum::() / samples.len() as f64, + p50_ms: percentile(&samples, 0.5), + p95_ms: percentile(&samples, 0.95), + max_ms: samples.last().copied().unwrap_or_default(), + }; + println!("{}", serde_json::to_string(&measurement)?); + } + if let Some(directory) = &args.snapshots + && repetition == 0 + { + std::fs::create_dir_all(directory)?; + let mut renderer = FrameRenderer::new(&constants); + let mut layers = RendererLayers::new(&constants.device, &constants.queue); + for frame_number in [0, 1800] { + let frame = segment(&source, frame_number); + let uniforms = ProjectUniforms::new( + &constants, + &project, + frame_number, + 60, + XY::new(args.width, args.height), + &cursor, + &frame, + duration, + &zoom, + ); + let output = renderer + .render_immediate(frame, uniforms, &cursor, true, &mut layers) + .await?; + let pixels = output + .data + .chunks_exact(output.padded_bytes_per_row as usize) + .flat_map(|row| row[..output.width as usize * 4].iter().copied()) + .collect::>(); + image::RgbaImage::from_raw(output.width, output.height, pixels) + .context("Invalid frame dimensions")? + .save(directory.join(format!("{}-{frame_number}.png", case.name)))?; + } + } + } + } + Ok(()) +} diff --git a/crates/rendering/src/layers/animated_gradient.rs b/crates/rendering/src/layers/animated_gradient.rs new file mode 100644 index 00000000000..49a0a7ccc9a --- /dev/null +++ b/crates/rendering/src/layers/animated_gradient.rs @@ -0,0 +1,620 @@ +use bytemuck::{Pod, Zeroable}; +use cap_project::AnimatedGradientConfig; +use wgpu::util::DeviceExt; + +use crate::ProjectUniforms; + +const MAX_SURFACE_DIMENSION: u32 = 1280; + +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq, Pod, Zeroable)] +struct AnimatedGradientUniforms { + stops: [[f32; 4]; 5], + flow: [f32; 4], + lighting: [f32; 4], + texture: [f32; 4], + motion: [f32; 4], + output: [f32; 4], +} + +impl AnimatedGradientUniforms { + fn new(config: &AnimatedGradientConfig, output: (u32, u32), seconds: f64) -> Self { + let mut stops = [[0.0; 4]; 5]; + for (output, stop) in stops.iter_mut().zip(&config.color_stops) { + *output = [ + stop.color[0] as f32 / 255.0, + stop.color[1] as f32 / 255.0, + stop.color[2] as f32 / 255.0, + stop.position / 100.0, + ]; + } + Self { + stops, + flow: [ + config.direction.to_radians(), + config.flow_scale, + config.flow_strength / 100.0, + config.curvature / 100.0, + ], + lighting: [ + config.relief / 100.0, + config.light / 100.0, + config.shade / 100.0, + config.ripples * 0.03, + ], + texture: [ + config.grain_amount / 100.0, + config.grain_size, + config.exposure / 100.0, + config.contrast / 100.0, + ], + motion: [ + (seconds * f64::from(config.motion_speed) / 50.0) as f32, + config.vibrance / 100.0, + (config.seed & 65535) as f32 / 1024.0, + (config.seed >> 16) as f32 / 1024.0, + ], + output: [ + output.0 as f32, + output.1 as f32, + config.color_stops.len() as f32, + config.detail, + ], + } + } +} + +pub struct AnimatedGradientLayer { + config: AnimatedGradientConfig, + motion_speed: f64, + uniforms: AnimatedGradientUniforms, + buffer: wgpu::Buffer, + surface_pipeline: wgpu::RenderPipeline, + surface_bind_group: wgpu::BindGroup, + composite_pipeline: wgpu::RenderPipeline, + composite_layout: wgpu::BindGroupLayout, + composite_bind_group: wgpu::BindGroup, + surface_view: wgpu::TextureView, + surface_size: (u32, u32), + sampler: wgpu::Sampler, + surface_dirty: bool, +} + +fn surface_size(output: (u32, u32)) -> (u32, u32) { + let scale = (MAX_SURFACE_DIMENSION as f64 / output.0.max(output.1).max(1) as f64).min(1.0); + ( + (output.0 as f64 * scale).round().max(1.0) as u32, + (output.1 as f64 * scale).round().max(1.0) as u32, + ) +} + +impl AnimatedGradientLayer { + pub fn new( + device: &wgpu::Device, + config: AnimatedGradientConfig, + project: &ProjectUniforms, + ) -> Self { + let normalized = config.normalized(); + let uniforms = AnimatedGradientUniforms::new( + &normalized, + project.output_size, + f64::from(project.frame_number) / f64::from(project.frame_rate.max(1)), + ); + let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("Animated gradient uniforms"), + contents: bytemuck::bytes_of(&uniforms), + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + }); + let uniform_entry = wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }; + let surface_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("Animated gradient surface layout"), + entries: &[uniform_entry], + }); + let composite_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("Animated gradient composite layout"), + entries: &[ + uniform_entry, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + let shader = + device.create_shader_module(wgpu::include_wgsl!("../shaders/animated-gradient.wgsl")); + let pipeline = |layout: &wgpu::BindGroupLayout, entry, format| { + let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("Animated gradient pipeline layout"), + bind_group_layouts: &[layout], + push_constant_ranges: &[], + }); + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(entry), + layout: Some(&layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some(entry), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: Default::default(), + depth_stencil: None, + multisample: Default::default(), + multiview: None, + cache: None, + }) + }; + let surface_pipeline = pipeline( + &surface_layout, + "fs_surface", + wgpu::TextureFormat::Rgba16Float, + ); + let composite_pipeline = pipeline( + &composite_layout, + "fs_main", + wgpu::TextureFormat::Rgba8Unorm, + ); + let surface_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Animated gradient surface bind group"), + layout: &surface_layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: buffer.as_entire_binding(), + }], + }); + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("Animated gradient sampler"), + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + ..Default::default() + }); + let surface_size = surface_size(project.output_size); + let surface_view = Self::surface_view(device, surface_size); + let composite_bind_group = + Self::composite_bind_group(device, &composite_layout, &buffer, &surface_view, &sampler); + Self { + config, + motion_speed: f64::from(normalized.motion_speed) / 50.0, + uniforms, + buffer, + surface_pipeline, + surface_bind_group, + composite_pipeline, + composite_layout, + composite_bind_group, + surface_view, + surface_size, + sampler, + surface_dirty: true, + } + } + + fn surface_view(device: &wgpu::Device, size: (u32, u32)) -> wgpu::TextureView { + device + .create_texture(&wgpu::TextureDescriptor { + label: Some("Animated gradient surface"), + size: wgpu::Extent3d { + width: size.0, + height: size.1, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba16Float, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }) + .create_view(&Default::default()) + } + + fn composite_bind_group( + device: &wgpu::Device, + layout: &wgpu::BindGroupLayout, + buffer: &wgpu::Buffer, + view: &wgpu::TextureView, + sampler: &wgpu::Sampler, + ) -> wgpu::BindGroup { + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Animated gradient composite bind group"), + layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(sampler), + }, + ], + }) + } + + pub fn prepare( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + config: AnimatedGradientConfig, + project: &ProjectUniforms, + ) { + let seconds = f64::from(project.frame_number) / f64::from(project.frame_rate.max(1)); + let mut uniforms = self.uniforms; + if self.config != config { + let normalized = config.normalized(); + uniforms = AnimatedGradientUniforms::new(&normalized, project.output_size, seconds); + self.motion_speed = f64::from(normalized.motion_speed) / 50.0; + self.config = config; + } + uniforms.motion[0] = (seconds * self.motion_speed) as f32; + uniforms.output[0] = project.output_size.0 as f32; + uniforms.output[1] = project.output_size.1 as f32; + if uniforms != self.uniforms { + queue.write_buffer(&self.buffer, 0, bytemuck::bytes_of(&uniforms)); + self.uniforms = uniforms; + self.surface_dirty = true; + } + let size = surface_size(project.output_size); + if self.surface_size != size { + self.surface_view = Self::surface_view(device, size); + self.composite_bind_group = Self::composite_bind_group( + device, + &self.composite_layout, + &self.buffer, + &self.surface_view, + &self.sampler, + ); + self.surface_size = size; + self.surface_dirty = true; + } + } + + pub fn render_surface(&mut self, encoder: &mut wgpu::CommandEncoder) { + if !self.surface_dirty { + return; + } + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("Animated gradient surface pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &self.surface_view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + pass.set_pipeline(&self.surface_pipeline); + pass.set_bind_group(0, &self.surface_bind_group, &[]); + pass.draw(0..3, 0..1); + self.surface_dirty = false; + } + + pub fn render(&self, pass: &mut wgpu::RenderPass<'_>) { + pass.set_pipeline(&self.composite_pipeline); + pass.set_bind_group(0, &self.composite_bind_group, &[]); + pass.draw(0..3, 0..1); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cap_project::{ + BackgroundSource, CursorEvents, ProjectConfiguration, RecordingMeta, RecordingMetaInner, + StudioRecordingMeta, XY, + }; + + use crate::{ + BackgroundLayer, DecodedSegmentFrames, RenderOptions, RenderVideoConstants, + ZoomTransformTimeline, + frame_pipeline::{RenderSession, finish_encoder, flush_pending_readback}, + }; + + #[test] + fn bounded_surface_preserves_aspect_and_small_outputs() { + assert_eq!(surface_size((3840, 2160)), (1280, 720)); + assert_eq!(surface_size((2160, 3840)), (720, 1280)); + assert_eq!(surface_size((320, 180)), (320, 180)); + assert_eq!(surface_size((0, 0)), (1, 1)); + } + + #[test] + fn motion_is_deterministic_and_zero_speed_freezes_it() { + let mut config = AnimatedGradientConfig::default(); + let at = |config: &AnimatedGradientConfig, seconds| { + AnimatedGradientUniforms::new(config, (1920, 1080), seconds) + }; + assert_eq!(at(&config, 2.0), at(&config, 2.0)); + assert_ne!(at(&config, 2.0), at(&config, 3.0)); + config.motion_speed = 0.0; + assert_eq!(at(&config, 0.0), at(&config, 7200.0)); + assert_eq!(std::mem::size_of::(), 160); + } + + async fn test_constants() -> RenderVideoConstants { + let meta: StudioRecordingMeta = serde_json::from_value(serde_json::json!({ + "display": { "path": "synthetic.mp4", "fps": 30 } + })) + .unwrap(); + let recording = RecordingMeta { + platform: None, + project_path: Default::default(), + pretty_name: "Animated gradient test".into(), + sharing: None, + inner: RecordingMetaInner::Studio(Box::new(meta.clone())), + upload: None, + }; + RenderVideoConstants::new_with_options( + RenderOptions { + screen_size: XY::new(320, 180), + camera_size: None, + preserve_screen_alpha: false, + }, + recording, + meta, + ) + .await + .unwrap() + } + + fn test_uniforms(constants: &RenderVideoConstants, frame: u32) -> ProjectUniforms { + let project = ProjectConfiguration::default(); + let cursor = CursorEvents::default(); + let frames = DecodedSegmentFrames { + screen_frame: None, + camera_frame: None, + segment_time: frame as f32 / 30.0, + recording_time: frame as f32 / 30.0, + segment_has_camera: false, + }; + let mut zoom = ZoomTransformTimeline::from_project( + &project, + &cursor, + 60.0, + constants.options.screen_size, + ); + zoom.ensure_precomputed_until(60.0); + let mut uniforms = ProjectUniforms::new( + constants, + &project, + frame, + 30, + XY::new(320, 180), + &cursor, + &frames, + 60.0, + &zoom, + ); + uniforms.output_size = (320, 180); + uniforms + } + + async fn pixels( + layer: &mut BackgroundLayer, + constants: &RenderVideoConstants, + config: &AnimatedGradientConfig, + frame: u32, + ) -> Vec { + background_pixels( + layer, + constants, + BackgroundSource::AnimatedGradient { + config: config.clone(), + }, + frame, + 30, + (320, 180), + ) + .await + } + + async fn background_pixels( + layer: &mut BackgroundLayer, + constants: &RenderVideoConstants, + source: BackgroundSource, + frame: u32, + frame_rate: u32, + size: (u32, u32), + ) -> Vec { + let mut uniforms = test_uniforms(constants, frame); + uniforms.frame_rate = frame_rate; + uniforms.output_size = size; + layer + .prepare(constants, &uniforms, source.into()) + .await + .unwrap(); + let mut session = RenderSession::new(&constants.device, size.0, size.1); + let mut encoder = constants.device.create_command_encoder(&Default::default()); + layer.render_surface(&mut encoder); + { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("Animated gradient test"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: session.current_texture_view(), + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + layer.render(&mut pass); + } + assert!( + finish_encoder( + &mut session, + &constants.device, + &constants.queue, + &uniforms, + encoder, + ) + .await + .unwrap() + .is_none() + ); + let frame = flush_pending_readback(&mut session, &constants.device) + .await + .unwrap() + .unwrap(); + frame + .data + .chunks_exact(frame.padded_bytes_per_row as usize) + .flat_map(|row| row[..size.0 as usize * 4].iter().copied()) + .collect() + } + + #[tokio::test] + #[ignore = "requires a graphics adapter"] + async fn gpu_motion_seeking_freeze_and_color_stop_edges() { + let constants = test_constants().await; + let config = AnimatedGradientConfig::default(); + let mut layer = BackgroundLayer::new(&constants.device); + let first = pixels(&mut layer, &constants, &config, 0).await; + let later = pixels(&mut layer, &constants, &config, 900).await; + assert_ne!(first, later); + assert_eq!(first, pixels(&mut layer, &constants, &config, 0).await); + if let Some(directory) = std::env::var_os("CAP_GRADIENT_TEST_OUTPUT") { + std::fs::create_dir_all(&directory).unwrap(); + for (name, data) in [ + ("gradient-start.png", &first), + ("gradient-later.png", &later), + ] { + image::RgbaImage::from_raw(320, 180, data.clone()) + .unwrap() + .save(std::path::Path::new(&directory).join(name)) + .unwrap(); + } + } + let frozen = AnimatedGradientConfig { + motion_speed: 0.0, + ..config + }; + assert_eq!( + pixels(&mut layer, &constants, &frozen, 0).await, + pixels(&mut layer, &constants, &frozen, 900).await + ); + let edges = AnimatedGradientConfig { + color_stops: vec![ + cap_project::AnimatedGradientStop { + color: [255, 0, 0], + position: 25.0, + }, + cap_project::AnimatedGradientStop { + color: [0, 0, 255], + position: 75.0, + }, + ], + direction: 0.0, + flow_strength: 0.0, + curvature: 0.0, + relief: 0.0, + light: 0.0, + shade: 0.0, + grain_amount: 0.0, + ..frozen + }; + let result = pixels(&mut layer, &constants, &edges, 0).await; + assert_eq!(&result[..4], &[255, 0, 0, 255]); + assert_eq!(&result[319 * 4..320 * 4], &[0, 0, 255, 255]); + assert!(result.chunks_exact(4).all(|pixel| pixel[3] == 255)); + } + + #[tokio::test] + #[ignore = "requires a graphics adapter"] + async fn gpu_switching_backgrounds_resizing_and_frame_rate_parity() { + let constants = test_constants().await; + let mut layer = BackgroundLayer::new(&constants.device); + let animated = BackgroundSource::AnimatedGradient { + config: AnimatedGradientConfig::default(), + }; + let legacy_sources = [ + BackgroundSource::Color { + value: [33, 40, 66], + alpha: 255, + }, + BackgroundSource::Gradient { + from: [31, 54, 190], + to: [237, 116, 194], + angle: 45, + noise_intensity: None, + noise_scale: None, + animated: None, + animation_speed: None, + }, + ]; + for legacy in legacy_sources { + let original = + background_pixels(&mut layer, &constants, legacy.clone(), 0, 30, (320, 180)).await; + let at_30fps = + background_pixels(&mut layer, &constants, animated.clone(), 60, 30, (320, 180)) + .await; + let at_60fps = background_pixels( + &mut layer, + &constants, + animated.clone(), + 120, + 60, + (320, 180), + ) + .await; + assert_eq!(at_30fps, at_60fps); + assert_ne!(original, at_30fps); + let resized = + background_pixels(&mut layer, &constants, animated.clone(), 60, 30, (180, 320)) + .await; + assert_eq!(resized.len(), 180 * 320 * 4); + assert_eq!( + at_30fps, + background_pixels(&mut layer, &constants, animated.clone(), 60, 30, (320, 180)) + .await + ); + assert_eq!( + original, + background_pixels(&mut layer, &constants, legacy, 900, 30, (320, 180)).await + ); + } + } +} diff --git a/crates/rendering/src/layers/background.rs b/crates/rendering/src/layers/background.rs index acf6e49543d..639b4dc2774 100644 --- a/crates/rendering/src/layers/background.rs +++ b/crates/rendering/src/layers/background.rs @@ -1,5 +1,5 @@ use bytemuck::{Pod, Zeroable}; -use cap_project::BackgroundSource; +use cap_project::{AnimatedGradientConfig, BackgroundSource}; use image::GenericImageView; use serde::{Deserialize, Serialize}; use specta::Type; @@ -10,6 +10,8 @@ use wgpu::{include_wgsl, util::DeviceExt}; use crate::{ProjectUniforms, RenderVideoConstants, RenderingError, create_shader_render_pipeline}; +use super::AnimatedGradientLayer; + const MAX_BACKGROUND_DIMENSION: u32 = 2560; const DEFAULT_BACKGROUND_CACHE_CAPACITY: usize = 8; @@ -262,12 +264,14 @@ pub enum ColorOrGradient { pub enum Background { Color([f32; 4]), Gradient(Gradient), + AnimatedGradient(AnimatedGradientConfig), Image { path: String }, } impl From for Background { fn from(value: BackgroundSource) -> Self { match value { + BackgroundSource::AnimatedGradient { config } => Background::AnimatedGradient(config), BackgroundSource::Color { value, alpha } => Background::Color([ value[0] as f32 / 255.0, value[1] as f32 / 255.0, @@ -315,7 +319,7 @@ fn background_source_is_empty(source: &BackgroundSource) -> bool { BackgroundSource::Image { path } | BackgroundSource::Wallpaper { path } => { path.as_deref().map(str::is_empty).unwrap_or(true) } - BackgroundSource::Gradient { .. } => false, + BackgroundSource::Gradient { .. } | BackgroundSource::AnimatedGradient { .. } => false, } } @@ -330,6 +334,7 @@ impl Background { } pub enum Inner { + AnimatedGradient(Box), Image { path: String, bind_group: wgpu::BindGroup, @@ -367,6 +372,16 @@ impl BackgroundLayer { let queue = &constants.queue; match background { + Background::AnimatedGradient(config) => match &mut self.inner { + Some(Inner::AnimatedGradient(layer)) => { + layer.prepare(device, queue, config, uniforms); + } + _ => { + self.inner = Some(Inner::AnimatedGradient(Box::new( + AnimatedGradientLayer::new(device, config, uniforms), + ))); + } + }, Background::Image { path } => { match &self.inner { Some(Inner::Image { @@ -476,7 +491,17 @@ impl BackgroundLayer { Ok(()) } + pub fn render_surface(&mut self, encoder: &mut wgpu::CommandEncoder) { + if let Some(Inner::AnimatedGradient(layer)) = &mut self.inner { + layer.render_surface(encoder); + } + } + pub fn render(&self, pass: &mut wgpu::RenderPass<'_>) { + if let Some(Inner::AnimatedGradient(layer)) = &self.inner { + layer.render(pass); + return; + } if let Some(Inner::Image { bind_group, .. }) = &self.inner { pass.set_pipeline(&self.image_pipeline.render_pipeline); pass.set_bind_group(0, bind_group, &[]); @@ -698,8 +723,8 @@ impl From for GradientOrColorUniforms { noise_scale, _padding: 0.0, }, - Background::Image { .. } => { - unreachable!("Image backgrounds should be handled separately") + Background::Image { .. } | Background::AnimatedGradient(_) => { + unreachable!("Textured backgrounds should be handled separately") } } } diff --git a/crates/rendering/src/layers/mod.rs b/crates/rendering/src/layers/mod.rs index 6237813d10e..5571e2fafbd 100644 --- a/crates/rendering/src/layers/mod.rs +++ b/crates/rendering/src/layers/mod.rs @@ -1,3 +1,4 @@ +mod animated_gradient; mod background; mod blur; mod camera; @@ -63,6 +64,7 @@ pub(crate) fn new_font_system() -> glyphon::FontSystem { glyphon::FontSystem::new_with_locale_and_db(locale.clone(), db.clone()) } +pub use animated_gradient::*; pub use background::*; pub use blur::*; pub use camera::*; diff --git a/crates/rendering/src/lib.rs b/crates/rendering/src/lib.rs index 775ea79e7e7..f9f752eb8e2 100644 --- a/crates/rendering/src/lib.rs +++ b/crates/rendering/src/lib.rs @@ -6217,6 +6217,7 @@ impl RendererLayers { } self.camera.copy_to_texture(encoder); self.camera_only.copy_to_texture(encoder); + self.background.render_surface(encoder); { let mut pass = render_pass!( diff --git a/crates/rendering/src/shaders/animated-gradient.wgsl b/crates/rendering/src/shaders/animated-gradient.wgsl new file mode 100644 index 00000000000..68de9f2e5a6 --- /dev/null +++ b/crates/rendering/src/shaders/animated-gradient.wgsl @@ -0,0 +1,190 @@ +struct GradientUniforms { + stops: array, 5>, + flow: vec4, + lighting: vec4, + texture: vec4, + motion: vec4, + output: vec4, +} + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) uv: vec2, +} + +struct HarmonicField { + value: f32, + gradient: vec2, +} + +@group(0) @binding(0) var u: GradientUniforms; +@group(0) @binding(1) var surface: texture_2d; +@group(0) @binding(2) var surface_sampler: sampler; + +const TAU: f32 = 6.283185307179586; + +@vertex +fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { + var positions = array, 3>( + vec2(-1.0, -1.0), + vec2(3.0, -1.0), + vec2(-1.0, 3.0), + ); + let position = positions[vertex_index]; + var output: VertexOutput; + output.position = vec4(position, 0.0, 1.0); + output.uv = vec2((position.x + 1.0) * 0.5, (1.0 - position.y) * 0.5); + return output; +} + +fn harmonic_field( + coordinate: vec2, + time: f32, + seed_offset: f32, + detail: u32, +) -> HarmonicField { + var value = 0.0; + var gradient = vec2(0.0); + var amplitude = 1.0; + var frequency = 0.36; + var weight = 0.0; + for (var level = 0u; level < 6u; level = level + 1u) { + if (level >= detail) { + break; + } + let index = f32(level); + let seed = u.motion.z * 0.7548777 + u.motion.w * 0.5698403 + seed_offset; + let angle = TAU * fract(seed * 0.1732051 + index * 0.3819660); + let direction = vec2(cos(angle), sin(angle)); + let wave_a = direction * frequency; + let wave_b = vec2(-direction.y, direction.x) * frequency * 1.17; + let phase_a = TAU * fract(seed * 0.6180340 + index * 0.4142136); + let phase_b = TAU * fract(seed * 0.2718282 + index * 0.7320508 + 0.37); + let theta_a = TAU * dot(coordinate, wave_a) + phase_a + time * (0.82 + index * 0.11); + let theta_b = TAU * dot(coordinate, wave_b) + phase_b - time * (0.57 + index * 0.08); + value += amplitude * (sin(theta_a) * 0.62 + cos(theta_b) * 0.38); + gradient += amplitude * TAU * ( + cos(theta_a) * wave_a * 0.62 - sin(theta_b) * wave_b * 0.38 + ); + weight += amplitude; + amplitude *= 0.52; + frequency *= 1.86; + } + return HarmonicField(value / weight, gradient / weight); +} + +fn palette(position: f32) -> vec3 { + let count = u32(clamp(round(u.output.z), 2.0, 5.0)); + let first = u.stops[0]; + if (position <= first.w) { + return first.rgb; + } + var previous = first; + for (var index = 1u; index < 5u; index = index + 1u) { + if (index >= count) { + break; + } + let current = u.stops[index]; + if (position <= current.w) { + let interval = max(current.w - previous.w, 0.00001); + let progress = clamp((position - previous.w) / interval, 0.0, 1.0); + return mix(previous.rgb, current.rgb, progress); + } + previous = current; + } + return previous.rgb; +} + +fn edge_aligned_uv(uv: vec2) -> vec2 { + let pixel_step = vec2(abs(dpdx(uv.x)), abs(dpdy(uv.y))); + let span = max(vec2(1.0) - pixel_step, vec2(0.00001)); + return clamp((uv - pixel_step * 0.5) / span, vec2(0.0), vec2(1.0)); +} + +@fragment +fn fs_surface(input: VertexOutput) -> @location(0) vec4 { + let uv = edge_aligned_uv(input.uv); + let direction = vec2(cos(u.flow.x), sin(u.flow.x)); + let perpendicular = vec2(-direction.y, direction.x); + let projection_span = max(abs(direction.x) + abs(direction.y), 0.00001); + let base_position = dot(uv - vec2(0.5), direction) / projection_span + 0.5; + let aspect = max(u.output.x, 1.0) / max(u.output.y, 1.0); + let canvas = vec2((uv.x - 0.5) * aspect, uv.y - 0.5); + let scale = max(u.flow.y, 0.2); + let coordinate = vec2(dot(canvas, direction), dot(canvas, perpendicular)) * scale; + let detail = u32(clamp(round(u.output.w), 1.0, 6.0)); + let guide = harmonic_field(coordinate, u.motion.x, 0.137, detail); + let bend = vec2( + guide.value * 0.58 - guide.gradient.y * 0.10, + -guide.value * 0.31 + guide.gradient.x * 0.10, + ); + let curved_coordinate = coordinate + bend * u.flow.w; + let flowing = harmonic_field( + curved_coordinate + vec2(2.73, -1.91), + u.motion.x * 0.79, + 0.713, + detail, + ); + let displacement = guide.value * 0.57 + flowing.value * 0.43; + let gradient_position = clamp(base_position + displacement * u.flow.z * 0.30, 0.0, 1.0); + var color = palette(gradient_position); + + let ripple_size = max(u.lighting.w, 0.05); + let ripple_phase = TAU * ( + (flowing.value * 0.56 + guide.value * 0.22) / ripple_size + + dot(coordinate, vec2(0.08, -0.05)) + ); + let ripple_enabled = select(0.0, 1.0, u.lighting.w > 0.0); + let base_slope = flowing.gradient * 0.64 + guide.gradient * 0.36; + let ripple_slope = cos(ripple_phase) * TAU / ripple_size * ( + flowing.gradient * 0.56 + guide.gradient * 0.22 + vec2(0.08, -0.05) + ); + let slope = base_slope + ripple_slope * ripple_enabled * 0.18; + let relief = u.lighting.x; + let normal = normalize(vec3(-slope * relief * 0.42, 1.0)); + let light_direction = normalize(vec3(-0.48, -0.62, 0.86)); + let illumination = dot(normal, light_direction); + let highlight = pow(max(illumination, 0.0), 4.0) * u.lighting.y * relief * 0.32; + let shadow = (1.0 - smoothstep(0.12, 0.82, illumination)) * u.lighting.z * relief * 0.28; + color = color * (1.0 - shadow) + vec3(highlight); + return vec4(color, 1.0); +} + +fn hash_u32(input: u32) -> u32 { + var value = input; + value = (value ^ (value >> 16u)) * 0x7feb352du; + value = (value ^ (value >> 15u)) * 0x846ca68bu; + return value ^ (value >> 16u); +} + +fn grain_value(uv: vec2) -> f32 { + let grain_size = max(u.texture.y, 0.5); + let pixel = vec2(floor(uv * max(u.output.xy, vec2(1.0)) / grain_size)); + let seed_low = u32(round(max(u.motion.z, 0.0) * 1024.0)); + let seed_high = u32(round(max(u.motion.w, 0.0) * 1024.0)); + let seed = seed_low | (seed_high << 16u); + let time_cell = u32(max(floor(abs(u.motion.x) * 24.0), 0.0)); + let hash = hash_u32( + pixel.x * 0x9e3779b9u ^ pixel.y * 0x85ebca6bu ^ seed ^ time_cell * 0xc2b2ae35u, + ); + return f32(hash & 0x00ffffffu) / 16777215.0; +} + +@fragment +fn fs_main(input: VertexOutput) -> @location(0) vec4 { + var color = textureSample(surface, surface_sampler, input.uv).rgb; + color *= exp2(u.texture.z); + color = (color - vec3(0.5)) * u.texture.w + vec3(0.5); + let luminance = dot(color, vec3(0.2126, 0.7152, 0.0722)); + let neutral = vec3(luminance); + if (u.motion.y <= 1.0) { + color = mix(neutral, color, u.motion.y); + } else { + let saturation = max(color.r, max(color.g, color.b)) - min(color.r, min(color.g, color.b)); + let vibrance = 1.0 + (u.motion.y - 1.0) * (1.0 - clamp(saturation, 0.0, 1.0)); + color = neutral + (color - neutral) * vibrance; + } + let grain = (grain_value(input.uv) - 0.5) * u.texture.x * 0.18; + color += vec3(grain); + return vec4(clamp(color, vec3(0.0), vec3(1.0)), 1.0); +} From be4bfb2ef117ddb7517f23b8ad22cf82f4f49df2 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:35:41 +0100 Subject: [PATCH 2/3] fix: improve Windows GPUI desktop integration --- apps/desktop-gpui/Cargo.lock | 572 ++++++++++++++++++- apps/desktop-gpui/Cargo.toml | 4 + apps/desktop-gpui/build.rs | 15 + apps/desktop-gpui/patches/zed-windows.patch | 153 ++++- apps/desktop-gpui/src/app_windows.rs | 12 +- apps/desktop-gpui/src/editor_export.rs | 71 ++- apps/desktop-gpui/src/editor_window.rs | 45 +- apps/desktop-gpui/src/main.rs | 2 + apps/desktop-gpui/src/main_window.rs | 42 +- apps/desktop-gpui/src/mode_select_window.rs | 26 +- apps/desktop-gpui/src/onboarding_window.rs | 25 +- apps/desktop-gpui/src/screenshot_editor.rs | 381 ++++++------ apps/desktop-gpui/src/settings_window.rs | 44 +- apps/desktop-gpui/src/teleprompter_window.rs | 29 +- apps/desktop-gpui/src/tray.rs | 17 +- apps/desktop-gpui/src/tray/windows.rs | 337 +++++++++++ apps/desktop-gpui/src/ui/button.rs | 5 + apps/desktop-gpui/src/ui/mod.rs | 3 + apps/desktop-gpui/src/ui/progress.rs | 33 +- apps/desktop-gpui/src/ui/windows_caption.rs | 114 ++++ 20 files changed, 1671 insertions(+), 259 deletions(-) create mode 100644 apps/desktop-gpui/build.rs create mode 100644 apps/desktop-gpui/src/tray/windows.rs create mode 100644 apps/desktop-gpui/src/ui/windows_caption.rs diff --git a/apps/desktop-gpui/Cargo.lock b/apps/desktop-gpui/Cargo.lock index 7c0f280531b..9dda7ec44be 100644 --- a/apps/desktop-gpui/Cargo.lock +++ b/apps/desktop-gpui/Cargo.lock @@ -719,6 +719,29 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps 6.2.2", +] + [[package]] name = "atomic" version = "0.5.3" @@ -1297,6 +1320,31 @@ dependencies = [ "libbz2-rs-sys", ] +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.1", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps 6.2.2", +] + [[package]] name = "calloop" version = "0.14.4" @@ -1517,11 +1565,13 @@ dependencies = [ "serde_json", "sha2", "smallvec", + "tauri-winres", "tiny-skia", "tokio", "tracing", "tracing-appender", "tracing-subscriber", + "tray-icon", "unicode-segmentation", "wgpu 25.0.2", "whisper-rs", @@ -2013,6 +2063,16 @@ dependencies = [ "uuid", ] +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon 0.12.16", +] + [[package]] name = "cfg-expr" version = "0.20.8" @@ -2020,7 +2080,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fb693542bcafa528e198be0ebd9d3632ca5b7c93dbe7237460e199910835997c" dependencies = [ "smallvec", - "target-lexicon", + "target-lexicon 0.13.5", ] [[package]] @@ -3204,6 +3264,12 @@ version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + [[package]] name = "dtoa" version = "1.0.11" @@ -3502,6 +3568,16 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + [[package]] name = "filedescriptor" version = "0.8.3" @@ -3890,6 +3966,64 @@ dependencies = [ "byteorder", ] +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps 6.2.2", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps 6.2.2", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -4027,6 +4161,38 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps 6.2.2", + "winapi", +] + [[package]] name = "gl_generator" version = "0.14.0" @@ -4038,6 +4204,53 @@ dependencies = [ "xml-rs", ] +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.1", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.0", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps 6.2.2", +] + [[package]] name = "glob" version = "0.3.4" @@ -4107,6 +4320,17 @@ dependencies = [ "wgpu 25.0.2", ] +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps 6.2.2", +] + [[package]] name = "gpu-alloc" version = "0.6.2" @@ -4486,6 +4710,58 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5" +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps 6.2.2", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "guardian" version = "1.3.0" @@ -5505,6 +5781,30 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading 0.7.4", + "once_cell", +] + [[package]] name = "libbz2-rs-sys" version = "0.2.5" @@ -5527,6 +5827,16 @@ dependencies = [ "cc", ] +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + [[package]] name = "libloading" version = "0.8.9" @@ -5575,7 +5885,7 @@ dependencies = [ "libspa-sys", "nom 8.0.0", "rustix 1.1.4", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -5586,7 +5896,7 @@ checksum = "69ad52764fca54818486f3cf75afec844d1f1a1568c24dcee25d41b1ab007dda" dependencies = [ "bindgen 0.72.1", "cc", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -6069,6 +6379,26 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "muda" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c9fec5a4e89860383d778d10563a605838f8f0b2f9303868937e5ff32e86177" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "png 0.17.16", + "thiserror 2.0.20", + "windows-sys 0.60.2", +] + [[package]] name = "naga" version = "25.0.1" @@ -6466,7 +6796,7 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ - "proc-macro-crate", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", "syn 2.0.119", @@ -7170,6 +7500,31 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps 6.2.2", +] + [[package]] name = "parakeet-rs" version = "0.3.4" @@ -7531,7 +7886,7 @@ checksum = "f2089f245b548723e60325773c27f586b7a2372c79ea941b246cd0d654706adc" dependencies = [ "bindgen 0.72.1", "libspa-sys", - "system-deps", + "system-deps 7.0.8", ] [[package]] @@ -7701,6 +8056,25 @@ dependencies = [ "num-integer", ] +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e8366a6159044a37876a2b9817124296703c586a5c92e2c53751fa06d8d43e8" +dependencies = [ + "toml_edit 0.20.7", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -7710,6 +8084,30 @@ dependencies = [ "toml_edit 0.25.13+spec-1.1.0", ] +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + [[package]] name = "proc-macro-error-attr2" version = "2.0.0" @@ -10043,13 +10441,26 @@ dependencies = [ "libc", ] +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr 0.15.8", + "heck 0.5.0", + "pkg-config", + "toml 0.8.23", + "version-compare", +] + [[package]] name = "system-deps" version = "7.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7" dependencies = [ - "cfg-expr", + "cfg-expr 0.20.8", "heck 0.5.0", "pkg-config", "toml 1.1.4+spec-1.1.0", @@ -10080,6 +10491,12 @@ dependencies = [ "objc", ] +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + [[package]] name = "target-lexicon" version = "0.13.5" @@ -10125,6 +10542,17 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-winres" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" +dependencies = [ + "dunce", + "embed-resource", + "toml 1.1.4+spec-1.1.0", +] + [[package]] name = "tauri-winrt-notification" version = "0.7.3" @@ -10486,6 +10914,28 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.11", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70f427fce4d84c72b5b732388bf4a9f4531b53f74e2887e3ecb2481f68f66d81" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.11", + "winnow 0.5.40", +] + [[package]] name = "toml_edit" version = "0.22.27" @@ -10664,6 +11114,27 @@ dependencies = [ "strength_reduce", ] +[[package]] +name = "tray-icon" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" +dependencies = [ + "crossbeam-channel", + "dirs 6.0.0", + "libappindicator", + "muda", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "once_cell", + "png 0.17.16", + "thiserror 2.0.20", + "windows-sys 0.60.2", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -12316,6 +12787,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -12364,13 +12844,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows-threading" version = "0.1.0" @@ -12416,6 +12913,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -12434,6 +12937,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -12452,12 +12961,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -12476,6 +12997,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -12494,6 +13021,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -12512,6 +13045,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -12530,6 +13069,21 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "0.7.15" @@ -12880,7 +13434,7 @@ version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" dependencies = [ - "proc-macro-crate", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", "syn 3.0.3", @@ -13177,7 +13731,7 @@ version = "5.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d496a145685283b67e232bd9e47377f6b60ad9d51e3601b23867f77c42477f96" dependencies = [ - "proc-macro-crate", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", "syn 3.0.3", diff --git a/apps/desktop-gpui/Cargo.toml b/apps/desktop-gpui/Cargo.toml index 8d00a8f3988..b634366052a 100644 --- a/apps/desktop-gpui/Cargo.toml +++ b/apps/desktop-gpui/Cargo.toml @@ -153,6 +153,9 @@ dirs = "6" # downloaded by either app loads identically in both. whisper-rs = "0.11.0" +[build-dependencies] +tauri-winres = "0.3.5" + [dev-dependencies] # The editor smoke (`tests/editor_frame0.rs`) drives `EditorInstance`, whose # decoders, renderer and preview tasks are all tokio-spawned -- a current-thread @@ -167,6 +170,7 @@ libc = "0.2" [target.'cfg(windows)'.dependencies] raw-window-handle = "0.6" +tray-icon = { version = "0.21.1", default-features = false } windows-sys = { version = "0.59", features = [ "Win32_Foundation", "Win32_Security", diff --git a/apps/desktop-gpui/build.rs b/apps/desktop-gpui/build.rs new file mode 100644 index 00000000000..202980b601e --- /dev/null +++ b/apps/desktop-gpui/build.rs @@ -0,0 +1,15 @@ +fn main() { + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { + return; + } + + let icon = "../desktop/src-tauri/icons/icon.ico"; + println!("cargo:rerun-if-changed={icon}"); + tauri_winres::WindowsResource::new() + .set_icon_with_id(icon, "1") + .set("ProductName", "Cap") + .set("FileDescription", "Cap") + .set("OriginalFilename", "cap-gpui.exe") + .compile() + .expect("failed to compile the Cap Windows icon resource"); +} diff --git a/apps/desktop-gpui/patches/zed-windows.patch b/apps/desktop-gpui/patches/zed-windows.patch index 06c1a5a173b..856b1cbd673 100644 --- a/apps/desktop-gpui/patches/zed-windows.patch +++ b/apps/desktop-gpui/patches/zed-windows.patch @@ -85,25 +85,50 @@ + color.a *= sprite.opacity * saturate(0.5 - distance) * edge_fade_alpha(input.position.xy, sprite.fade); --- a/crates/gpui_windows/src/window.rs +++ b/crates/gpui_windows/src/window.rs -@@ -216 +216,5 @@ +@@ -95,0 +96 @@ ++ pub(crate) custom_frame: bool, +@@ -216 +217,5 @@ - placement.rcNormalPosition, + translate_rect( + placement.rcNormalPosition, + workspace_offset(self.hwnd, self.display.get(), self.scale_factor.get()), + 1, + ), -@@ -463 +467 @@ +@@ -265,0 +271 @@ ++ custom_frame: context.custom_frame, +@@ -388,0 +395 @@ ++ custom_frame: bool, +@@ -405,0 +413,4 @@ ++} ++ ++fn custom_frame_enabled(normal_window: bool, transparent_titlebar: Option) -> bool { ++ normal_window && transparent_titlebar == Some(true) +@@ -452,0 +464,7 @@ ++ let custom_frame = custom_frame_enabled( ++ params.kind == WindowKind::Normal, ++ params ++ .titlebar ++ .as_ref() ++ .map(|titlebar| titlebar.appears_transparent), ++ ); +@@ -463 +481 @@ - (WS_EX_TOOLWINDOW, WINDOW_STYLE(0x0)) + (WS_EX_TOOLWINDOW, WS_POPUP) -@@ -465,0 +470,5 @@ -+ if hide_title_bar { +@@ -465,0 +484,5 @@ ++ if hide_title_bar && !custom_frame { + dwstyle |= WS_POPUP; + } else { + dwstyle |= WS_CAPTION; + } -@@ -550,0 +560 @@ +@@ -482,0 +506,3 @@ ++ if custom_frame { ++ dwexstyle |= WS_EX_WINDOWEDGE; ++ } +@@ -499,0 +526 @@ ++ custom_frame, +@@ -550,0 +578 @@ + params.window_min_size, -@@ -621,2 +631,30 @@ +@@ -621,2 +649,30 @@ - let bounds = gpui::bounds(self.bounds().origin, size).to_device_pixels(self.scale_factor()); - let rect = calculate_window_rect(bounds, &self.state.border_offset); + let size = size.to_device_pixels(self.scale_factor()); @@ -136,15 +161,15 @@ + if !movable { + flags |= SWP_NOMOVE; + } -@@ -631,2 +669,2 @@ +@@ -631,2 +687,2 @@ - bounds.origin.x.0, - bounds.origin.y.0, + rect.left, + rect.top, -@@ -635 +673 @@ +@@ -635 +691 @@ - SWP_NOMOVE, + flags, -@@ -637,0 +676,29 @@ +@@ -637,0 +694,29 @@ + .log_err(); + } + }) @@ -174,9 +199,9 @@ + LPARAM(position as isize), + ) + .context("unable to start window move") -@@ -1510,0 +1578 @@ +@@ -1510,0 +1596 @@ + minimum_size: Option>, -@@ -1524 +1592,10 @@ +@@ -1524 +1610,10 @@ - placement.rcNormalPosition = calculate_window_rect(bounds, border_offset); + let mut rect = calculate_window_rect(bounds, border_offset); + if !is_tool_window(hwnd) { @@ -188,7 +213,7 @@ + } + placement.rcNormalPosition = + translate_rect(rect, workspace_offset(hwnd, display, scale_factor), -1); -@@ -1525,0 +1603,54 @@ +@@ -1525,0 +1621,54 @@ +} + +fn is_tool_window(hwnd: HWND) -> bool { @@ -243,15 +268,29 @@ + right: left + width, + bottom: top + height, + } -@@ -1612,2 +1743,2 @@ +@@ -1612,2 +1761,2 @@ - use super::ClickState; - use gpui::{DevicePixels, MouseButton, point}; -+ use super::{ClickState, fit_window_rect, translate_rect}; ++ use super::{ClickState, custom_frame_enabled, fit_window_rect, translate_rect}; + use gpui::{Bounds, DevicePixels, MouseButton, point, size}; -@@ -1614,0 +1746,66 @@ +@@ -1614,0 +1764,80 @@ + use windows::Win32::Foundation::RECT; + + #[test] ++ fn custom_frame_requires_a_normal_window_with_an_explicit_transparent_titlebar() { ++ assert!(custom_frame_enabled(true, Some(true))); ++ for (normal_window, transparent_titlebar) in [ ++ (true, Some(false)), ++ (true, None), ++ (false, Some(true)), ++ (false, Some(false)), ++ (false, None), ++ ] { ++ assert!(!custom_frame_enabled(normal_window, transparent_titlebar)); ++ } ++ } ++ ++ #[test] + fn expanded_window_moves_above_bottom_taskbar() { + let work = Bounds { + origin: point(DevicePixels(0), DevicePixels(0)), @@ -317,17 +356,30 @@ + } --- a/crates/gpui_windows/src/events.rs +++ b/crates/gpui_windows/src/events.rs -@@ -847,5 +846,0 @@ +@@ -716,0 +717,12 @@ ++ if self.custom_frame && !self.state.is_maximized() { ++ let dpi = unsafe { GetDpiForWindow(handle) }; ++ let (frame, top) = ++ custom_frame_insets(get_frame_thicknessx(dpi), dpi, windows_build_number()); ++ let params = unsafe { &mut *(lparam.0 as *mut NCCALCSIZE_PARAMS) }; ++ params.rgrc[0].left += frame; ++ params.rgrc[0].top += top; ++ params.rgrc[0].right -= frame; ++ params.rgrc[0].bottom -= frame; ++ return Some(0); ++ } ++ +@@ -847,5 +858,0 @@ - - // SetWindowPos may not send WM_SIZE for maximized windows in some cases, - // so we manually update the size to ensure proper rendering - let device_size = size(DevicePixels(width), DevicePixels(height)); - self.handle_size_change(device_size, new_scale_factor, true); -@@ -858,3 +852,0 @@ +@@ -858,3 +864,0 @@ - // this will emit `WM_SIZE` and `WM_MOVE` right here - // even before this function returns - // the new size is handled in `WM_SIZE` -@@ -873,0 +866,15 @@ +@@ -873,0 +878,15 @@ + } + + // SetWindowPos can omit a final WM_SIZE; its outer bounds also include native captions. @@ -343,3 +395,68 @@ + DevicePixels((client_rect.bottom - client_rect.top).max(1)), + ); + self.handle_size_change(device_size, self.state.scale_factor.get(), true); +@@ -1652,0 +1672,26 @@ ++fn custom_frame_insets(frame: i32, dpi: u32, windows_build: u32) -> (i32, i32) { ++ let top = if windows_build >= 22000 { ++ (dpi as f32 / 96.).round() as i32 ++ } else { ++ 0 ++ }; ++ (frame, top) ++} ++ ++fn windows_build_number() -> u32 { ++ use windows::Win32::System::SystemInformation::OSVERSIONINFOW; ++ ++ static BUILD: std::sync::OnceLock = std::sync::OnceLock::new(); ++ *BUILD.get_or_init(|| { ++ let mut version = OSVERSIONINFOW { ++ dwOSVersionInfoSize: std::mem::size_of::() as u32, ++ ..Default::default() ++ }; ++ if unsafe { windows::Wdk::System::SystemServices::RtlGetVersion(&mut version) }.is_ok() { ++ version.dwBuildNumber ++ } else { ++ 0 ++ } ++ }) ++} ++ +@@ -1690,0 +1736,37 @@ ++ ++#[cfg(test)] ++mod tests { ++ use super::custom_frame_insets; ++ ++ #[test] ++ fn windows_10_has_no_top_inset_at_any_scale() { ++ for build in [19045, 21999] { ++ for (frame, dpi) in [(8, 96), (10, 120), (12, 144), (16, 192), (24, 288)] { ++ assert_eq!(custom_frame_insets(frame, dpi, build), (frame, 0)); ++ } ++ } ++ } ++ ++ #[test] ++ fn windows_11_preserves_the_first_client_row_at_each_scale() { ++ for build in [22000, 22621, 26100] { ++ for (frame, dpi, top) in [ ++ (8, 96, 1), ++ (10, 120, 1), ++ (12, 144, 2), ++ (14, 168, 2), ++ (16, 192, 2), ++ (20, 240, 3), ++ (24, 288, 3), ++ ] { ++ assert_eq!(custom_frame_insets(frame, dpi, build), (frame, top)); ++ } ++ } ++ } ++ ++ #[test] ++ fn unknown_windows_build_does_not_restore_a_native_titlebar() { ++ assert_eq!(custom_frame_insets(8, 96, 0), (8, 0)); ++ assert_eq!(custom_frame_insets(16, 192, 0), (16, 0)); ++ } ++} diff --git a/apps/desktop-gpui/src/app_windows.rs b/apps/desktop-gpui/src/app_windows.rs index 655475184b9..c86d6097b64 100644 --- a/apps/desktop-gpui/src/app_windows.rs +++ b/apps/desktop-gpui/src/app_windows.rs @@ -613,7 +613,7 @@ pub fn open_settings(page: Page, cx: &mut App) { // hand-draws its own.) titlebar: Some(gpui::TitlebarOptions { title: Some("Cap Settings".into()), - appears_transparent: !cfg!(target_os = "windows"), + appears_transparent: true, traffic_light_position: Some(settings_window::TRAFFIC_LIGHTS), }), // A normal window, not a panel: the Tauri Settings window is an @@ -758,7 +758,7 @@ pub fn open_onboarding(cx: &mut App) { window_bounds: Some(WindowBounds::Windowed(bounds)), titlebar: Some(gpui::TitlebarOptions { title: Some("Welcome to Cap".into()), - appears_transparent: !cfg!(target_os = "windows"), + appears_transparent: true, traffic_light_position: Some(onboarding_window::TRAFFIC_LIGHTS), }), kind: WindowKind::Normal, @@ -892,7 +892,7 @@ pub fn open_mode_select(cx: &mut App) -> bool { // `TitleBarStyle::Overlay`). titlebar: Some(gpui::TitlebarOptions { title: Some("Cap Mode Selection".into()), - appears_transparent: !cfg!(target_os = "windows"), + appears_transparent: true, traffic_light_position: mode_select_window::TRAFFIC_LIGHTS, }), // An ordinary window that activates the dock icon @@ -1007,7 +1007,7 @@ pub fn open_teleprompter(cx: &mut App) { // buttons, moved, as on the settings window. titlebar: Some(gpui::TitlebarOptions { title: Some("Cap Teleprompter".into()), - appears_transparent: !cfg!(target_os = "windows"), + appears_transparent: true, traffic_light_position: Some(teleprompter_window::TRAFFIC_LIGHTS), }), // `alwaysOnTop: true` + `visibleOnAllWorkspaces: true` are applied @@ -2457,7 +2457,7 @@ pub fn open_editor(project_path: PathBuf, cx: &mut App) { // left group reserves an `h-full w-16` spacer for them. titlebar: Some(gpui::TitlebarOptions { title: Some("Cap Editor".into()), - appears_transparent: !cfg!(target_os = "windows"), + appears_transparent: true, traffic_light_position: editor_window::TRAFFIC_LIGHTS, }), // An ordinary window that activates the dock icon @@ -3642,7 +3642,7 @@ pub fn open_screenshot_editor(path: PathBuf, cx: &mut App) { window_bounds: Some(WindowBounds::Windowed(bounds)), titlebar: Some(gpui::TitlebarOptions { title: Some("Cap Screenshot Editor".into()), - appears_transparent: !cfg!(target_os = "windows"), + appears_transparent: true, traffic_light_position: None, }), kind: WindowKind::Normal, diff --git a/apps/desktop-gpui/src/editor_export.rs b/apps/desktop-gpui/src/editor_export.rs index 5969858a529..2deb563b2c0 100644 --- a/apps/desktop-gpui/src/editor_export.rs +++ b/apps/desktop-gpui/src/editor_export.rs @@ -131,6 +131,7 @@ impl ExportResolution { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ExportPhase { Idle, + ChoosingFile, Starting, Rendering, Copying, @@ -143,9 +144,13 @@ impl ExportPhase { pub(crate) fn is_busy(self) -> bool { matches!( self, - Self::Starting | Self::Rendering | Self::Copying | Self::Uploading + Self::ChoosingFile | Self::Starting | Self::Rendering | Self::Copying | Self::Uploading ) } + + fn shows_progress(self) -> bool { + !matches!(self, Self::Idle | Self::ChoosingFile) + } } pub struct PreviewStats { @@ -514,9 +519,15 @@ impl EditorWindow { let project = self.project.clone(); ui.cancel.store(false, Ordering::Relaxed); let cancel = ui.cancel.clone(); - ui.phase = ExportPhase::Starting; + ui.phase = if destination == ExportDestination::File { + ExportPhase::ChoosingFile + } else { + ExportPhase::Starting + }; ui.error = None; ui.rendered = 0; + ui.total_frames = 0; + ui.output_path = None; cx.notify(); ui.export_task = Some(cx.spawn_in(window, async move |this, cx| { @@ -546,12 +557,22 @@ impl EditorWindow { None }; - let _ = this.update(cx, |this, cx| { - if let Some(ui) = this.export.as_mut() { - ui.phase = ExportPhase::Rendering; + let started = this.update(cx, |this, cx| { + let Some(ui) = this.export.as_mut() else { + return false; + }; + if cancel.load(Ordering::Relaxed) { + ui.phase = ExportPhase::Idle; + cx.notify(); + return false; } + ui.phase = ExportPhase::Starting; cx.notify(); + true }); + if !started.unwrap_or(false) { + return; + } let (progress_tx, progress_rx) = flume::unbounded::<(u32, u32)>(); let export_cancel = cancel.clone(); @@ -774,6 +795,7 @@ impl EditorWindow { ui.share_link = None; ui.upload_progress = 0.0; ui.rendered = 0; + ui.total_frames = 0; cx.notify(); ui.export_task = Some(cx.spawn_in(window, async move |this, cx| { @@ -994,9 +1016,12 @@ impl EditorWindow { .child(self.render_export_preview_pane(ui)) .child(self.render_export_sidebar(ui, cx)), ) - .when(ui.phase != ExportPhase::Idle, |this| { + .when(ui.phase.shows_progress(), |this| { this.child(self.render_export_overlay(ui, cx)) }) + .when(ui.phase == ExportPhase::ChoosingFile, |this| { + this.child(div().absolute().inset_0().occlude()) + }) .into_any_element() } @@ -1253,6 +1278,7 @@ impl EditorWindow { ui::ButtonSize::Lg, ) .label(label) + .disabled(ui.phase.is_busy()) .full_width() .on_click(cx.listener(|this, _, window, cx| this.start_export(window, cx))); if let Some(icon) = icon { @@ -1771,7 +1797,7 @@ impl EditorWindow { ExportPhase::Done if ui.destination == ExportDestination::Link => "Upload complete", ExportPhase::Done => "Export complete", ExportPhase::Failed => "Export failed", - ExportPhase::Idle => "", + ExportPhase::Idle | ExportPhase::ChoosingFile => "", }; let mut wash: Hsla = theme.gray(1); @@ -2074,3 +2100,34 @@ async fn run_export( .await } } + +#[cfg(test)] +mod tests { + use super::ExportPhase; + + #[test] + fn choosing_a_file_blocks_duplicate_exports_without_showing_progress() { + assert!(ExportPhase::ChoosingFile.is_busy()); + assert!(!ExportPhase::ChoosingFile.shows_progress()); + } + + #[test] + fn cancelling_file_selection_returns_to_the_idle_page() { + assert!(!ExportPhase::Idle.is_busy()); + assert!(!ExportPhase::Idle.shows_progress()); + } + + #[test] + fn export_work_and_results_remain_visible() { + for phase in [ + ExportPhase::Starting, + ExportPhase::Rendering, + ExportPhase::Copying, + ExportPhase::Uploading, + ExportPhase::Done, + ExportPhase::Failed, + ] { + assert!(phase.shows_progress()); + } + } +} diff --git a/apps/desktop-gpui/src/editor_window.rs b/apps/desktop-gpui/src/editor_window.rs index 0a8fcad7dbe..6b8517ab3bb 100644 --- a/apps/desktop-gpui/src/editor_window.rs +++ b/apps/desktop-gpui/src/editor_window.rs @@ -6984,7 +6984,7 @@ impl EditorWindow { let theme = self.theme; let name_focused = self.name_input.read(cx).focus_handle().is_focused(window); - div() + let header = div() .relative() .flex() .flex_row() @@ -6992,6 +6992,9 @@ impl EditorWindow { .w_full() .h(px(HEADER_HEIGHT)) .flex_none() + .when(cfg!(target_os = "windows"), |header| { + header.window_control_area(gpui::WindowControlArea::Drag) + }) // Left group: `flex flex-row flex-1 gap-2 items-center px-4 h-full`. .child( div() @@ -7003,8 +7006,11 @@ impl EditorWindow { .items_center() .px(px(16.)) .h_full() + .when(cfg!(target_os = "windows"), |group| group.occlude()) // The macOS spacer for the inset traffic lights: `h-full w-16`. - .child(div().h_full().w(px(64.)).flex_none()) + .when(!cfg!(target_os = "windows"), |group| { + group.child(div().h_full().w(px(64.)).flex_none()) + }) .child( ui::EditorButton::plain(&theme, "delete-recording") .left_icon("icons/trash.svg") @@ -7065,7 +7071,15 @@ impl EditorWindow { .child(".cap"), ), ) - .child(div().flex_1().h_full()), + .child( + div() + .flex_1() + .h_full() + .when(cfg!(target_os = "windows"), |area| { + area.occlude() + .window_control_area(gpui::WindowControlArea::Drag) + }), + ), ) // Centre group: `flex flex-row items-center justify-center gap-2 // px-4 border-x border-black-transparent-10`. @@ -7081,6 +7095,7 @@ impl EditorWindow { .border_l_1() .border_r_1() .border_color(gpui::hsla(0., 0., 0., 0.1)) + .when(cfg!(target_os = "windows"), |group| group.occlude()) .child( ui::EditorButton::plain(&theme, "presets") .left_icon("icons/presets.svg") @@ -7111,9 +7126,18 @@ impl EditorWindow { .pl(px(8.)) .pr(px(8.)) .h_full() + .when(cfg!(target_os = "windows"), |group| group.occlude()) .child(self.history_button("editor-undo", "icons/undo.svg", true, cx)) .child(self.history_button("editor-redo", "icons/redo.svg", false, cx)) - .child(div().flex_1().h_full()) + .child( + div() + .flex_1() + .h_full() + .when(cfg!(target_os = "windows"), |area| { + area.occlude() + .window_control_area(gpui::WindowControlArea::Drag) + }), + ) // `Button` (gray), `flex gap-1.5 justify-center h-[40px]`. .child(self.render_clips_pill(cx)) // `` (`Header.tsx:74-77, @@ -7132,7 +7156,18 @@ impl EditorWindow { .then(|| self.header_pill("icons/captions.svg", "Captions")), ) .child(self.render_export_button(cx)), - ) + ); + + #[cfg(target_os = "windows")] + let header = header.child(ui::windows_caption_controls( + theme, + window.is_window_active(), + window.is_maximized(), + true, + true, + )); + + header } /// The Captions toggle: `Button variant="gray"` at diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index 13b984fda76..b0b10d00b7f 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -3,6 +3,8 @@ //! Milestone 1 is the main recording window (compact + expanded) with real //! device enumeration. No tauri, no webview: the whole UI is gpui. +#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")] + mod app_windows; mod assets; mod auth; diff --git a/apps/desktop-gpui/src/main_window.rs b/apps/desktop-gpui/src/main_window.rs index 07a85442453..e033b5acf09 100644 --- a/apps/desktop-gpui/src/main_window.rs +++ b/apps/desktop-gpui/src/main_window.rs @@ -2103,6 +2103,9 @@ impl MainWindow { let theme = self.theme; let header = div() + .when(cfg!(target_os = "windows"), |header| { + header.window_control_area(gpui::WindowControlArea::Drag) + }) .flex() .flex_row() .items_center() @@ -2180,6 +2183,7 @@ impl MainWindow { }; div() + .occlude() .flex() .h_full() .flex_shrink_0() @@ -2327,17 +2331,14 @@ impl MainWindow { ) // Keep drag handlers off the header root: starting native dragging // on a button's mouse-down consumes its later click. - .child( - div() - .id("drag-region") - .flex_1() - .min_w_0() - .h_full() - .when(cfg!(target_os = "windows"), |region| region.h(px(20.))) - .on_mouse_down(gpui::MouseButton::Left, |_, window, _| { + .child(div().id("drag-region").flex_1().min_w_0().h_full().when( + !cfg!(target_os = "windows"), + |region| { + region.on_mouse_down(gpui::MouseButton::Left, |_, window, _| { window.start_window_move(); - }), - ) + }) + }, + )) .child( div() .flex() @@ -2413,26 +2414,7 @@ impl MainWindow { ); #[cfg(target_os = "windows")] - let actions = { - let drag_strip = |id: &'static str| { - div() - .id(id) - .absolute() - .left_0() - .right_0() - .h(px(6.)) - .on_mouse_down(gpui::MouseButton::Left, |_, window, cx| { - window.start_window_move(); - cx.stop_propagation(); - }) - }; - - actions - .relative() - .h_full() - .child(drag_strip("header-drag-top").top_0()) - .child(drag_strip("header-drag-bottom").bottom_0()) - }; + let actions = actions.h_full(); actions } diff --git a/apps/desktop-gpui/src/mode_select_window.rs b/apps/desktop-gpui/src/mode_select_window.rs index 85c29f5e657..b9e7402b3d8 100644 --- a/apps/desktop-gpui/src/mode_select_window.rs +++ b/apps/desktop-gpui/src/mode_select_window.rs @@ -238,7 +238,7 @@ impl Render for ModeSelectWindow { self.sync_appearance(window, cx); let theme = self.theme; - div() + let shell = div() .track_focus(&self.focus) // `flex flex-col relative justify-center items-center min-h-screen // bg-gray-1` @@ -302,7 +302,29 @@ impl Render for ModeSelectWindow { .child(self.render_card(Mode::Studio, cx)) .child(self.render_card(Mode::Screenshot, cx)), ), - ) + ); + + #[cfg(target_os = "windows")] + let shell = shell.child( + div() + .absolute() + .top_0() + .left_0() + .right_0() + .h(px(36.)) + .flex() + .justify_end() + .window_control_area(gpui::WindowControlArea::Drag) + .child(crate::ui::windows_caption_controls( + theme, + window.is_window_active(), + window.is_maximized(), + true, + false, + )), + ); + + shell } } diff --git a/apps/desktop-gpui/src/onboarding_window.rs b/apps/desktop-gpui/src/onboarding_window.rs index 04f8a776f84..5f11deb2bf9 100644 --- a/apps/desktop-gpui/src/onboarding_window.rs +++ b/apps/desktop-gpui/src/onboarding_window.rs @@ -780,6 +780,24 @@ impl Render for OnboardingWindow { self.theme.refresh(window, cx, false); let theme = self.theme; + let header = div().h(px(52.)).w_full().flex_shrink_0(); + #[cfg(target_os = "windows")] + let header = header + .flex() + .justify_end() + .window_control_area(gpui::WindowControlArea::Drag) + .child(div().h(px(36.)).child(ui::windows_caption_controls( + theme, + window.is_window_active(), + window.is_maximized(), + true, + false, + ))); + #[cfg(not(target_os = "windows"))] + let header = header.on_mouse_down(gpui::MouseButton::Left, |_, window, _| { + window.start_window_move(); + }); + div() .size_full() .flex() @@ -788,12 +806,7 @@ impl Render for OnboardingWindow { .bg(Hsla::from(theme.gray_1)) .text_color(Hsla::from(theme.gray_12)) .track_focus(&self.focus) - .child(div().h(px(52.)).w_full().flex_shrink_0().on_mouse_down( - gpui::MouseButton::Left, - |_, window, _| { - window.start_window_move(); - }, - )) + .child(header) .child(match self.step { Step::Welcome => self.render_welcome(cx).into_any_element(), Step::Permissions => self.render_permissions(cx).into_any_element(), diff --git a/apps/desktop-gpui/src/screenshot_editor.rs b/apps/desktop-gpui/src/screenshot_editor.rs index 0f44043c26e..50d904da0d2 100644 --- a/apps/desktop-gpui/src/screenshot_editor.rs +++ b/apps/desktop-gpui/src/screenshot_editor.rs @@ -2763,7 +2763,7 @@ impl ScreenshotEditorWindow { } /// `Header.tsx:109-216`. - fn render_header(&self, cx: &mut Context) -> impl IntoElement { + fn render_header(&self, _window: &Window, cx: &mut Context) -> impl IntoElement { let theme = self.theme; let crop_enabled = self.image_size.is_some(); let exporting = self.exporting; @@ -2774,7 +2774,89 @@ impl ScreenshotEditorWindow { ExportStatus::Idle => "Create shareable link", }; - div() + let tools = div() + .flex() + .flex_row() + .items_center() + .justify_center() + .gap(px(8.)) + .child( + self.anchored( + Anchor::Aspect, + ui::EditorButton::plain(&theme, "screenshot-aspect") + .width(px(80.)) + .left_icon("icons/layout.svg") + .icon_size(px(16.)) + .label(self.aspect_label()) + .right_icon("icons/chevron-down.svg") + .right_icon_end(true) + .pressed( + self.menu + .as_ref() + .is_some_and(|(kind, _)| *kind == MenuKind::Aspect), + ) + .tooltip(&theme, "Aspect Ratio") + .on_click(cx.listener(|this, _, window, cx| { + cx.stop_propagation(); + this.toggle_menu(MenuKind::Aspect, Anchor::Aspect, window, cx); + })), + ), + ) + .child( + ui::EditorButton::plain(&theme, "screenshot-crop") + .left_icon("icons/crop.svg") + .icon_size(px(16.)) + .disabled(!crop_enabled) + .tooltip(&theme, "Crop Image") + .on_click(cx.listener(|this, _, window, cx| { + cx.stop_propagation(); + this.open_crop_dialog(window, cx); + })), + ) + .child(divider(&theme, 24.)) + .child(self.render_annotation_tools(cx)) + .child(divider(&theme, 24.)) + .children( + [ + Popover::Background, + Popover::Padding, + Popover::Rounding, + Popover::Shadow, + Popover::Border, + ] + .map(|popover| { + let button = ui::EditorButton::plain(&theme, popover.id()) + .left_icon(popover.icon()) + .icon_size(px(16.)) + .pressed(self.active_popover == Some(popover)) + .on_click(cx.listener(move |this, _, window, cx| { + cx.stop_propagation(); + this.toggle_popover(popover, window, cx); + })); + // The `kbd` prop rides the tooltip, which + // `ui::EditorButton` does not carry, so the wrapper the + // popover anchors against owns it instead. + self.anchored( + popover.anchor(), + kbd_tooltip(&theme, popover.tooltip(), popover.keys(), button), + ) + .into_any_element() + }), + ); + #[cfg(not(target_os = "windows"))] + let tools = tools.absolute().top_0().left_0().size_full(); + #[cfg(target_os = "windows")] + let tools = div() + .id("screenshot-header-tools") + .flex() + .flex_1() + .min_w_0() + .h(px(32.)) + .overflow_x_scroll() + .occlude() + .child(tools.flex_shrink_0().mx_auto()); + + let header = div() .relative() .flex() .flex_row() @@ -2783,6 +2865,12 @@ impl ScreenshotEditorWindow { .w_full() .h(px(HEADER_HEIGHT)) .px(px(16.)) + .when(cfg!(target_os = "windows"), |header| { + header + .pr_0() + .gap(px(8.)) + .window_control_area(gpui::WindowControlArea::Drag) + }) .flex_shrink_0() .border_b_1() .border_color(theme.gray_3) @@ -2792,86 +2880,14 @@ impl ScreenshotEditorWindow { theme.gray_1 }) // The inset traffic lights' spacer (`Header.tsx:115`). - .child(div().flex().items_center().child(div().w(px(56.)))) + .when(!cfg!(target_os = "windows"), |header| { + header.child(div().flex().items_center().child(div().w(px(56.)))) + }) // `absolute left-1/2 -translate-x-1/2` -- a full-width centred row // is the same placement without a transform, and it is not // interactive itself, so the right cluster painted after it still // takes its own clicks. - .child( - div() - .absolute() - .top_0() - .left_0() - .size_full() - .flex() - .flex_row() - .items_center() - .justify_center() - .gap(px(8.)) - .child( - self.anchored( - Anchor::Aspect, - ui::EditorButton::plain(&theme, "screenshot-aspect") - .width(px(80.)) - .left_icon("icons/layout.svg") - .icon_size(px(16.)) - .label(self.aspect_label()) - .right_icon("icons/chevron-down.svg") - .right_icon_end(true) - .pressed( - self.menu - .as_ref() - .is_some_and(|(kind, _)| *kind == MenuKind::Aspect), - ) - .tooltip(&theme, "Aspect Ratio") - .on_click(cx.listener(|this, _, window, cx| { - cx.stop_propagation(); - this.toggle_menu(MenuKind::Aspect, Anchor::Aspect, window, cx); - })), - ), - ) - .child( - ui::EditorButton::plain(&theme, "screenshot-crop") - .left_icon("icons/crop.svg") - .icon_size(px(16.)) - .disabled(!crop_enabled) - .tooltip(&theme, "Crop Image") - .on_click(cx.listener(|this, _, window, cx| { - cx.stop_propagation(); - this.open_crop_dialog(window, cx); - })), - ) - .child(divider(&theme, 24.)) - .child(self.render_annotation_tools(cx)) - .child(divider(&theme, 24.)) - .children( - [ - Popover::Background, - Popover::Padding, - Popover::Rounding, - Popover::Shadow, - Popover::Border, - ] - .map(|popover| { - let button = ui::EditorButton::plain(&theme, popover.id()) - .left_icon(popover.icon()) - .icon_size(px(16.)) - .pressed(self.active_popover == Some(popover)) - .on_click(cx.listener(move |this, _, window, cx| { - cx.stop_propagation(); - this.toggle_popover(popover, window, cx); - })); - // The `kbd` prop rides the tooltip, which - // `ui::EditorButton` does not carry, so the wrapper the - // popover anchors against owns it instead. - self.anchored( - popover.anchor(), - kbd_tooltip(&theme, popover.tooltip(), popover.keys(), button), - ) - .into_any_element() - }), - ), - ) + .child(tools) .child( div() .flex() @@ -2880,6 +2896,9 @@ impl ScreenshotEditorWindow { .gap(px(8.)) .h_full() .pr(px(8.)) + .when(cfg!(target_os = "windows"), |actions| { + actions.h(px(32.)).flex_shrink_0().occlude() + }) .child(divider(&theme, 24.)) .child( ui::EditorButton::plain(&theme, "screenshot-copy") @@ -2933,7 +2952,18 @@ impl ScreenshotEditorWindow { })), ), ), - ) + ); + + #[cfg(target_os = "windows")] + let header = header.child(ui::windows_caption_controls( + theme, + _window.is_window_active(), + _window.is_maximized(), + true, + true, + )); + + header } // -- Layers panel ----------------------------------------------------------- @@ -4267,7 +4297,7 @@ impl ScreenshotEditorWindow { /// `ScreenshotEditorSkeleton` (`screenshot-editor-skeleton.tsx`): the /// header's three clusters as `rounded-lg` placeholders, and the Cap logo /// spinning over a flat preview. - fn render_skeleton(&self) -> impl IntoElement { + fn render_skeleton(&self, _window: &Window) -> impl IntoElement { let theme = self.theme; let block = move |width: f32| { div() @@ -4281,95 +4311,118 @@ impl ScreenshotEditorWindow { }) }; - div() - .size_full() + let tools = div() .flex() - .flex_col() + .flex_row() + .items_center() + .justify_center() + .gap(px(8.)) + .child(block(96.)) + .child(block(36.)) + .child(divider(&theme, 24.)) + .children((0..5).map(|_| block(36.))) + .child(divider(&theme, 24.)) + .children((0..5).map(|_| block(36.))); + #[cfg(not(target_os = "windows"))] + let tools = tools.absolute().top_0().left_0().size_full(); + #[cfg(target_os = "windows")] + let tools = div() + .id("screenshot-skeleton-tools") + .flex() + .flex_1() + .min_w_0() + .h(px(36.)) + .overflow_x_scroll() + .occlude() + .child(tools.flex_shrink_0().mx_auto()); + + let header = div() + .relative() + .flex() + .flex_row() + .items_center() + .justify_between() + .w_full() + .h(px(HEADER_HEIGHT)) + .px(px(16.)) + .when(cfg!(target_os = "windows"), |header| { + header + .pr_0() + .gap(px(8.)) + .window_control_area(gpui::WindowControlArea::Drag) + }) + .flex_shrink_0() + .border_b_1() + .border_color(theme.gray_3) + .bg(if theme.is_dark() { + theme.gray_2 + } else { + theme.gray_1 + }) + .when(!cfg!(target_os = "windows"), |header| { + header.child(div().w(px(56.))) + }) + .child(tools) .child( div() - .relative() .flex() .flex_row() .items_center() - .justify_between() - .w_full() - .h(px(HEADER_HEIGHT)) - .px(px(16.)) - .flex_shrink_0() - .border_b_1() - .border_color(theme.gray_3) - .bg(if theme.is_dark() { - theme.gray_2 - } else { - theme.gray_1 - }) - .child(div().w(px(56.))) - .child( - div() - .absolute() - .top_0() - .left_0() - .size_full() - .flex() - .flex_row() - .items_center() - .justify_center() - .gap(px(8.)) - .child(block(96.)) - .child(block(36.)) - .child(divider(&theme, 24.)) - .children((0..5).map(|_| block(36.))) - .child(divider(&theme, 24.)) - .children((0..5).map(|_| block(36.))), - ) - .child( - div() - .flex() - .flex_row() - .items_center() - .gap(px(8.)) - .pr(px(8.)) - .child(divider(&theme, 24.)) - .children((0..3).map(|_| block(36.))), - ), - ) - .child( - div() - .flex_1() - .min_h_0() - .relative() - .flex() - .items_center() - .justify_center() - .bg(if theme.is_dark() { - theme.gray_3 - } else { - theme.gray_2 + .gap(px(8.)) + .pr(px(8.)) + .when(cfg!(target_os = "windows"), |actions| { + actions.flex_shrink_0() }) - .child( - div() - .absolute() - .left(px(16.)) - .bottom(px(16.)) - .flex() - .flex_row() - .items_center() - .gap(px(8.)) - .p(px(4.)) - .rounded(px(8.)) - .border_1() - .border_color(theme.gray_4) - .bg(if theme.is_dark() { - theme.gray_3 - } else { - theme.gray_1 - }) - .child(block(36.)) - .child(div().w(px(80.)).h(px(8.)).rounded_full().bg(theme.gray_4)) - .child(block(36.)), - ) - .child(spinning_logo(&theme)), - ) + .child(divider(&theme, 24.)) + .children((0..3).map(|_| block(36.))), + ); + + #[cfg(target_os = "windows")] + let header = header.child(ui::windows_caption_controls( + theme, + _window.is_window_active(), + _window.is_maximized(), + true, + true, + )); + + div().size_full().flex().flex_col().child(header).child( + div() + .flex_1() + .min_h_0() + .relative() + .flex() + .items_center() + .justify_center() + .bg(if theme.is_dark() { + theme.gray_3 + } else { + theme.gray_2 + }) + .child( + div() + .absolute() + .left(px(16.)) + .bottom(px(16.)) + .flex() + .flex_row() + .items_center() + .gap(px(8.)) + .p(px(4.)) + .rounded(px(8.)) + .border_1() + .border_color(theme.gray_4) + .bg(if theme.is_dark() { + theme.gray_3 + } else { + theme.gray_1 + }) + .child(block(36.)) + .child(div().w(px(80.)).h(px(8.)).rounded_full().bg(theme.gray_4)) + .child(block(36.)), + ) + .child(spinning_logo(&theme)), + ) } } @@ -4407,10 +4460,10 @@ impl Render for ScreenshotEditorWindow { .text_color(theme.gray_12); if !self.ready { - return root.child(self.render_skeleton()); + return root.child(self.render_skeleton(window)); } - root.child(self.render_header(cx)) + root.child(self.render_header(window, cx)) .child( div() .flex() diff --git a/apps/desktop-gpui/src/settings_window.rs b/apps/desktop-gpui/src/settings_window.rs index 7339090dab4..8f69b4faad6 100644 --- a/apps/desktop-gpui/src/settings_window.rs +++ b/apps/desktop-gpui/src/settings_window.rs @@ -1456,7 +1456,7 @@ impl Render for SettingsWindow { self.sync_appearance(window, cx); let theme = self.theme; - div() + let shell = div() .track_focus(&self.focus) // `(window-chrome).tsx` binds Cmd-W to `getCurrentWindow().close()` // for every chrome window. Escape is not bound there and is not @@ -1488,9 +1488,47 @@ impl Render for SettingsWindow { .font_family("Geist") // `body { font-weight: 500 }` (`ui-solid/src/main.css:189-192`). .font_weight(FontWeight::MEDIUM) - .text_color(theme.settings_text()) + .text_color(theme.settings_text()); + + #[cfg(target_os = "windows")] + let shell = shell + .flex_col() + .child( + div() + .h(px(36.)) + .w_full() + .flex_shrink_0() + .flex() + .justify_end() + .bg(theme.header_bg()) + .border_b_1() + .border_color(theme.header_border()) + .window_control_area(gpui::WindowControlArea::Drag) + .child(ui::windows_caption_controls( + theme, + window.is_window_active(), + window.is_maximized(), + true, + true, + )), + ) + .child( + div() + .flex() + .flex_row() + .flex_1() + .min_h_0() + .w_full() + .child(self.render_sidebar(cx)) + .child(self.render_content(window, cx)), + ); + + #[cfg(not(target_os = "windows"))] + let shell = shell .child(self.render_sidebar(cx)) - .child(self.render_content(window, cx)) + .child(self.render_content(window, cx)); + + shell // Painted last so it lands over the page: the select menus, and // the drag layer the zoom slider needs while the button is held. .children(self.render_menu(cx)) diff --git a/apps/desktop-gpui/src/teleprompter_window.rs b/apps/desktop-gpui/src/teleprompter_window.rs index 3cc033e897d..67885e84014 100644 --- a/apps/desktop-gpui/src/teleprompter_window.rs +++ b/apps/desktop-gpui/src/teleprompter_window.rs @@ -529,7 +529,7 @@ impl Render for TeleprompterWindow { // `body { font-weight: 500 }` (`ui-solid/src/main.css:189-192`). .font_weight(FontWeight::MEDIUM) .text_color(Hsla::from(theme.gray_12)) - .child(self.render_header()) + .child(self.render_header(window)) .child(self.render_body(window, cx)) .child(self.render_footer(cx)) // `z-30` on the popover, over a footer that makes no stacking @@ -553,10 +553,10 @@ impl TeleprompterWindow { /// `cap-window-header flex h-9 shrink-0 items-center` with the note pushed /// to the trailing edge. The traffic lights are AppKit's, at (14, 14). - fn render_header(&self) -> impl IntoElement { + fn render_header(&self, _window: &Window) -> impl IntoElement { let theme = self.theme; - div() + let header = div() .flex() .flex_row() .items_center() @@ -570,6 +570,14 @@ impl TeleprompterWindow { div() .ml_auto() .mr(px(12.)) + .when(cfg!(target_os = "windows"), |note| { + note.flex_1() + .min_w_0() + .ml(px(12.)) + .mr(px(4.)) + .overflow_hidden() + .whitespace_nowrap() + }) .flex() .flex_row() .items_center() @@ -584,7 +592,20 @@ impl TeleprompterWindow { .text_color(Hsla::from(theme.gray_9)), ) .child("This window is hidden from Cap recordings"), - ) + ); + + #[cfg(target_os = "windows")] + let header = header + .window_control_area(gpui::WindowControlArea::Drag) + .child(ui::windows_caption_controls( + theme, + _window.is_window_active(), + _window.is_maximized(), + true, + true, + )); + + header } /// `cap-window-body relative min-h-0 flex-1 overflow-hidden`: the script diff --git a/apps/desktop-gpui/src/tray.rs b/apps/desktop-gpui/src/tray.rs index 4c991e2c13a..5cff0be6562 100644 --- a/apps/desktop-gpui/src/tray.rs +++ b/apps/desktop-gpui/src/tray.rs @@ -446,10 +446,16 @@ fn open_previous_item(path: PathBuf, cx: &mut App) { } RecordingMetaInner::Instant(_) => { if let Some(sharing) = &meta.sharing { + #[cfg(target_os = "windows")] + cx.open_url(&sharing.link); + #[cfg(not(target_os = "windows"))] open_with_finder(&sharing.link); } else { let mp4 = path.join("content/output.mp4"); if mp4.exists() { + #[cfg(target_os = "windows")] + cx.open_with_system(&mp4); + #[cfg(not(target_os = "windows"))] open_with_finder(&mp4.to_string_lossy()); } } @@ -459,6 +465,7 @@ fn open_previous_item(path: PathBuf, cx: &mut App) { /// `tauri_plugin_opener`'s `open_url` / `open_path`, which on macOS are both /// `open ` -- the same spawn `library::open_recording_folder` uses. +#[cfg(not(target_os = "windows"))] fn open_with_finder(target: &str) { #[cfg(target_os = "macos")] if let Err(error) = std::process::Command::new("open").arg(target).spawn() { @@ -1069,7 +1076,13 @@ mod mac { } } -#[cfg(not(target_os = "macos"))] +#[cfg(target_os = "windows")] +mod windows; + +#[cfg(target_os = "windows")] +pub use windows::*; + +#[cfg(not(any(target_os = "macos", target_os = "windows")))] mod stub { use gpui::App; @@ -1092,7 +1105,7 @@ mod stub { } } -#[cfg(not(target_os = "macos"))] +#[cfg(not(any(target_os = "macos", target_os = "windows")))] pub use stub::*; /// Keeps the `Global` import honest on non-mac builds. diff --git a/apps/desktop-gpui/src/tray/windows.rs b/apps/desktop-gpui/src/tray/windows.rs new file mode 100644 index 00000000000..04de1870557 --- /dev/null +++ b/apps/desktop-gpui/src/tray/windows.rs @@ -0,0 +1,337 @@ +use std::collections::HashMap; + +use gpui::{App, Global}; +use tray_icon::{ + Icon, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent, + menu::{ + self, IconMenuItem, IsMenuItem, Menu, MenuEvent, MenuId, MenuItem, PredefinedMenuItem, + Submenu, + }, +}; + +use super::{ + Entry, PreviousItem, TrayItem, current_menu_entries, handle_item, scan_previous, stop_recording, +}; +use crate::main_window::Mode; + +const TRAY_ID: &str = "cap-gpui-tray"; +const DEFAULT_ICON: &[u8] = + include_bytes!("../../../desktop/src-tauri/icons/tray-default-icon.png"); +const STOP_ICON: &[u8] = include_bytes!("../../assets/tray/tray-stop-icon.png"); + +enum Event { + Menu(MenuId), + Click, +} + +struct NativeMenu { + menu: Menu, + actions: HashMap, +} + +struct Tray { + icon: TrayIcon, + native_menu: NativeMenu, + mode: Mode, + previous: Vec, + recording: bool, + previous_generation: u64, +} + +impl Global for Tray {} + +pub fn init(cx: &mut App) { + if cx.has_global::() { + return; + } + + let mode = Mode::from_store(); + let entries = current_menu_entries(cx, mode, &[]); + let tray = match create_tray(mode, &entries) { + Ok(tray) => tray, + Err(error) => { + tracing::error!("failed to create the Windows tray: {error:#}"); + return; + } + }; + let (tx, rx) = flume::unbounded(); + let menu_tx = tx.clone(); + MenuEvent::set_event_handler(Some(move |event: MenuEvent| { + let _ = menu_tx.send(Event::Menu(event.id)); + })); + TrayIconEvent::set_event_handler(Some(move |event| { + if activates_tray(&event) { + let _ = tx.send(Event::Click); + } + })); + cx.set_global(tray); + + cx.spawn(async move |cx| { + while let Ok(event) = rx.recv_async().await { + cx.update(|cx| { + if !cx.has_global::() { + return; + } + match event { + Event::Menu(id) => { + let item = cx.global::().native_menu.actions.get(&id).cloned(); + if let Some(item) = item { + handle_item(item, cx); + } + } + Event::Click => { + if cx.global::().recording { + stop_recording(cx); + } + } + } + }); + } + }) + .detach(); + + refresh_previous(cx); +} + +fn create_tray(mode: Mode, entries: &[Entry]) -> anyhow::Result { + let native_menu = build_native_menu(entries)?; + let icon = TrayIconBuilder::new() + .with_id(TRAY_ID) + .with_tooltip("Cap") + .with_icon(decode_tray_icon(DEFAULT_ICON)?) + .with_menu(Box::new(native_menu.menu.clone())) + .build()?; + + Ok(Tray { + icon, + native_menu, + mode, + previous: Vec::new(), + recording: false, + previous_generation: 0, + }) +} + +fn activates_tray(event: &TrayIconEvent) -> bool { + matches!( + event, + TrayIconEvent::Click { + id, + button_state: MouseButtonState::Up, + .. + } if id.as_ref() == TRAY_ID + ) +} + +fn decode_tray_icon(bytes: &[u8]) -> anyhow::Result { + let image = image::load_from_memory(bytes)?.into_rgba8(); + let (width, height) = image.dimensions(); + Ok(Icon::from_rgba(image.into_raw(), width, height)?) +} + +fn decode_menu_icon(bytes: &[u8]) -> anyhow::Result { + let image = image::load_from_memory(bytes)?.into_rgba8(); + let (width, height) = image.dimensions(); + Ok(menu::Icon::from_rgba(image.into_raw(), width, height)?) +} + +fn menu_text(title: &str) -> String { + title.replace('&', "&&") +} + +fn build_native_menu(entries: &[Entry]) -> anyhow::Result { + let menu = Menu::new(); + let mut actions = HashMap::new(); + for entry in entries { + let item = build_native_item(entry, &mut actions)?; + menu.append(item.as_ref())?; + } + Ok(NativeMenu { menu, actions }) +} + +fn build_native_item( + entry: &Entry, + actions: &mut HashMap, +) -> anyhow::Result> { + match entry { + Entry::Separator => Ok(Box::new(PredefinedMenuItem::separator())), + Entry::Item { + title, + item, + enabled, + icon, + } => { + let icon = icon + .as_deref() + .and_then(|bytes| match decode_menu_icon(bytes) { + Ok(icon) => Some(icon), + Err(error) => { + tracing::warn!("failed to load a Windows tray thumbnail: {error:#}"); + None + } + }); + let enabled = *enabled && item.is_some(); + let native_item: Box = match icon { + Some(icon) => Box::new(IconMenuItem::new( + menu_text(title), + enabled, + Some(icon), + None, + )), + None => Box::new(MenuItem::new(menu_text(title), enabled, None)), + }; + if enabled && let Some(item) = item { + let _ = actions.insert(native_item.id().clone(), item.clone()); + } + Ok(native_item) + } + Entry::Submenu { + title, + enabled, + items, + } => { + let submenu = Submenu::new(menu_text(title), *enabled); + for entry in items { + let item = build_native_item(entry, actions)?; + submenu.append(item.as_ref())?; + } + Ok(Box::new(submenu)) + } + } +} + +pub fn set_recording(recording: bool, cx: &mut App) { + if !cx.has_global::() { + return; + } + let tray = cx.global_mut::(); + tray.recording = recording; + if recording { + tray.icon.set_menu(None); + } else { + tray.icon + .set_menu(Some(Box::new(tray.native_menu.menu.clone()))); + } + let bytes = if recording { STOP_ICON } else { DEFAULT_ICON }; + match decode_tray_icon(bytes) { + Ok(icon) => { + if let Err(error) = tray.icon.set_icon(Some(icon)) { + tracing::warn!("failed to update the Windows tray icon: {error}"); + } + } + Err(error) => tracing::warn!("failed to decode the Windows tray icon: {error:#}"), + } + let tooltip = if recording { + "Cap - Stop Recording" + } else { + "Cap" + }; + if let Err(error) = tray.icon.set_tooltip(Some(tooltip)) { + tracing::warn!("failed to update the Windows tray tooltip: {error}"); + } +} + +pub fn mode_changed(mode: Mode, cx: &mut App) { + if !cx.has_global::() { + return; + } + cx.global_mut::().mode = mode; + refresh_menu(cx); +} + +pub fn refresh_previous(cx: &mut App) { + if !cx.has_global::() { + return; + } + let tray = cx.global_mut::(); + tray.previous_generation = tray.previous_generation.wrapping_add(1); + let generation = tray.previous_generation; + cx.spawn(async move |cx| { + let previous = cx + .background_executor() + .spawn(async { scan_previous(true) }) + .await; + cx.update(|cx| { + if !cx.has_global::() || cx.global::().previous_generation != generation { + return; + } + cx.global_mut::().previous = previous; + refresh_menu(cx); + }); + }) + .detach(); +} + +pub fn previous_items(cx: &App) -> Vec { + if !cx.has_global::() { + return Vec::new(); + } + cx.global::().previous.clone() +} + +pub fn menu_snapshot(cx: &App) -> Vec { + if !cx.has_global::() { + return Vec::new(); + } + let tray = cx.global::(); + current_menu_entries(cx, tray.mode, &tray.previous) +} + +pub fn refresh_menu(cx: &mut App) { + if !cx.has_global::() { + return; + } + let entries = menu_snapshot(cx); + let native_menu = match build_native_menu(&entries) { + Ok(menu) => menu, + Err(error) => { + tracing::warn!("failed to rebuild the Windows tray menu: {error:#}"); + return; + } + }; + let tray = cx.global_mut::(); + if !tray.recording { + tray.icon.set_menu(Some(Box::new(native_menu.menu.clone()))); + } + tray.native_menu = native_menu; +} + +#[cfg(test)] +mod tests { + use super::*; + use tray_icon::{MouseButton, Rect}; + + #[test] + fn menu_titles_preserve_literal_ampersands() { + assert_eq!( + menu_text("Research & Development"), + "Research && Development" + ); + assert_eq!(menu_text("Studio"), "Studio"); + } + + #[test] + fn recording_stop_uses_one_click_event_for_this_tray() { + for button in [MouseButton::Left, MouseButton::Right, MouseButton::Middle] { + let event = |id: &str, button_state| TrayIconEvent::Click { + id: id.into(), + position: Default::default(), + rect: Rect::default(), + button, + button_state, + }; + assert!(activates_tray(&event(TRAY_ID, MouseButtonState::Up))); + assert!(!activates_tray(&event(TRAY_ID, MouseButtonState::Down))); + assert!(!activates_tray(&event( + "another-tray", + MouseButtonState::Up + ))); + } + assert!(!activates_tray(&TrayIconEvent::DoubleClick { + id: TRAY_ID.into(), + position: Default::default(), + rect: Rect::default(), + button: MouseButton::Left, + })); + } +} diff --git a/apps/desktop-gpui/src/ui/button.rs b/apps/desktop-gpui/src/ui/button.rs index e5c588c73ee..ec8f750a53f 100644 --- a/apps/desktop-gpui/src/ui/button.rs +++ b/apps/desktop-gpui/src/ui/button.rs @@ -503,6 +503,7 @@ pub struct IconButton { border: Option, active: bool, disabled: bool, + occlude: bool, rounded: Option, on_click: Option, } @@ -523,6 +524,7 @@ impl IconButton { border: None, active: false, disabled: false, + occlude: false, rounded: None, on_click: None, } @@ -532,6 +534,7 @@ impl IconButton { /// all, `text-gray-11` going to `text-gray-12` on hover. pub fn header(theme: &Theme, id: impl Into, icon: impl Into) -> Self { Self { + occlude: cfg!(target_os = "windows"), size: px(20.), icon_size: px(16.), idle: theme.gray(11), @@ -634,12 +637,14 @@ impl RenderOnce for IconButton { border, active, disabled, + occlude, rounded, on_click, } = self; div() .id(id) + .when(occlude, |this| this.occlude()) .tab_index(0) .flex() .items_center() diff --git a/apps/desktop-gpui/src/ui/mod.rs b/apps/desktop-gpui/src/ui/mod.rs index e1fd1430327..8035936d772 100644 --- a/apps/desktop-gpui/src/ui/mod.rs +++ b/apps/desktop-gpui/src/ui/mod.rs @@ -63,6 +63,7 @@ mod tab_rail; pub mod text_input; mod toggle; mod tooltip; +mod windows_caption; // Some of these have no call site in this rev. They are the foundation tier // the editor's sidebar unit was blocked on -- `KbdChip` for `EditorButton`'s @@ -121,3 +122,5 @@ pub use text_input::{ pub use toggle::{Toggle, ToggleSize}; #[allow(unused_imports)] pub use tooltip::{Tooltip, TooltipStyle}; +#[cfg(target_os = "windows")] +pub use windows_caption::windows_caption_controls; diff --git a/apps/desktop-gpui/src/ui/progress.rs b/apps/desktop-gpui/src/ui/progress.rs index 953a398fd6f..35e175c0566 100644 --- a/apps/desktop-gpui/src/ui/progress.rs +++ b/apps/desktop-gpui/src/ui/progress.rs @@ -61,17 +61,24 @@ impl CircularProgress { self.text_size = size; self } + + fn percentage_label(&self) -> Option { + self.progress + .filter(|_| self.label) + .map(|fraction| format!("{}%", (fraction * 100.).round() as u32)) + } } impl RenderOnce for CircularProgress { fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { + let percentage_label = self.percentage_label(); let CircularProgress { progress, size, stroke, track, fill, - label, + label: _, text_color, text_size, } = self; @@ -131,12 +138,12 @@ impl RenderOnce for CircularProgress { ) }) })) - .when(label, |this| { + .when_some(percentage_label, |this, label| { this.child( div() .text_size(text_size) .text_color(text_color) - .child(format!("{}%", (fraction * 100.).round() as u32)), + .child(label), ) }) } @@ -144,6 +151,26 @@ impl RenderOnce for CircularProgress { #[cfg(test)] mod tests { + use super::*; + + #[test] + fn indeterminate_progress_has_no_percentage_label() { + let ring = CircularProgress::new(px(80.), px(6.), gpui::black(), gpui::white()) + .label(gpui::white(), px(14.)) + .indeterminate(); + assert_eq!(ring.percentage_label(), None); + } + + #[test] + fn determinate_progress_labels_only_measured_progress() { + for (fraction, expected) in [(0., "0%"), (0.25, "25%"), (1., "100%")] { + let ring = CircularProgress::new(px(80.), px(6.), gpui::black(), gpui::white()) + .label(gpui::white(), px(14.)) + .progress(fraction); + assert_eq!(ring.percentage_label().as_deref(), Some(expected)); + } + } + #[test] fn quadrant_shares_split_the_fraction_evenly() { let share = diff --git a/apps/desktop-gpui/src/ui/windows_caption.rs b/apps/desktop-gpui/src/ui/windows_caption.rs new file mode 100644 index 00000000000..598b66c34a5 --- /dev/null +++ b/apps/desktop-gpui/src/ui/windows_caption.rs @@ -0,0 +1,114 @@ +use gpui::{ + Div, InteractiveElement, ParentElement, StatefulInteractiveElement, Styled, div, + prelude::FluentBuilder, px, rgb, rgba, svg, +}; + +use crate::theme::Theme; + +pub fn windows_caption_controls( + theme: Theme, + active: bool, + maximized: bool, + minimizable: bool, + resizable: bool, +) -> Div { + let dark = theme.is_dark(); + let hover = rgba(if dark { 0xffffff0d } else { 0x0000000d }); + let pressed = rgba(if dark { 0xe9e9e908 } else { 0x00000008 }); + let button = |id: &'static str, icon: &'static str, height: f32, enabled: bool| { + let foreground = Theme::with_alpha( + rgb(if dark { 0xffffff } else { 0x12161f }), + if active && enabled { 0.8 } else { 0.4 }, + ); + + div() + .id(id) + .group(id) + .when(enabled, |button| button.tab_index(0)) + .w(px(46.)) + .h_full() + .flex_shrink_0() + .flex() + .items_center() + .justify_center() + .cursor_default() + .occlude() + .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .when(enabled, |button| { + button + .hover(move |style| { + style.bg(if id == "caption-close" { + rgb(0xc42b1c) + } else { + hover + }) + }) + .active(move |style| { + style.bg(if id == "caption-close" { + rgba(0xc42b1ce6) + } else { + pressed + }) + }) + }) + .child( + svg() + .path(icon) + .id("caption-glyph") + .w(px(10.)) + .h(px(height)) + .text_color(foreground) + .when(id == "caption-close", |icon| { + icon.group_hover("caption-close", |style| style.text_color(gpui::white())) + .group_active("caption-close", |style| style.text_color(gpui::white())) + }), + ) + }; + + div() + .flex() + .h_full() + .flex_shrink_0() + .occlude() + .child( + button( + "caption-minimize", + "icons/caption-minimize-windows.svg", + 1., + minimizable, + ) + .when(minimizable, |button| { + button.on_click(|_, window, _| window.minimize_window()) + }), + ) + .when(resizable, |row| { + row.child( + button( + "caption-maximize", + if maximized { + "icons/caption-restore-windows.svg" + } else { + "icons/caption-maximize-windows.svg" + }, + if maximized { 11. } else { 10. }, + true, + ) + .on_click(|_, window, _| { + if let Some(native) = crate::platform::native_window(window) { + crate::platform::zoom_native(&native); + } + }), + ) + }) + .child( + button( + "caption-close", + "icons/caption-close-windows.svg", + 10., + true, + ) + .on_click(|_, window, cx| { + crate::menus::close_window_by_handle(window.window_handle(), cx); + }), + ) +} From c6365f9bf961fe52644222037fc746045ffc2184 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:56:07 +0100 Subject: [PATCH 3/3] fix: keep gradient stop drags in one undo step --- .../src/editor_sidebar/animated_gradient.rs | 45 +++- .../routes/editor/AnimatedGradientEditor.tsx | 62 ++++-- .../editor/animated-gradient-drag.test.ts | 197 ++++++++++++++++++ .../routes/editor/animated-gradient-drag.ts | 52 +++++ 4 files changed, 327 insertions(+), 29 deletions(-) create mode 100644 apps/desktop/src/routes/editor/animated-gradient-drag.test.ts create mode 100644 apps/desktop/src/routes/editor/animated-gradient-drag.ts diff --git a/apps/desktop-gpui/src/editor_sidebar/animated_gradient.rs b/apps/desktop-gpui/src/editor_sidebar/animated_gradient.rs index 8fa60dab8ef..ffda5323b4f 100644 --- a/apps/desktop-gpui/src/editor_sidebar/animated_gradient.rs +++ b/apps/desktop-gpui/src/editor_sidebar/animated_gradient.rs @@ -590,19 +590,22 @@ impl EditorWindow { let Some(fraction) = ui::fraction_from_x(event.position.x, bounds) else { return; }; - let mut inserted = None; self.close_color_picker(cx); + let Some(mut updated) = self.animated_gradient_config().cloned() else { + return; + }; + let Some(index) = insert_stop(&mut updated, fraction * 100.) else { + return; + }; + self.begin_animated_gradient_stop_drag(index, cx); self.edit_animated_gradient( |config| { - inserted = insert_stop(config, fraction * 100.); - inserted.is_some() + *config = updated; + true }, window, cx, ); - if let Some(index) = inserted { - self.begin_animated_gradient_stop_drag(index, cx); - } } fn add_animated_gradient_stop(&mut self, window: &mut Window, cx: &mut Context) { @@ -1406,6 +1409,36 @@ mod tests { assert_eq!(stop_limits(&config, 4), (75., 100.)); } + #[test] + fn adding_and_dragging_a_colour_is_one_history_entry() { + let mut config = AnimatedGradientConfig::default(); + assert!(remove_stop(&mut config, 2)); + let mut project = ProjectConfiguration::default(); + project.background.source = BackgroundSource::AnimatedGradient { config }; + let initial = project.clone(); + let mut history = crate::editor_edits::ProjectHistory::new(initial.clone()); + let mut drag = ui::SliderDrag::new(); + let BackgroundSource::AnimatedGradient { config } = &mut project.background.source else { + panic!("expected animated gradient"); + }; + let mut updated = config.clone(); + let index = insert_stop(&mut updated, 40.).unwrap(); + drag.begin(SliderKey::AnimatedGradientStop(index), || history.pause()); + *config = updated; + history.record(&project); + let BackgroundSource::AnimatedGradient { config } = &mut project.background.source else { + panic!("expected animated gradient"); + }; + config.color_stops[index].position = 50.; + history.record(&project); + drag.end(|| history.resume(&project)); + assert_eq!( + serde_json::to_value(history.undo().unwrap()).unwrap(), + serde_json::to_value(&initial).unwrap() + ); + assert!(!history.can_undo()); + } + #[test] fn control_values_read_like_the_solid_editor() { let direction = AnimatedGradientParameter::Direction.control(); diff --git a/apps/desktop/src/routes/editor/AnimatedGradientEditor.tsx b/apps/desktop/src/routes/editor/AnimatedGradientEditor.tsx index 4ddb7d6a2dd..91058640118 100644 --- a/apps/desktop/src/routes/editor/AnimatedGradientEditor.tsx +++ b/apps/desktop/src/routes/editor/AnimatedGradientEditor.tsx @@ -26,6 +26,10 @@ import IconLucideSave from "~icons/lucide/save"; import IconLucideShuffle from "~icons/lucide/shuffle"; import IconLucideTrash2 from "~icons/lucide/trash-2"; import IconLucideX from "~icons/lucide/x"; +import { + type PointerDragSession, + startPointerDrag, +} from "./animated-gradient-drag"; import { BrandColorsDropdown } from "./BrandColorsDropdown"; import { hexToRgb, RgbInput } from "./color-utils"; import { useEditorContext } from "./context"; @@ -189,8 +193,11 @@ export function AnimatedGradientEditor(props: { let disposed = false; let revision = 0; let barRef!: HTMLDivElement; + let activeDrag: PointerDragSession | null = null; onCleanup(() => { disposed = true; + activeDrag?.cleanup(); + activeDrag = null; }); const config = createMemo(() => { @@ -297,26 +304,26 @@ export function AnimatedGradientEditor(props: { ); }; - const dragStop = (index: number, event: PointerEvent) => { + const dragStop = ( + index: () => number | null, + event: PointerEvent, + options?: { pauseImmediately?: boolean; onStart?: () => void }, + ) => { const target = event.currentTarget as HTMLElement; - target.setPointerCapture(event.pointerId); - let resume: (() => void) | null = null; - const move = (moveEvent: PointerEvent) => { - const position = barPosition(moveEvent); - if (position === null) return; - if (!resume) resume = projectHistory.pause(); - moveStop(index, position); - }; - const end = () => { - target.removeEventListener("pointermove", move); - target.removeEventListener("pointerup", end); - target.removeEventListener("pointercancel", end); - resume?.(); - resume = null; - }; - target.addEventListener("pointermove", move); - target.addEventListener("pointerup", end); - target.addEventListener("pointercancel", end); + activeDrag?.cleanup(); + activeDrag = startPointerDrag({ + target, + pointerId: event.pointerId, + pauseHistory: projectHistory.pause, + pauseImmediately: options?.pauseImmediately, + onStart: options?.onStart, + onMove: (moveEvent) => { + const position = barPosition(moveEvent); + const stopIndex = index(); + if (position !== null && stopIndex !== null) + moveStop(stopIndex, position); + }, + }); }; const resetFineTune = () => { @@ -550,9 +557,18 @@ export function AnimatedGradientEditor(props: { onPointerDown={(event) => { if (event.button !== 0) return; const position = barPosition(event); - if (position === null) return; - const index = addStop(position); - if (index !== null) dragStop(index, event); + if ( + position === null || + current().colorStops.length >= MAX_STOPS + ) + return; + let index: number | null = null; + dragStop(() => index, event, { + pauseImmediately: true, + onStart: () => { + index = addStop(position); + }, + }); }} >
index, event); }} onKeyDown={(event) => { const step = event.shiftKey ? 10 : 1; diff --git a/apps/desktop/src/routes/editor/animated-gradient-drag.test.ts b/apps/desktop/src/routes/editor/animated-gradient-drag.test.ts new file mode 100644 index 00000000000..f57879a67c0 --- /dev/null +++ b/apps/desktop/src/routes/editor/animated-gradient-drag.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it, vi } from "vitest"; +import { startPointerDrag } from "./animated-gradient-drag"; + +type GradientState = { + stops: number[]; +}; + +function createHistory(initial: GradientState) { + let state = structuredClone(initial); + let pauseDepth = 0; + const snapshots = [structuredClone(state)]; + const commit = () => { + if ( + JSON.stringify(snapshots[snapshots.length - 1]) !== JSON.stringify(state) + ) + snapshots.push(structuredClone(state)); + }; + + return { + get state() { + return state; + }, + get snapshotCount() { + return snapshots.length; + }, + get paused() { + return pauseDepth > 0; + }, + mutate(update: (value: GradientState) => void) { + update(state); + if (pauseDepth === 0) commit(); + }, + pause() { + pauseDepth += 1; + let resumed = false; + return () => { + if (resumed) return; + resumed = true; + pauseDepth -= 1; + if (pauseDepth === 0) commit(); + }; + }, + undo() { + if (snapshots.length > 1) snapshots.pop(); + state = structuredClone(snapshots[snapshots.length - 1]); + }, + }; +} + +function createTarget() { + const target = new EventTarget() as EventTarget & { + setPointerCapture: ReturnType; + }; + target.setPointerCapture = vi.fn(); + return target; +} + +function pointerEvent(type: string, clientX = 0, pointerId = 7) { + const event = new Event(type); + Object.defineProperties(event, { + clientX: { value: clientX }, + pointerId: { value: pointerId }, + }); + return event; +} + +function asElement(target: EventTarget) { + return target as unknown as HTMLElement; +} + +describe("startPointerDrag", () => { + it("groups adding and dragging a stop into one undo entry", () => { + const target = createTarget(); + const history = createHistory({ stops: [0, 100] }); + let stopIndex = -1; + startPointerDrag({ + target: asElement(target), + pointerId: 7, + pauseHistory: history.pause, + pauseImmediately: true, + onStart: () => + history.mutate((state) => { + stopIndex = 1; + state.stops.splice(stopIndex, 0, 30); + }), + onMove: (event) => + history.mutate((state) => { + state.stops[stopIndex] = event.clientX; + }), + }); + + target.dispatchEvent(pointerEvent("pointermove", 40)); + target.dispatchEvent(pointerEvent("pointerup", 40)); + + expect(history.state.stops).toEqual([0, 40, 100]); + expect(history.snapshotCount).toBe(2); + history.undo(); + expect(history.state.stops).toEqual([0, 100]); + }); + + it("groups dragging an existing stop into one undo entry", () => { + const target = createTarget(); + const history = createHistory({ stops: [0, 30, 100] }); + startPointerDrag({ + target: asElement(target), + pointerId: 7, + pauseHistory: history.pause, + onMove: (event) => + history.mutate((state) => { + state.stops[1] = event.clientX; + }), + }); + + target.dispatchEvent(pointerEvent("pointermove", 40)); + target.dispatchEvent(pointerEvent("pointermove", 50)); + target.dispatchEvent(pointerEvent("pointerup", 50)); + + expect(history.snapshotCount).toBe(2); + history.undo(); + expect(history.state.stops).toEqual([0, 30, 100]); + }); + + it("keeps a click without movement atomic", () => { + const addTarget = createTarget(); + const addHistory = createHistory({ stops: [0, 100] }); + startPointerDrag({ + target: asElement(addTarget), + pointerId: 7, + pauseHistory: addHistory.pause, + pauseImmediately: true, + onStart: () => addHistory.mutate((state) => state.stops.splice(1, 0, 30)), + onMove: () => undefined, + }); + addTarget.dispatchEvent(pointerEvent("pointerup", 30)); + + expect(addHistory.snapshotCount).toBe(2); + addHistory.undo(); + expect(addHistory.state.stops).toEqual([0, 100]); + + const existingTarget = createTarget(); + const existingHistory = createHistory({ stops: [0, 30, 100] }); + startPointerDrag({ + target: asElement(existingTarget), + pointerId: 7, + pauseHistory: existingHistory.pause, + onMove: () => undefined, + }); + existingTarget.dispatchEvent(pointerEvent("pointerup", 30)); + expect(existingHistory.snapshotCount).toBe(1); + }); + + it.each(["pointercancel", "lostpointercapture"])( + "resumes history and removes listeners on %s", + (endEvent) => { + const target = createTarget(); + const history = createHistory({ stops: [0, 30, 100] }); + startPointerDrag({ + target: asElement(target), + pointerId: 7, + pauseHistory: history.pause, + onMove: (event) => + history.mutate((state) => { + state.stops[1] = event.clientX; + }), + }); + + target.dispatchEvent(pointerEvent("pointermove", 40)); + target.dispatchEvent(pointerEvent(endEvent, 40)); + target.dispatchEvent(pointerEvent("pointermove", 50)); + + expect(history.paused).toBe(false); + expect(history.state.stops).toEqual([0, 40, 100]); + }, + ); + + it("resumes history and removes listeners on unmount cleanup", () => { + const target = createTarget(); + const history = createHistory({ stops: [0, 30, 100] }); + const session = startPointerDrag({ + target: asElement(target), + pointerId: 7, + pauseHistory: history.pause, + onMove: (event) => + history.mutate((state) => { + state.stops[1] = event.clientX; + }), + }); + + target.dispatchEvent(pointerEvent("pointermove", 40)); + session.cleanup(); + session.cleanup(); + target.dispatchEvent(pointerEvent("pointermove", 50)); + + expect(history.paused).toBe(false); + expect(history.state.stops).toEqual([0, 40, 100]); + }); +}); diff --git a/apps/desktop/src/routes/editor/animated-gradient-drag.ts b/apps/desktop/src/routes/editor/animated-gradient-drag.ts new file mode 100644 index 00000000000..ddfa6526733 --- /dev/null +++ b/apps/desktop/src/routes/editor/animated-gradient-drag.ts @@ -0,0 +1,52 @@ +export type PointerDragSession = { + cleanup: () => void; +}; + +export function startPointerDrag(options: { + target: HTMLElement; + pointerId: number; + pauseHistory: () => () => void; + pauseImmediately?: boolean; + onStart?: () => void; + onMove: (event: PointerEvent) => void; +}): PointerDragSession { + let active = true; + let resumeHistory: (() => void) | null = null; + + const pauseHistory = () => { + if (!resumeHistory) resumeHistory = options.pauseHistory(); + }; + const move = (event: PointerEvent) => { + if (event.pointerId !== options.pointerId) return; + pauseHistory(); + options.onMove(event); + }; + const cleanup = () => { + if (!active) return; + active = false; + options.target.removeEventListener("pointermove", move); + options.target.removeEventListener("pointerup", end); + options.target.removeEventListener("pointercancel", end); + options.target.removeEventListener("lostpointercapture", end); + resumeHistory?.(); + resumeHistory = null; + }; + const end = (event: PointerEvent) => { + if (event.pointerId === options.pointerId) cleanup(); + }; + + options.target.addEventListener("pointermove", move); + options.target.addEventListener("pointerup", end); + options.target.addEventListener("pointercancel", end); + options.target.addEventListener("lostpointercapture", end); + try { + options.target.setPointerCapture(options.pointerId); + if (options.pauseImmediately) pauseHistory(); + options.onStart?.(); + } catch (error) { + cleanup(); + throw error; + } + + return { cleanup }; +}