From 553854bff1ecd44e9a61235731434f6b436ff839 Mon Sep 17 00:00:00 2001 From: Moonbot-Tech Date: Fri, 6 Nov 2026 17:49:31 +0300 Subject: [PATCH 01/19] fix(calendar): keep sixth-week dates in their own month grid Calendar selection lost the last days of months that require six weeks. Flattening multiple months into five-row chunks also assigned later rows to the wrong month. Generate complete Sunday-first weeks, then render each month as its own column while preserving the existing five-row minimum. Keep the Longbridge calendar lifecycle and record this intentional Mirror to TrackedFork correction in the existing component migration ledger. Move the existing tests to sibling modules and add real rendered selection coverage for August 30/31 beside September in both Dark and Light themes. Validation: all 624 component tests, gallery/font and component API/mirror guardrails pass. The new grid coverage fails when five fixed rows return. --- .../component-manifest.json | 5 +- .../moon-ui-components/src/time/calendar.rs | 85 +++------ .../src/time/calendar/tests.rs | 84 +++++++++ crates/moon-ui-components/src/time/utils.rs | 165 ++---------------- .../src/time/utils/tests.rs | 113 ++++++++++++ docs/component-class-migrations.json | 6 + docs/component-mirror-baseline.json | 12 +- 7 files changed, 260 insertions(+), 210 deletions(-) create mode 100644 crates/moon-ui-components/src/time/calendar/tests.rs create mode 100644 crates/moon-ui-components/src/time/utils/tests.rs diff --git a/crates/moon-ui-components/component-manifest.json b/crates/moon-ui-components/component-manifest.json index 157759c..8f50e0d 100644 --- a/crates/moon-ui-components/component-manifest.json +++ b/crates/moon-ui-components/component-manifest.json @@ -176,12 +176,13 @@ }, { "concept": "calendar", - "class": "Mirror", + "class": "TrackedFork", "behavior_source": "Longbridge Calendar", "theme_source": "MoonTheme bridge through MoonCalendar facade", "public_path": "moon_ui::MoonCalendar", "upstream_ref": "Longbridge::time::calendar@cda0fc7fd4e4809dd2a8bae0337ac43b49e9f675", - "fork_reason": null, + "fork_reason": "Reviewed calendar correctness fork: complete five/six-week month grids preserve every selectable date and month boundaries in multi-month views", + "donor_drift_budget": 1, "contracts": ["gallery.visual_coverage"] }, { diff --git a/crates/moon-ui-components/src/time/calendar.rs b/crates/moon-ui-components/src/time/calendar.rs index 5ca8632..e2e5c0f 100644 --- a/crates/moon-ui-components/src/time/calendar.rs +++ b/crates/moon-ui-components/src/time/calendar.rs @@ -410,11 +410,12 @@ impl CalendarState { (year, month as u32) } - /// Returns the days of the month in a 2D vector to render on calendar. - fn days(&self) -> Vec> { + /// Return each displayed month as a separate week grid, preserving five/six-row boundaries. + fn days(&self) -> Vec>> { (0..self.number_of_months) - .flat_map(|offset| { - days_in_month(self.current_year, self.current_month as u32 + offset as u32) + .map(|offset| { + let (year, month) = self.offset_year_month(offset); + days_in_month(year, month) }) .collect() } @@ -581,6 +582,11 @@ impl Calendar { window, cx, ) + .map(|button| { + #[cfg(test)] + let button = button.debug_selector(move || format!("calendar-day-{date_id}")); + button + }) .when(is_today && !is_active, |this| { this.border_1().border_color(cx.theme().border) }) // Add border for today @@ -782,6 +788,7 @@ impl Calendar { .child(label.into()) } + /// Render independent month columns so a sixth week cannot spill into the next month. fn render_days(&self, window: &mut Window, cx: &mut App) -> impl IntoElement { let state = self.state.read(cx); let weeks = [ @@ -801,29 +808,23 @@ impl Calendar { _ => this.gap_4().text_sm(), }) .justify_between() - .children( - state - .days() - .chunks(5) - .enumerate() - .map(|(offset_month, days)| { - v_flex() - .gap_0p5() - .child( - h_flex().gap_0p5().justify_between().children( - weeks - .iter() - .map(|week| self.render_week(week.clone(), window, cx)), - ), - ) - .children(days.iter().map(|week| { - h_flex().gap_0p5().justify_between().children( - week.iter() - .map(|d| self.render_day(d, offset_month, window, cx)), - ) - })) - }), - ) + .children(state.days().iter().enumerate().map(|(offset_month, days)| { + v_flex() + .gap_0p5() + .child( + h_flex().gap_0p5().justify_between().children( + weeks + .iter() + .map(|week| self.render_week(week.clone(), window, cx)), + ), + ) + .children(days.iter().map(|week| { + h_flex().gap_0p5().justify_between().children( + week.iter() + .map(|d| self.render_day(d, offset_month, window, cx)), + ) + })) + })) } fn render_week(&self, week: impl Into, _: &mut Window, cx: &mut App) -> Div { @@ -983,32 +984,4 @@ impl RenderOnce for Calendar { } #[cfg(test)] -mod tests { - use chrono::NaiveDate; - - use super::Date; - - #[test] - fn test_date_to_string() { - let date = Date::Single(Some(NaiveDate::from_ymd_opt(2024, 8, 3).unwrap())); - assert_eq!(date.to_string(), "2024-08-03"); - - let date = Date::Single(None); - assert_eq!(date.to_string(), "nil"); - - let date = Date::Range( - Some(NaiveDate::from_ymd_opt(2024, 8, 3).unwrap()), - Some(NaiveDate::from_ymd_opt(2024, 8, 5).unwrap()), - ); - assert_eq!(date.to_string(), "2024-08-03 - 2024-08-05"); - - let date = Date::Range(Some(NaiveDate::from_ymd_opt(2024, 8, 3).unwrap()), None); - assert_eq!(date.to_string(), "2024-08-03 - nil"); - - let date = Date::Range(None, Some(NaiveDate::from_ymd_opt(2024, 8, 5).unwrap())); - assert_eq!(date.to_string(), "nil - 2024-08-05"); - - let date = Date::Range(None, None); - assert_eq!(date.to_string(), "nil"); - } -} +mod tests; diff --git a/crates/moon-ui-components/src/time/calendar/tests.rs b/crates/moon-ui-components/src/time/calendar/tests.rs new file mode 100644 index 0000000..795ebfd --- /dev/null +++ b/crates/moon-ui-components/src/time/calendar/tests.rs @@ -0,0 +1,84 @@ +use chrono::NaiveDate; + +use super::Date; + +#[test] +fn test_date_to_string() { + let date = Date::Single(Some(NaiveDate::from_ymd_opt(2024, 8, 3).unwrap())); + assert_eq!(date.to_string(), "2024-08-03"); + + let date = Date::Single(None); + assert_eq!(date.to_string(), "nil"); + + let date = Date::Range( + Some(NaiveDate::from_ymd_opt(2024, 8, 3).unwrap()), + Some(NaiveDate::from_ymd_opt(2024, 8, 5).unwrap()), + ); + assert_eq!(date.to_string(), "2024-08-03 - 2024-08-05"); + + let date = Date::Range(Some(NaiveDate::from_ymd_opt(2024, 8, 3).unwrap()), None); + assert_eq!(date.to_string(), "2024-08-03 - nil"); + + let date = Date::Range(None, Some(NaiveDate::from_ymd_opt(2024, 8, 5).unwrap())); + assert_eq!(date.to_string(), "nil - 2024-08-05"); + + let date = Date::Range(None, None); + assert_eq!(date.to_string(), "nil"); +} + +use super::{Calendar, CalendarState}; +use crate::moon::{MoonTheme, ThemeMode}; +use gpui::{AppContext as _, Context, Entity, IntoElement, Render, VisualTestContext, Window}; + +/// Render two real month columns through the public Calendar builder. +struct MonthGridHarness(Entity); +impl Render for MonthGridHarness { + /// Keep adjacent five/six-week grids mounted together to detect boundary spill. + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + Calendar::new(&self.0).number_of_months(2) + } +} + +/// Five fixed weeks lose August 30/31; flattening then chunking at five mixes September's month +/// offset. Clicking the rendered sixth-week date must emit/select the exact requested date. +#[gpui::test] +fn sixth_week_dates_remain_selectable_beside_next_month(cx: &mut gpui::TestAppContext) { + cx.update(crate::init); + for theme in [ThemeMode::Dark, ThemeMode::Light] { + cx.update(|cx| MoonTheme::set_mode(theme, cx)); + let window = cx.add_window(|window, cx| { + let state = cx.new(|cx| CalendarState::new(window, cx)); + state.update(cx, |state, cx| { + state.set_date(NaiveDate::from_ymd_opt(2026, 8, 1).unwrap(), window, cx) + }); + MonthGridHarness(state) + }); + let state = window.read_with(cx, |view, _| view.0.clone()).unwrap(); + let mut visual = VisualTestContext::from_window(window.into(), cx); + visual.run_until_parked(); + for (date, selector) in [ + ("2026-08-30", "calendar-day-2026-08-30_0"), + ("2026-08-31", "calendar-day-2026-08-31_0"), + ] { + let bounds = visual + .debug_bounds(selector) + .expect("sixth-week date must render"); + visual.simulate_click(bounds.center(), gpui::Modifiers::default()); + visual.run_until_parked(); + visual.update(|_, cx| { + assert_eq!( + state.read(cx).date(), + Date::Single(Some(date.parse().unwrap())) + ) + }); + } + let august = visual.debug_bounds("calendar-day-2026-08-01_0").unwrap(); + let september = visual + .debug_bounds("calendar-day-2026-09-01_1") + .expect("independent next month"); + assert!( + september.left() > august.right(), + "month columns must remain separate" + ); + } +} diff --git a/crates/moon-ui-components/src/time/utils.rs b/crates/moon-ui-components/src/time/utils.rs index b9f47a6..88fff29 100644 --- a/crates/moon-ui-components/src/time/utils.rs +++ b/crates/moon-ui-components/src/time/utils.rs @@ -28,154 +28,25 @@ impl NaiveDateExt for chrono::NaiveDate { } } +/// Return complete Sunday-first weeks for a month, retaining the five-row minimum. +/// +/// Month zero and months past December are normalized across year boundaries. +/// Panics when the requested year or the surrounding grid exceeds chrono's date range. pub(crate) fn days_in_month(year: i32, month: u32) -> Vec> { - let mut year = year; - let mut month = month; - if month > 12 { - year += 1; - month = 1; - } - if month < 1 { - year -= 1; - month = 12; - } - - let date = NaiveDate::from_ymd_opt(year, month, 1).unwrap(); - let num_days = date.days_in_month(); - let start_weekday = date.weekday().num_days_from_sunday(); - - // Get the days in the month, 2023-02 will returns - // "29|30|31| 1| 2| 3| 4", - // " 5| 6| 7| 8| 9|10|11", - // "12|13|14|15|16|17|18", - // "19|20|21|22|23|24|25", - // "26|27|28| 1| 2| 3| 4", - let mut days = vec![]; - for n in 0..5 { - let mut week_days = vec![]; - for weekday in 0..7 { - let (mut y, mut m) = (year, month); - - // If the day is less than the start weekday, we need to go back to the previous month. - if n == 0 && weekday < start_weekday { - m = if m == 1 { 12 } else { m - 1 }; - y = if m == 1 { year - 1 } else { y }; - } - - // If start_weekday is 3, and n is 0 and weekday is 3, then day is 1. - // If start_weekday is 3, and n is 1 and weekday is 4, then day is 9. - let day = n * 7 + weekday as i32 - start_weekday as i32; - - // If the day is greater than the number of days in the month, we need to go to the next month. - if day > num_days { - m = if m == 12 { 1 } else { m + 1 }; - y = if m == 1 { year + 1 } else { y }; - } - - #[allow(clippy::expect_fun_call)] - let date = date - .checked_add_signed(Duration::days(day as i64)) - .expect(&format!("invalid date {}-{} days {}", y, m, day)); - week_days.push(date); - } - - days.push(week_days); - } - - days + let offset = i64::from(month) - 1; + let year = year + offset.div_euclid(12) as i32; + let month = offset.rem_euclid(12) as u32 + 1; + let first = NaiveDate::from_ymd_opt(year, month, 1).unwrap(); + let leading = first.weekday().num_days_from_sunday() as i32; + let weeks = ((leading + first.days_in_month() + 6) / 7).max(5); + (0..weeks) + .map(|week| { + (0..7) + .map(|day| first + Duration::days(i64::from(week * 7 + day - leading))) + .collect() + }) + .collect() } #[cfg(test)] -mod tests { - use chrono::{Datelike, NaiveDate}; - - use super::{NaiveDateExt, days_in_month}; - - #[test] - fn test_days_in_month() { - assert_eq!( - NaiveDate::from_ymd_opt(2024, 2, 1).unwrap().days_in_month(), - 29 - ); - assert_eq!( - NaiveDate::from_ymd_opt(2023, 2, 1).unwrap().days_in_month(), - 28 - ); - assert_eq!( - NaiveDate::from_ymd_opt(2023, 1, 1).unwrap().days_in_month(), - 31 - ); - assert_eq!( - NaiveDate::from_ymd_opt(2023, 4, 1).unwrap().days_in_month(), - 30 - ); - } - - #[test] - fn test_days() { - #[track_caller] - fn assert_case(date: NaiveDate, expected: Vec<&str>) { - let out = days_in_month(date.year(), date.month()) - .iter() - .map(|week| { - week.iter() - .map(|d| { - if d.year() == date.year() && d.month() == date.month() { - format!("{:2}", d.day()) - } else if d.year() == date.year() { - format!("{}-{}", d.month(), d.day()) - } else { - format!("{}-{}-{}", d.year(), d.month(), d.day()) - } - }) - .collect::>() - .join("|") - }) - .collect::>(); - - assert_eq!(out, expected); - } - - assert_case( - NaiveDate::from_ymd_opt(2024, 8, 1).unwrap(), - vec![ - "7-28|7-29|7-30|7-31| 1| 2| 3", - " 4| 5| 6| 7| 8| 9|10", - "11|12|13|14|15|16|17", - "18|19|20|21|22|23|24", - "25|26|27|28|29|30|31", - ], - ); - assert_case( - NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(), - vec![ - "2024-12-29|2024-12-30|2024-12-31| 1| 2| 3| 4", - " 5| 6| 7| 8| 9|10|11", - "12|13|14|15|16|17|18", - "19|20|21|22|23|24|25", - "26|27|28|29|30|31|2-1", - ], - ); - - assert_case( - NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(), - vec![ - "1-28|1-29|1-30|1-31| 1| 2| 3", - " 4| 5| 6| 7| 8| 9|10", - "11|12|13|14|15|16|17", - "18|19|20|21|22|23|24", - "25|26|27|28|29|3-1|3-2", - ], - ); - assert_case( - NaiveDate::from_ymd_opt(2023, 2, 20).unwrap(), - vec![ - "1-29|1-30|1-31| 1| 2| 3| 4", - " 5| 6| 7| 8| 9|10|11", - "12|13|14|15|16|17|18", - "19|20|21|22|23|24|25", - "26|27|28|3-1|3-2|3-3|3-4", - ], - ); - } -} +mod tests; diff --git a/crates/moon-ui-components/src/time/utils/tests.rs b/crates/moon-ui-components/src/time/utils/tests.rs new file mode 100644 index 0000000..a4080fa --- /dev/null +++ b/crates/moon-ui-components/src/time/utils/tests.rs @@ -0,0 +1,113 @@ +use chrono::{Datelike, NaiveDate}; + +use super::{NaiveDateExt, days_in_month}; + +#[test] +fn test_days_in_month() { + assert_eq!( + NaiveDate::from_ymd_opt(2024, 2, 1).unwrap().days_in_month(), + 29 + ); + assert_eq!( + NaiveDate::from_ymd_opt(2023, 2, 1).unwrap().days_in_month(), + 28 + ); + assert_eq!( + NaiveDate::from_ymd_opt(2023, 1, 1).unwrap().days_in_month(), + 31 + ); + assert_eq!( + NaiveDate::from_ymd_opt(2023, 4, 1).unwrap().days_in_month(), + 30 + ); +} + +#[test] +fn test_days() { + #[track_caller] + fn assert_case(date: NaiveDate, expected: Vec<&str>) { + let out = days_in_month(date.year(), date.month()) + .iter() + .map(|week| { + week.iter() + .map(|d| { + if d.year() == date.year() && d.month() == date.month() { + format!("{:2}", d.day()) + } else if d.year() == date.year() { + format!("{}-{}", d.month(), d.day()) + } else { + format!("{}-{}-{}", d.year(), d.month(), d.day()) + } + }) + .collect::>() + .join("|") + }) + .collect::>(); + + assert_eq!(out, expected); + } + + assert_case( + NaiveDate::from_ymd_opt(2024, 8, 1).unwrap(), + vec![ + "7-28|7-29|7-30|7-31| 1| 2| 3", + " 4| 5| 6| 7| 8| 9|10", + "11|12|13|14|15|16|17", + "18|19|20|21|22|23|24", + "25|26|27|28|29|30|31", + ], + ); + assert_case( + NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(), + vec![ + "2024-12-29|2024-12-30|2024-12-31| 1| 2| 3| 4", + " 5| 6| 7| 8| 9|10|11", + "12|13|14|15|16|17|18", + "19|20|21|22|23|24|25", + "26|27|28|29|30|31|2-1", + ], + ); + + assert_case( + NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(), + vec![ + "1-28|1-29|1-30|1-31| 1| 2| 3", + " 4| 5| 6| 7| 8| 9|10", + "11|12|13|14|15|16|17", + "18|19|20|21|22|23|24", + "25|26|27|28|29|3-1|3-2", + ], + ); + assert_case( + NaiveDate::from_ymd_opt(2023, 2, 20).unwrap(), + vec![ + "1-29|1-30|1-31| 1| 2| 3| 4", + " 5| 6| 7| 8| 9|10|11", + "12|13|14|15|16|17|18", + "19|20|21|22|23|24|25", + "26|27|28|3-1|3-2|3-3|3-4", + ], + ); +} + +/// Replacing the required sixth week with five rows hides August 30/31 from date selection. +#[test] +fn every_day_is_present_once_in_calendar_weeks() { + for year in [2024, 2026, 2100] { + for month in 1..=12 { + let weeks = days_in_month(year, month); + for day in 1..=NaiveDate::from_ymd_opt(year, month, 1) + .unwrap() + .days_in_month() + { + let date = NaiveDate::from_ymd_opt(year, month, day as u32).unwrap(); + assert_eq!( + weeks.iter().flatten().filter(|&&d| d == date).count(), + 1, + "{date}" + ); + } + assert!(weeks.iter().all(|week| week.len() == 7)); + } + } +} diff --git a/docs/component-class-migrations.json b/docs/component-class-migrations.json index 1a9052a..3219ef1 100644 --- a/docs/component-class-migrations.json +++ b/docs/component-class-migrations.json @@ -1,6 +1,12 @@ { "version": 1, "migrations": [ + { + "concept": "calendar", + "from": "Mirror", + "to": "TrackedFork", + "reason": "The reviewed calendar correction keeps every selectable date in five/six-week months and preserves separate month grids in multi-month views. The Longbridge lifecycle remains, but these tested base-file changes are intentional donor drift, not a clean Mirror." + }, { "concept": "notification", "from": "Pending", diff --git a/docs/component-mirror-baseline.json b/docs/component-mirror-baseline.json index 9bb7107..4e528ba 100644 --- a/docs/component-mirror-baseline.json +++ b/docs/component-mirror-baseline.json @@ -141,18 +141,18 @@ }, { "concept": "calendar", - "class": "Mirror", + "class": "TrackedFork", "public_path": "moon_ui::MoonCalendar", "upstream_ref": "Longbridge::time::calendar@cda0fc7fd4e4809dd2a8bae0337ac43b49e9f675", "local_paths": [ "time/calendar.rs" ], - "local_hash": "cbbde77dcd5f0b76", + "local_hash": "3405d3aedda4aa94", "local_files": [ { "path": "time/calendar.rs", - "hash": "c1eb7c19d501c575", - "bytes": 33765 + "hash": "963bb9dcc995a018", + "bytes": 32871 } ], "donor_hash": "cbbde77dcd5f0b76", @@ -163,7 +163,9 @@ "bytes": 33765 } ], - "donor_changed_files": [] + "donor_changed_files": [ + "time/calendar.rs" + ] }, { "concept": "checkbox", From ad6f4d9b9e7a6d7ffd14f295191145bf532f8907 Mon Sep 17 00:00:00 2001 From: Moonbot-Tech Date: Fri, 6 Nov 2026 17:50:25 +0300 Subject: [PATCH 02/19] fix(runtime): preserve mid-draw invalidation and let unchanged controls settle A notification from prepaint or paint could leave dirty views queued while the common frame-entry predicate reported a clean window. A separate layout model also lost its observer notification, so waking a frame alone could not invalidate the cached view that consumed the measurement. Treat queued views as draw debt everywhere and publish model notifications through the existing deferred effect queue in every draw phase. This makes previously hidden no-op notification loops observable; remove Input's paint-time notify and make scroll, placeholder, loading, list search mode, and resizable measured geometry notify only when their visible state changes. No additional timer or parallel scheduling state is introduced. Validation: mid-prepaint/paint and cached-model regressions pass; real Input and Resizable forms settle after edits in Dark and Light. Native Windows FireTest applies text/width without a rescue event, then observes two quiet native callbacks with zero renders. Restoring the old dirty predicate or Input paint notification independently makes that native probe fail. --- crates/moon-gpui/src/window.rs | 16 +- crates/moon-gpui/src/window/tests.rs | 165 ++++++++++++++++++ .../moon-ui-components/src/input/element.rs | 4 +- .../src/input/element/regression_tests.rs | 112 +++++++++++- crates/moon-ui-components/src/input/state.rs | 22 ++- crates/moon-ui-components/src/list/list.rs | 7 +- .../moon-ui-components/src/list/list/tests.rs | 36 ++++ .../moon-ui-components/src/resizable/mod.rs | 6 +- 8 files changed, 343 insertions(+), 25 deletions(-) diff --git a/crates/moon-gpui/src/window.rs b/crates/moon-gpui/src/window.rs index 4a0d5d0..c07594b 100644 --- a/crates/moon-gpui/src/window.rs +++ b/crates/moon-gpui/src/window.rs @@ -180,28 +180,29 @@ impl WindowInvalidator { } } + /// Retain frame debt and notify observers even when the changed entity is a layout model. pub fn invalidate_view(&self, entity: EntityId, cx: &mut App) -> bool { let mut inner = self.inner.borrow_mut(); inner.update_count += 1; inner.dirty_views.insert(entity); + // A model has no dispatch-tree view path; its observers must invalidate their views. + // Effects are flushed after the current update, so this does not re-enter the draw. + cx.push_effect(Effect::Notify { emitter: entity }); if inner.draw_phase == DrawPhase::None { Self::record_frame_dirty(&mut inner); inner.dirty = true; Self::request_frame_inner(&inner); - cx.push_effect(Effect::Notify { emitter: entity }); true } else { false } } + /// Whether the UI owes a draw, including views changed during the preceding draw. + /// Frame dispatch, keyboard dispatch and test flushing must consume the same debt. pub fn is_dirty(&self) -> bool { - self.inner.borrow().dirty - } - - /// Views invalidated while a draw was in progress; they still owe a frame. - pub fn has_dirty_views(&self) -> bool { - !self.inner.borrow().dirty_views.is_empty() + let inner = self.inner.borrow(); + inner.dirty || !inner.dirty_views.is_empty() } pub fn set_dirty(&self, dirty: bool) { @@ -1727,7 +1728,6 @@ impl Window { // refused mid-draw, a callback queued for the next frame, a present // still owed) must wake the frame clock again. if window.invalidator.is_dirty() - || window.invalidator.has_dirty_views() || !window.next_frame_callbacks.borrow().is_empty() || window.needs_present.get() { diff --git a/crates/moon-gpui/src/window/tests.rs b/crates/moon-gpui/src/window/tests.rs index bd9a328..cd9de7a 100644 --- a/crates/moon-gpui/src/window/tests.rs +++ b/crates/moon-gpui/src/window/tests.rs @@ -21,6 +21,171 @@ use crate::{ TestAppContext, TouchPhase, UTF16Selection, Window, div, gpu_canvas, point, px, size, }; +/// A view that invalidates once from prepaint or paint after its first stable frame. +struct MidDrawNotification { + armed: Rc>, + renders: Rc>, + during_paint: bool, +} + +impl Render for MidDrawNotification { + /// Register the one-shot mutation in the selected draw phase. + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + self.renders.set(self.renders.get() + 1); + let entity = cx.entity_id(); + let prepaint = self.armed.clone(); + let paint = self.armed.clone(); + let during_paint = self.during_paint; + crate::canvas( + move |_, _, cx| { + if !during_paint && prepaint.replace(false) { + cx.notify(entity); + } + }, + move |_, _, _, cx| { + if during_paint && paint.replace(false) { + cx.notify(entity); + } + }, + ) + .size(px(40.)) + } +} + +/// Catches ignoring the queued views in the frame-entry predicate: a measured layout change +/// would remain invisible until unrelated input, even though the frame clock was woken for it. +#[test] +fn mid_draw_notification_is_drawn_without_another_event() { + for during_paint in [false, true] { + let armed = Rc::new(Cell::new(false)); + let renders = Rc::new(Cell::new(0)); + let (mut app, any) = open_window({ + let armed = armed.clone(); + let renders = renders.clone(); + move |_, _| MidDrawNotification { + armed, + renders, + during_paint, + } + }); + app.update_window(any, |_, window, cx| { + let before = renders.get(); + armed.set(true); + window.draw(cx).clear(); + assert!( + window.invalidator.is_dirty(), + "a mid-draw change still owes a frame" + ); + // The platform's next tick must take the UI branch without another input event. + if window.invalidator.is_dirty() { + window.draw(cx).clear(); + } + assert_eq!( + renders.get() - before, + 2, + "one applying frame follows the mutation" + ); + assert!( + !window.invalidator.is_dirty(), + "the consumed debt must settle" + ); + }) + .unwrap(); + } +} + +/// A cached consumer observes a separate model, as table hosts observe their layout state. +struct MidDrawModelConsumer { + model: crate::Entity, + armed: Rc>, + seen: Rc>, + during_paint: bool, +} + +impl Render for MidDrawModelConsumer { + /// Publish one layout measurement while recording the model value actually rendered. + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + self.seen.set(*self.model.read(cx)); + let prepaint_model = self.model.clone(); + let paint_model = self.model.clone(); + let prepaint = self.armed.clone(); + let paint = self.armed.clone(); + let during_paint = self.during_paint; + crate::canvas( + move |_, _, cx| { + if !during_paint && prepaint.replace(false) { + prepaint_model.update(cx, |value, cx| { + *value += 1; + cx.notify(); + }); + } + }, + move |_, _, _, cx| { + if during_paint && paint.replace(false) { + paint_model.update(cx, |value, cx| { + *value += 1; + cx.notify(); + }); + } + }, + ) + .size(px(40.)) + } +} + +/// The model itself has no view path, so only its observer can invalidate this cached child. +struct CachedModelHost(crate::Entity); + +impl Render for CachedModelHost { + /// Use the production view cache rather than repainting every consumer unconditionally. + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div().child(crate::AnyView::from(self.0.clone()).cached(crate::StyleRefinement::default())) + } +} + +/// Queuing a frame alone cannot deliver a model notification: the observer must also run. +#[test] +fn mid_draw_model_notification_reaches_its_cached_observer() { + for during_paint in [false, true] { + let armed = Rc::new(Cell::new(false)); + let seen = Rc::new(Cell::new(0)); + let (mut app, window) = open_window({ + let armed = armed.clone(); + let seen = seen.clone(); + move |_, cx| { + let model = cx.new(|_| 0_usize); + CachedModelHost(cx.new(|cx| { + cx.observe(&model, |_, _, cx| cx.notify()).detach(); + MidDrawModelConsumer { + model, + armed, + seen, + during_paint, + } + })) + } + }); + app.update_window(window, |_, window, cx| { + armed.set(true); + window.refresh(); + window.draw(cx).clear(); + }) + .unwrap(); + assert_eq!( + seen.get(), + 1, + "the cached consumer must draw the measured value without input" + ); + app.update_window(window, |_, window, _| { + assert!( + !window.invalidator.is_dirty(), + "the observer and frame debt must settle" + ); + }) + .unwrap(); + } +} + /// A root view that draws nothing; the window's own state is what these tests read. struct EmptyView; diff --git a/crates/moon-ui-components/src/input/element.rs b/crates/moon-ui-components/src/input/element.rs index 309b751..c59dd87 100644 --- a/crates/moon-ui-components/src/input/element.rs +++ b/crates/moon-ui-components/src/input/element.rs @@ -1964,6 +1964,8 @@ impl Element for TextElement { } } + /// Paint the prepared input and retain geometry for input handling. Geometry already used + /// by this frame does not invalidate it; scroll or wrapping changes request their own draw. fn paint( &mut self, _id: Option<&GlobalElementId>, @@ -2240,8 +2242,6 @@ impl Element for TextElement { state.scroll_size = prepaint.scroll_size; state.update_scroll_offset(Some(prepaint.cursor_scroll_offset), cx); state.deferred_scroll_offset = None; - - cx.notify(); }); if let Some(hitbox) = prepaint.hover_definition_hitbox.as_ref() { diff --git a/crates/moon-ui-components/src/input/element/regression_tests.rs b/crates/moon-ui-components/src/input/element/regression_tests.rs index 92ce70f..386b72d 100644 --- a/crates/moon-ui-components/src/input/element/regression_tests.rs +++ b/crates/moon-ui-components/src/input/element/regression_tests.rs @@ -1,6 +1,10 @@ //! Single-line prepaint regressions, including nonzero glyph advances absent from NoopTextSystem. -use std::{cell::RefCell, rc::Rc, sync::Arc}; +use std::{ + cell::{Cell, RefCell}, + rc::Rc, + sync::Arc, +}; use gpui::{ AppContext as _, Bounds, Context, Entity, FontId, GlyphId, InteractiveElement as _, @@ -12,9 +16,113 @@ use super::TextElement; use crate::{ Root, h_flex, input::{InputState, blink_cursor::CURSOR_WIDTH, display_map::LineLayout}, - moon::{MoonNumberInput, MoonSize, MoonTheme, ThemeMode}, + moon::{MoonInput, MoonNumberInput, MoonSize, MoonTheme, ThemeMode}, }; +/// A real input in resizable panels, counting the enclosing view's actual renders. +struct SettlingInputHarness { + state: Entity, + panels: Entity, + renders: Rc>, +} + +impl Render for SettlingInputHarness { + /// Reapply unchanged builder properties just as Select and ordinary application views do. + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + self.renders.set(self.renders.get() + 1); + // A regression may loop inside the initial window draw before the test can poll idle. + assert!( + self.renders.get() < 128, + "unchanged layout entered a render loop" + ); + self.state.update(cx, |state, cx| { + state.set_placeholder("hint", window, cx); + state.set_loading(false, window, cx); + }); + gpui::div().w(px(480.)).h(px(80.)).child( + crate::resizable::h_resizable("settling-input-panels") + .with_state(&self.panels) + .child( + crate::resizable::resizable_panel() + .size(px(240.)) + .child(MoonInput::new("settling-input").state(&self.state)), + ) + .child(crate::resizable::resizable_panel().child("neighbor")), + ) + } +} + +/// Catches restoring paint-time Input notifications, unconditional scroll/placeholder/loading +/// notifications, or unconditional measured-panel notifications: an unchanged form would keep +/// rebuilding after the frame scheduler starts honoring mid-draw debt. Real edits and resizes +/// must still update the input and then settle in both themes. +#[gpui::test] +fn input_and_resizable_layout_settle_after_edits(cx: &mut TestAppContext) { + cx.update(crate::init); + for theme in [ThemeMode::Dark, ThemeMode::Light] { + cx.update(|cx| MoonTheme::set_mode(theme, cx)); + let renders = Rc::new(Cell::new(0)); + let slot = Rc::new(RefCell::new(None)); + let window = cx.add_window({ + let renders = renders.clone(); + let slot = slot.clone(); + move |window, cx| { + let state = cx.new(|cx| InputState::new(window, cx).default_value("before")); + let panels = cx.new(|_| crate::resizable::ResizableState::default()); + slot.replace(Some((state.clone(), panels.clone()))); + let view = cx.new(|_| SettlingInputHarness { + state, + panels, + renders, + }); + Root::new(view, window, cx).bordered(false) + } + }); + let (input, panels) = slot.borrow().clone().expect("controls were created"); + let mut visual = VisualTestContext::from_window(window.into(), cx); + assert_form_settles(&mut visual, &renders); + let before = renders.get(); + let width_before = visual.update(|_, cx| input.read(cx).input_bounds.size.width); + visual.update(|window, cx| { + input.update(cx, |state, cx| state.set_value("after", window, cx)); + panels.update(cx, |state, cx| state.resize_panel(0, px(160.), window, cx)); + }); + assert_form_settles(&mut visual, &renders); + assert!( + renders.get() > before, + "the changed form must actually render" + ); + visual.update(|_, cx| { + let state = input.read(cx); + assert_eq!(state.value().as_ref(), "after"); + assert!( + state.input_bounds.size.width < width_before, + "resize must reach the input layout" + ); + assert!( + state.last_layout.is_some(), + "input geometry must still be retained" + ); + }); + } +} + +/// Give genuine one-shot layout corrections a bounded opportunity to finish, then test idle. +fn assert_form_settles(visual: &mut VisualTestContext, renders: &Cell) { + for _ in 0..8 { + visual.update(|_, _| {}); + } + let settled = renders.get(); + for _ in 0..4 { + visual.update(|_, _| {}); + } + assert_eq!( + renders.get(), + settled, + "unchanged layout must not owe another render" + ); +} + /// Hosts the actual Xs number builder using caller-owned state and an optional unit. struct NumberHarness { state: Entity, diff --git a/crates/moon-ui-components/src/input/state.rs b/crates/moon-ui-components/src/input/state.rs index 872dacf..1443f80 100644 --- a/crates/moon-ui-components/src/input/state.rs +++ b/crates/moon-ui-components/src/input/state.rs @@ -802,20 +802,19 @@ impl InputState { self.mode.diagnostics_mut() } - /// Set placeholder + /// Update the placeholder, invalidating only when the visible hint changes. pub fn set_placeholder( &mut self, placeholder: impl Into, _: &mut Window, cx: &mut Context, ) { - self.placeholder = placeholder.into(); - cx.notify(); + self.sync_placeholder(placeholder, cx); } /// Set the placeholder only when it differs, so a caller that re-applies the same text on /// every render — a translated hint following a live locale switch — does not schedule a - /// redraw each frame. Unlike [`Self::set_placeholder`] it needs no window. + /// redraw each frame. This window-free form also backs [`Self::set_placeholder`]. pub fn sync_placeholder( &mut self, placeholder: impl Into, @@ -1243,13 +1242,15 @@ impl InputState { self.number_max = max; } - /// Set true to show spinner at the input right. + /// Set true to show the spinner at the input right; unchanged values do not invalidate. /// /// Only for [`InputMode::SingleLine`] mode. pub fn set_loading(&mut self, loading: bool, _: &mut Window, cx: &mut Context) { debug_assert!(self.mode.is_single_line()); - self.loading = loading; - cx.notify(); + if self.loading != loading { + self.loading = loading; + cx.notify(); + } } /// Set the default value of the input field. @@ -1969,6 +1970,7 @@ impl InputState { self.diagnostic_popover = None; } + /// Clamp scrolling to the current content and request a draw only if the offset changes. pub(super) fn update_scroll_offset( &mut self, offset: Option>, @@ -1993,8 +1995,10 @@ impl InputState { offset.y.clamp(safe_y_range.start, safe_y_range.end) }; offset.x = offset.x.clamp(safe_x_range.start, safe_x_range.end); - self.scroll_handle.set_offset(offset); - cx.notify(); + if self.scroll_handle.offset() != offset { + self.scroll_handle.set_offset(offset); + cx.notify(); + } } /// Scroll to make the given offset visible. diff --git a/crates/moon-ui-components/src/list/list.rs b/crates/moon-ui-components/src/list/list.rs index b0380ee..b52d56a 100644 --- a/crates/moon-ui-components/src/list/list.rs +++ b/crates/moon-ui-components/src/list/list.rs @@ -127,9 +127,12 @@ where self } + /// Show or hide search, without invalidating a list whose mode is unchanged. pub fn set_searchable(&mut self, searchable: bool, cx: &mut Context) { - self.searchable = searchable; - cx.notify(); + if self.searchable != searchable { + self.searchable = searchable; + cx.notify(); + } } /// Sets whether the list is selectable, default is true. diff --git a/crates/moon-ui-components/src/list/list/tests.rs b/crates/moon-ui-components/src/list/list/tests.rs index 5768930..a49f6c7 100644 --- a/crates/moon-ui-components/src/list/list/tests.rs +++ b/crates/moon-ui-components/src/list/list/tests.rs @@ -16,6 +16,42 @@ use crate::{ list::{ListDelegate, ListItem, cache::RowEntry}, }; +/// Reapplying Select/ComboBox search mode must not publish another list change. Switching +/// the mode must still publish, otherwise the search field remains stale until another event. +#[gpui::test] +fn search_mode_publishes_only_transitions(cx: &mut gpui::TestAppContext) { + cx.update(crate::init); + let slot = Rc::new(RefCell::new(None)); + cx.add_window({ + let slot = slot.clone(); + move |window, cx| { + let list = cx.new(|cx| { + ListState::new( + OptionalChromeDelegate { + item_renders: Rc::new(Cell::new(0)), + }, + window, + cx, + ) + }); + slot.replace(Some(list.clone())); + OptionalChromeHarness { list } + } + }); + let list = slot.borrow().clone().expect("list was created"); + let notifications = Rc::new(Cell::new(0)); + let _subscription = cx.update(|cx| { + cx.observe(&list, { + let notifications = notifications.clone(); + move |_, _| notifications.set(notifications.get() + 1) + }) + }); + for (searchable, expected) in [(false, 0), (true, 1), (true, 1), (false, 2), (false, 2)] { + cx.update(|cx| list.update(cx, |state, cx| state.set_searchable(searchable, cx))); + assert_eq!(notifications.get(), expected); + } +} + /// Delegate with a headerless global section, one headed section, and a large trailing section. struct OptionalChromeDelegate { /// Number of item elements constructed across measurement and visible rendering. diff --git a/crates/moon-ui-components/src/resizable/mod.rs b/crates/moon-ui-components/src/resizable/mod.rs index ba90d10..8fb2924 100644 --- a/crates/moon-ui-components/src/resizable/mod.rs +++ b/crates/moon-ui-components/src/resizable/mod.rs @@ -196,6 +196,8 @@ impl ResizableState { } } + /// Retain measured bounds and constraints for dragging. Only a new initial flex basis + /// needs another layout; re-recording the geometry just painted must converge to idle. pub(crate) fn update_panel_size( &mut self, panel_ix: usize, @@ -207,13 +209,13 @@ impl ResizableState { // This check is only necessary to stop the very first panel from resizing on its own // it needs to be passed when the panel is freshly created so we get the initial size, // but its also fine when it sometimes passes later. - if self.sizes[panel_ix].as_f32() == PANEL_MIN_SIZE.as_f32() { + if self.sizes[panel_ix] == PANEL_MIN_SIZE && self.panels[panel_ix].size != Some(size) { self.sizes[panel_ix] = size; self.panels[panel_ix].size = Some(size); + cx.notify(); } self.panels[panel_ix].bounds = bounds; self.panels[panel_ix].size_range = size_range; - cx.notify(); } pub(crate) fn remove_panel(&mut self, panel_ix: usize, cx: &mut Context) { From 9c7e968d981b2c8735abebcf8b0293ec3037cb60 Mon Sep 17 00:00:00 2001 From: Moonbot-Tech Date: Fri, 6 Nov 2026 17:51:13 +0300 Subject: [PATCH 03/19] fix(render): retain presentation debt until the native backend accepts it Window cleared needs_present even when the backend skipped a recovering, occluded, or unavailable surface. Without further input the valid scene could then remain unpresented. Return acceptance from PlatformWindow::draw and keep that existing debt armed until the native frame clock succeeds. Carry the contract through DirectX, Metal, X11, Wayland, Web and the test platform. Do not prepare canvas text while a window is unpresentable; the restored frame prepares it once presentation becomes possible. Record input latency only after an accepted present. Validation: real frame-callback tests cover deferred draws, wake requests, hidden canvas text and recovery without rescue input; the full 220-test runtime suite and integration test pass. Windows FireTest covers native clocks and recovery, and Hel1 covers X11/Wayland software surfaces. Metal and Web have source parity here; native Metal validation remains a separate platform check and is not claimed by the Windows run. --- .../src/linux/wayland/window.rs | 5 +- .../moon-gpui-linux/src/linux/x11/window.rs | 7 +- crates/moon-gpui-macos/src/metal_renderer.rs | 10 +- crates/moon-gpui-macos/src/window.rs | 4 +- crates/moon-gpui-web/src/window.rs | 4 +- .../moon-gpui-windows/src/directx_renderer.rs | 14 +- crates/moon-gpui-windows/src/window.rs | 5 +- crates/moon-gpui/src/platform.rs | 4 +- crates/moon-gpui/src/platform/test/window.rs | 68 +++++++- crates/moon-gpui/src/window.rs | 11 +- crates/moon-gpui/src/window/tests.rs | 154 ++++++++++++++++++ 11 files changed, 253 insertions(+), 33 deletions(-) diff --git a/crates/moon-gpui-linux/src/linux/wayland/window.rs b/crates/moon-gpui-linux/src/linux/wayland/window.rs index 1342f5a..1c3a9b9 100644 --- a/crates/moon-gpui-linux/src/linux/wayland/window.rs +++ b/crates/moon-gpui-linux/src/linux/wayland/window.rs @@ -1469,7 +1469,7 @@ impl PlatformWindow for WaylandWindow { self.0.callbacks.borrow_mut().button_layout_changed = Some(callback); } - fn draw(&self, scene: &Scene) { + fn draw(&self, scene: &Scene) -> bool { let mut state = self.borrow_mut(); if state.renderer.device_lost() { @@ -1491,7 +1491,7 @@ impl PlatformWindow for WaylandWindow { } state.force_render_after_recovery = true; - return; + return false; } state.renderer_presented = state.renderer.draw(scene); @@ -1499,6 +1499,7 @@ impl PlatformWindow for WaylandWindow { if state.renderer.needs_redraw() { state.force_render_after_recovery = true; } + state.renderer_presented } fn set_gpu_canvas_active(&self, active: bool) { diff --git a/crates/moon-gpui-linux/src/linux/x11/window.rs b/crates/moon-gpui-linux/src/linux/x11/window.rs index f829be7..0e09d61 100644 --- a/crates/moon-gpui-linux/src/linux/x11/window.rs +++ b/crates/moon-gpui-linux/src/linux/x11/window.rs @@ -1689,7 +1689,7 @@ impl PlatformWindow for X11Window { self.0.callbacks.borrow_mut().button_layout_changed = Some(callback); } - fn draw(&self, scene: &Scene) { + fn draw(&self, scene: &Scene) -> bool { let mut inner = self.0.state.borrow_mut(); if inner.renderer.device_lost() { @@ -1709,14 +1709,15 @@ impl PlatformWindow for X11Window { } inner.force_render_after_recovery = true; - return; + return false; } - inner.renderer.draw(scene); + let accepted = inner.renderer.draw(scene); if inner.renderer.needs_redraw() { inner.force_render_after_recovery = true; } + accepted } fn sprite_atlas(&self) -> Arc { diff --git a/crates/moon-gpui-macos/src/metal_renderer.rs b/crates/moon-gpui-macos/src/metal_renderer.rs index 1f2ef98..69286e8 100644 --- a/crates/moon-gpui-macos/src/metal_renderer.rs +++ b/crates/moon-gpui-macos/src/metal_renderer.rs @@ -551,14 +551,15 @@ impl MetalRenderer { // nothing to do } - pub fn draw(&mut self, scene: &Scene) { + /// Return whether a drawable was acquired and its frame submitted for presentation. + pub fn draw(&mut self, scene: &Scene) -> bool { let layer = match &self.layer { Some(l) => l.clone(), None => { log::error!( "draw() called on headless renderer - use render_scene_to_image() instead" ); - return; + return false; } }; let viewport_size = layer.drawable_size(); @@ -573,7 +574,7 @@ impl MetalRenderer { "failed to retrieve next drawable, drawable size: {:?}", viewport_size ); - return; + return false; }; loop { @@ -605,7 +606,7 @@ impl MetalRenderer { command_buffer.present_drawable(drawable); command_buffer.commit(); } - return; + return true; } Err(err) => { log::error!( @@ -626,6 +627,7 @@ impl MetalRenderer { } } } + false } /// Renders the scene to a texture and returns the pixel data as an RGBA image. diff --git a/crates/moon-gpui-macos/src/window.rs b/crates/moon-gpui-macos/src/window.rs index 664ce29..c27467c 100644 --- a/crates/moon-gpui-macos/src/window.rs +++ b/crates/moon-gpui-macos/src/window.rs @@ -1792,9 +1792,9 @@ impl PlatformWindow for MacWindow { self.0.as_ref().lock().toggle_tab_bar_callback = Some(callback); } - fn draw(&self, scene: &gpui::Scene) { + fn draw(&self, scene: &gpui::Scene) -> bool { let mut this = self.0.lock(); - this.renderer.draw(scene); + this.renderer.draw(scene) } fn sprite_atlas(&self) -> Arc { diff --git a/crates/moon-gpui-web/src/window.rs b/crates/moon-gpui-web/src/window.rs index aa8f782..65c6de0 100644 --- a/crates/moon-gpui-web/src/window.rs +++ b/crates/moon-gpui-web/src/window.rs @@ -666,7 +666,7 @@ impl PlatformWindow for WebWindow { self.inner.callbacks.borrow_mut().appearance_changed = Some(callback); } - fn draw(&self, scene: &Scene) { + fn draw(&self, scene: &Scene) -> bool { if let Some((width, height)) = self.inner.pending_physical_size.take() { if self.inner.canvas.width() != width || self.inner.canvas.height() != height { self.inner.canvas.set_width(width); @@ -681,7 +681,7 @@ impl PlatformWindow for WebWindow { drop(state); } - self.inner.state.borrow_mut().renderer.draw(scene); + self.inner.state.borrow_mut().renderer.draw(scene) } fn completed_frame(&self) { diff --git a/crates/moon-gpui-windows/src/directx_renderer.rs b/crates/moon-gpui-windows/src/directx_renderer.rs index 883f718..00284f2 100644 --- a/crates/moon-gpui-windows/src/directx_renderer.rs +++ b/crates/moon-gpui-windows/src/directx_renderer.rs @@ -544,7 +544,8 @@ impl DirectXRenderer { } #[inline] - fn present(&mut self) -> Result<()> { + /// Report occlusion separately from a present accepted by DXGI. + fn present(&mut self) -> Result { let result = unsafe { self.resources .as_ref() @@ -554,12 +555,12 @@ impl DirectXRenderer { }; if result == DXGI_STATUS_OCCLUDED { self.presentable = false; - return Ok(()); + return Ok(false); } result.ok().context("Presenting swap chain failed")?; self.presentable = true; - Ok(()) + Ok(true) } pub(crate) fn handle_device_lost(&mut self, directx_devices: &DirectXDevices) -> Result<()> { @@ -640,19 +641,20 @@ impl DirectXRenderer { Ok(()) } + /// Draw only while presentable and return whether DXGI accepted the resulting frame. pub(crate) fn draw( &mut self, scene: &Scene, background_appearance: WindowBackgroundAppearance, clear_color: Option, - ) -> Result<()> { + ) -> Result { if self.skip_draws { // skip drawing this frame, we just recovered from a device lost event // and so likely do not have the textures anymore that are required for drawing - return Ok(()); + return Ok(false); } if !self.can_present() { - return Ok(()); + return Ok(false); } self.run_gpu_canvas_prepare(&scene.gpu_canvases_under_scene, GpuCanvasLayer::UnderScene); self.run_gpu_canvas_prepare(&scene.gpu_canvases_over_scene, GpuCanvasLayer::OverScene); diff --git a/crates/moon-gpui-windows/src/window.rs b/crates/moon-gpui-windows/src/window.rs index 7231528..6caa628 100644 --- a/crates/moon-gpui-windows/src/window.rs +++ b/crates/moon-gpui-windows/src/window.rs @@ -1007,7 +1007,7 @@ impl PlatformWindow for WindowsWindow { self.state.renderer.borrow_mut().can_present() } - fn draw(&self, scene: &Scene) { + fn draw(&self, scene: &Scene) -> bool { self.state .renderer .borrow_mut() @@ -1016,7 +1016,8 @@ impl PlatformWindow for WindowsWindow { self.state.background_appearance.get(), self.state.clear_color.get(), ) - .log_err(); + .log_err() + .unwrap_or(false) } fn sprite_atlas(&self) -> Arc { diff --git a/crates/moon-gpui/src/platform.rs b/crates/moon-gpui/src/platform.rs index 828bfdc..5f00006 100644 --- a/crates/moon-gpui/src/platform.rs +++ b/crates/moon-gpui/src/platform.rs @@ -703,7 +703,9 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle { fn frame_diagnostics(&self) -> Option { None } - fn draw(&self, scene: &Scene); + /// Submit the scene and return whether the backend accepted its presentation. + /// A false result leaves the frame owed until a later native tick can accept it. + fn draw(&self, scene: &Scene) -> bool; fn completed_frame(&self) {} fn sprite_atlas(&self) -> Arc; fn is_subpixel_rendering_supported(&self) -> bool; diff --git a/crates/moon-gpui/src/platform/test/window.rs b/crates/moon-gpui/src/platform/test/window.rs index e2c0986..50d1dfd 100644 --- a/crates/moon-gpui/src/platform/test/window.rs +++ b/crates/moon-gpui/src/platform/test/window.rs @@ -1,6 +1,6 @@ use crate::{ AnyWindowHandle, AtlasKey, AtlasTextureId, AtlasTile, Bounds, DevicePixels, - DispatchEventResult, GpuSpecs, Pixels, PlatformAtlas, PlatformDisplay, + DispatchEventResult, GpuSpecs, Pixels, PlatformAtlas, PlatformDisplay, PlatformFrameRequester, PlatformHeadlessRenderer, PlatformInput, PlatformInputHandler, PlatformWindow, Point, PromptButton, RequestFrameOptions, Rgba, Scene, Size, TestPlatform, TileId, WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowParams, @@ -12,7 +12,10 @@ use parking_lot::Mutex; use raw_window_handle::{HasDisplayHandle, HasWindowHandle}; use std::{ rc::{Rc, Weak}, - sync::{self, Arc}, + sync::{ + self, Arc, + atomic::{AtomicUsize, Ordering}, + }, }; pub(crate) struct TestWindowState { @@ -35,6 +38,21 @@ pub(crate) struct TestWindowState { moved_callback: Option>, input_handler: Option, is_fullscreen: bool, + request_frame_callback: Option>, + pub(crate) frame_requests: Arc, + pub(crate) deferred_presents: usize, + pub(crate) draw_attempts: usize, + pub(crate) presentable: bool, +} + +/// Count requests without scheduling a timer; tests explicitly deliver the next platform tick. +#[derive(Default)] +pub(crate) struct TestFrameRequests(pub(crate) AtomicUsize); + +impl PlatformFrameRequester for TestFrameRequests { + fn request_frame(&self) { + self.0.fetch_add(1, Ordering::SeqCst); + } } #[derive(Clone)] @@ -87,9 +105,24 @@ impl TestWindow { moved_callback: None, input_handler: None, is_fullscreen: false, + request_frame_callback: None, + frame_requests: Arc::new(TestFrameRequests::default()), + deferred_presents: 0, + draw_attempts: 0, + presentable: true, }))) } + /// Deliver the actual frame callback after releasing the platform-state lock. + #[cfg(test)] + pub(crate) fn simulate_frame(&self) { + let callback = self.0.lock().request_frame_callback.take(); + if let Some(mut callback) = callback { + callback(RequestFrameOptions::default()); + self.0.lock().request_frame_callback = Some(callback); + } + } + pub fn simulate_resize(&mut self, size: Size) { let scale_factor = self.scale_factor(); let mut lock = self.0.lock(); @@ -260,7 +293,17 @@ impl PlatformWindow for TestWindow { self.0.lock().is_fullscreen } - fn on_request_frame(&self, _callback: Box) {} + fn on_request_frame(&self, callback: Box) { + self.0.lock().request_frame_callback = Some(callback); + } + + fn can_present(&self) -> bool { + self.0.lock().presentable + } + + fn frame_requester(&self) -> Option> { + Some(self.0.lock().frame_requests.clone()) + } fn on_input(&self, callback: Box DispatchEventResult>) { self.0.lock().input_callback = Some(callback) @@ -294,13 +337,24 @@ impl PlatformWindow for TestWindow { fn on_appearance_changed(&self, _callback: Box) {} - fn draw(&self, scene: &Scene) { + fn draw(&self, scene: &Scene) -> bool { let scale_factor = self.scale_factor(); let mut state = self.0.lock(); - let device_size: Size = state.bounds.size.to_device_pixels(scale_factor); - if let Some(renderer) = &mut state.renderer { - renderer.render_scene(scene, device_size).warn_on_err(); + state.draw_attempts += 1; + if !state.presentable { + return false; + } + if state.deferred_presents > 0 { + state.deferred_presents -= 1; + return false; } + let device_size: Size = state.bounds.size.to_device_pixels(scale_factor); + state.renderer.as_mut().is_none_or(|renderer| { + renderer + .render_scene(scene, device_size) + .warn_on_err() + .is_some() + }) } fn sprite_atlas(&self) -> sync::Arc { diff --git a/crates/moon-gpui/src/window.rs b/crates/moon-gpui/src/window.rs index c07594b..ac71d43 100644 --- a/crates/moon-gpui/src/window.rs +++ b/crates/moon-gpui/src/window.rs @@ -2992,7 +2992,7 @@ impl Window { // `force_present` is true on every UI redraw, so the scene's text is rebuilt from // scratch then; reuse only happens on GPU-only frames, for canvases that did not // ask to present and whose environment is unchanged. - if gpu_canvas_prepare_text(force_present, request_present) { + if presentable && gpu_canvas_prepare_text(force_present, request_present) { let mut seen = FxHashSet::default(); let mut occurrences: FxHashMap<(usize, bool), usize> = FxHashMap::default(); for (layer, canvas, bounds, wants_present) in canvases { @@ -3244,11 +3244,14 @@ impl Window { } #[profiling::function] + /// Keep the frame owed until the native backend accepts its presentation. fn present(&mut self) { - self.platform_window.draw(&self.rendered_frame.scene); + let accepted = self.platform_window.draw(&self.rendered_frame.scene); #[cfg(feature = "input-latency-histogram")] - self.input_latency_tracker.record_frame_presented(); - self.needs_present.set(false); + if accepted { + self.input_latency_tracker.record_frame_presented(); + } + self.needs_present.set(!accepted); profiling::finish_frame!(); } diff --git a/crates/moon-gpui/src/window/tests.rs b/crates/moon-gpui/src/window/tests.rs index cd9de7a..8e33433 100644 --- a/crates/moon-gpui/src/window/tests.rs +++ b/crates/moon-gpui/src/window/tests.rs @@ -206,6 +206,160 @@ fn open_window( (app, any) } +/// A static scene still owes its last frame after a transient backend refusal, without new input. +#[test] +fn deferred_idle_present_retries_then_settles() { + let (mut app, any) = open_window(|_, _| EmptyView); + let platform = app.test_window(any); + platform.0.lock().deferred_presents = 2; + let requests = platform.0.lock().frame_requests.clone(); + for _ in 0..2 { + let before = requests.0.load(std::sync::atomic::Ordering::SeqCst); + platform.simulate_frame(); + assert_eq!( + requests.0.load(std::sync::atomic::Ordering::SeqCst) - before, + 1, + "one next native tick is requested" + ); + app.update_window(any, |_, window, _| { + assert!( + window.needs_present.get(), + "the rejected idle frame remains owed" + ); + assert!( + !window.invalidator.is_dirty(), + "retry must not rebuild the UI" + ); + }) + .unwrap(); + } + platform.simulate_frame(); + app.update_window(any, |_, window, _| assert!(!window.needs_present.get())) + .unwrap(); + assert_eq!(platform.0.lock().draw_attempts, 3); + platform.simulate_frame(); + assert_eq!( + platform.0.lock().draw_attempts, + 3, + "a successful present settles" + ); +} + +/// Request one GPU-only frame, consuming the local request just as a retained chart does. +struct OneShotPresent { + request: Rc>, + text_prepares: Rc>, +} + +impl GpuCanvasDriver for OneShotPresent { + fn frame(&mut self, info: GpuFrameInfo) -> GpuFrameDecision { + if info.presentable && self.request.replace(false) { + GpuFrameDecision::RequestPresent + } else { + GpuFrameDecision::Skip + } + } + + fn prepare_text(&mut self, _: &mut GpuCanvasTextContext<'_>) -> anyhow::Result<()> { + self.text_prepares.set(self.text_prepares.get() + 1); + Ok(()) + } + + fn draw(&mut self, _: &mut GpuCanvasDrawContext<'_>) -> anyhow::Result<()> { + Ok(()) + } +} + +/// Open the production canvas path with a retained request and count actual text preparations. +fn one_shot_present_window() -> ( + TestAppContext, + AnyWindowHandle, + Rc>, + Rc>, +) { + let request = Rc::new(Cell::new(false)); + let prepares = Rc::new(Cell::new(0)); + let handles = vec![GpuCanvasHandle::new(OneShotPresent { + request: request.clone(), + text_prepares: prepares.clone(), + })]; + let (app, any) = open_window(move |_, _| CanvasesView { handles }); + (app, any, request, prepares) +} + +/// A failed GPU-only request must CREATE window debt even when its initial window debt was false. +#[test] +fn deferred_gpu_only_present_creates_and_consumes_window_debt() { + let (mut app, any, request, _) = one_shot_present_window(); + let platform = app.test_window(any); + platform.simulate_frame(); + app.update_window(any, |_, window, _| assert!(!window.needs_present.get())) + .unwrap(); + let initial_attempts = platform.0.lock().draw_attempts; + platform.0.lock().deferred_presents = 2; + request.set(true); + let requests = platform.0.lock().frame_requests.clone(); + for _ in 0..2 { + let before = requests.0.load(std::sync::atomic::Ordering::SeqCst); + platform.simulate_frame(); + assert_eq!( + requests.0.load(std::sync::atomic::Ordering::SeqCst) - before, + 1 + ); + app.update_window(any, |_, window, _| assert!(window.needs_present.get())) + .unwrap(); + } + assert!( + !request.get(), + "the producer already consumed its sole change" + ); + platform.simulate_frame(); + app.update_window(any, |_, window, _| assert!(!window.needs_present.get())) + .unwrap(); + assert_eq!(platform.0.lock().draw_attempts - initial_attempts, 3); + platform.simulate_frame(); + assert_eq!(platform.0.lock().draw_attempts - initial_attempts, 3); +} + +/// An occluded window retains debt but must not repeatedly shape the same pending text. +#[test] +fn deferred_present_skips_text_while_unpresentable_and_finishes_on_restore() { + let (mut app, any, _, prepares) = one_shot_present_window(); + let platform = app.test_window(any); + let before = prepares.get(); + platform.0.lock().presentable = false; + for _ in 0..3 { + platform.simulate_frame(); + } + assert_eq!( + prepares.get(), + before, + "no text preparation while the backend cannot present" + ); + app.update_window(any, |_, window, _| assert!(window.needs_present.get())) + .unwrap(); + platform.0.lock().presentable = true; + platform.simulate_frame(); + assert_eq!(prepares.get(), before + 1); + app.update_window(any, |_, window, _| assert!(!window.needs_present.get())) + .unwrap(); +} + +/// A late native tick after close must not keep requesting work for the removed window. +#[test] +fn deferred_present_does_not_rearm_a_closed_window() { + let (mut app, any) = open_window(|_, _| EmptyView); + let platform = app.test_window(any); + platform.0.lock().deferred_presents = 1; + platform.simulate_frame(); + app.update_window(any, |_, window, _| window.remove_window()) + .unwrap(); + let requests = platform.0.lock().frame_requests.clone(); + let before = requests.0.load(std::sync::atomic::Ordering::SeqCst); + platform.simulate_frame(); + assert_eq!(requests.0.load(std::sync::atomic::Ordering::SeqCst), before); +} + /// Draw one frame of `any`. fn draw(app: &mut TestAppContext, any: AnyWindowHandle) { app.update_window(any, |_, window, cx| { From 1e5aad29f7e7597972ef54e83670f76c184f7be5 Mon Sep 17 00:00:00 2001 From: Moonbot-Tech Date: Fri, 6 Nov 2026 17:51:51 +0300 Subject: [PATCH 04/19] fix(render): submit retained canvas bakes before growing scene buffers A canvas can mark its CPU bake clean after recording GPU commands. When a later scene batch overflowed the instance buffer, dropping that encoder lost the bake permanently: the larger retry saw clean CPU state and never rebuilt the texture. Submit the already recorded commands before retrying. The retry still clears the partial surface and only the final scene presents. Apply the same ordering to wgpu and Metal, including CPU-to-GPU instance buffer synchronization on non-unified Metal memory. DirectX submits its immediate-context work already and needs no corresponding change. Validation: an explicit native-surface regression forces an actual buffer growth after a one-shot retained bake, then reads the final GPU pixel. Hel1 produced the expected RGBA value; restoring encoder discard makes that test fail. Metal has source parity; its native probe is still separate. --- crates/moon-gpui-macos/src/metal_renderer.rs | 17 +- crates/moon-gpui-wgpu/src/wgpu_renderer.rs | 13 +- .../moon-gpui-wgpu/src/wgpu_renderer/tests.rs | 182 ++++++++++++++++++ .../src/wgpu_renderer/tests/native_window.rs | 182 ++++++++++++++++++ 4 files changed, 388 insertions(+), 6 deletions(-) create mode 100644 crates/moon-gpui-wgpu/src/wgpu_renderer/tests.rs create mode 100644 crates/moon-gpui-wgpu/src/wgpu_renderer/tests/native_window.rs diff --git a/crates/moon-gpui-macos/src/metal_renderer.rs b/crates/moon-gpui-macos/src/metal_renderer.rs index 69286e8..f58d50d 100644 --- a/crates/moon-gpui-macos/src/metal_renderer.rs +++ b/crates/moon-gpui-macos/src/metal_renderer.rs @@ -1062,6 +1062,7 @@ impl MetalRenderer { self.draw_primitives_to_texture(scene, instance_buffer, drawable.texture(), viewport_size) } + /// Record the scene, preserving canvas bakes when an instance-buffer retry is required. fn draw_primitives_to_texture( &mut self, scene: &Scene, @@ -1107,6 +1108,8 @@ impl MetalRenderer { &mut instance_offset, ); if !ok { + self.sync_instance_buffer(instance_buffer, instance_offset); + command_buffer.commit(); anyhow::bail!( "gpu canvas text too large: {} mono, {} poly", scene.gpu_canvas_text_under_scene.monochrome_sprites.len(), @@ -1213,6 +1216,8 @@ impl MetalRenderer { }; if !ok { command_encoder.end_encoding(); + self.sync_instance_buffer(instance_buffer, instance_offset); + command_buffer.commit(); anyhow::bail!( "scene too large: {} paths, {} shadows, {} quads, {} underlines, {} mono, {} poly, {} surfaces", scene.paths.len(), @@ -1240,6 +1245,8 @@ impl MetalRenderer { &mut instance_offset, ); if !ok { + self.sync_instance_buffer(instance_buffer, instance_offset); + command_buffer.commit(); anyhow::bail!( "gpu canvas text too large: {} mono, {} poly", scene.gpu_canvas_text_over_scene.monochrome_sprites.len(), @@ -1247,15 +1254,19 @@ impl MetalRenderer { ); } + self.sync_instance_buffer(instance_buffer, instance_offset); + + Ok(command_buffer.to_owned()) + } + + /// Publish CPU instance writes before submitting either a complete scene or a failed retry. + fn sync_instance_buffer(&self, instance_buffer: &InstanceBuffer, instance_offset: usize) { if !self.is_unified_memory { - // Sync the instance buffer to the GPU instance_buffer.metal_buffer.did_modify_range(NSRange { location: 0, length: instance_offset as NSUInteger, }); } - - Ok(command_buffer.to_owned()) } fn draw_paths_to_intermediate( diff --git a/crates/moon-gpui-wgpu/src/wgpu_renderer.rs b/crates/moon-gpui-wgpu/src/wgpu_renderer.rs index 95b73c0..88fff82 100644 --- a/crates/moon-gpui-wgpu/src/wgpu_renderer.rs +++ b/crates/moon-gpui-wgpu/src/wgpu_renderer.rs @@ -19,6 +19,9 @@ use std::ptr::NonNull; use std::rc::Rc; use std::sync::{Arc, Mutex, Weak}; +#[cfg(all(test, any(windows, target_os = "linux")))] +mod tests; + #[repr(C)] #[derive(Clone, Copy, Pod, Zeroable)] struct GlobalParams { @@ -1283,6 +1286,8 @@ impl WgpuRenderer { self.draw_gpu_canvas_text(text_frame, instance_offset, pass) } + /// Submit scene commands, retaining canvas preparation when instance growth retries it. + /// Returns false when the surface cannot accept this frame. pub fn draw(&mut self, scene: &Scene) -> bool { // Bail out early if the surface has been unconfigured (e.g. during // Android background/rotation transitions). Attempting to acquire @@ -1461,7 +1466,9 @@ impl WgpuRenderer { } } if overflow { - drop(encoder); + // Canvas prepare already committed its CPU state. Keep those GPU commands; + // the retry clears the partial surface before the only present below. + self.resources().queue.submit([encoder.finish()]); if self.instance_buffer_capacity >= self.max_buffer_size { log::error!( "instance buffer size grew too large: {}", @@ -1581,7 +1588,7 @@ impl WgpuRenderer { } if overflow { - drop(encoder); + self.resources().queue.submit([encoder.finish()]); if self.instance_buffer_capacity >= self.max_buffer_size { log::error!( "instance buffer size grew too large: {}", @@ -1625,7 +1632,7 @@ impl WgpuRenderer { } if overflow { - drop(encoder); + self.resources().queue.submit([encoder.finish()]); if self.instance_buffer_capacity >= self.max_buffer_size { log::error!( "instance buffer size grew too large: {}", diff --git a/crates/moon-gpui-wgpu/src/wgpu_renderer/tests.rs b/crates/moon-gpui-wgpu/src/wgpu_renderer/tests.rs new file mode 100644 index 0000000..d386469 --- /dev/null +++ b/crates/moon-gpui-wgpu/src/wgpu_renderer/tests.rs @@ -0,0 +1,182 @@ +//! Real-surface regression for a retained canvas bake followed by a scene-buffer retry. + +mod native_window; + +use super::{GpuContext, WgpuRenderer, WgpuSurfaceConfig}; +use gpui::{ + Bounds, ContentMask, DevicePixels, GpuCanvasDrawContext, GpuCanvasDriver, GpuCanvasHandle, + GpuCanvasLayer, GpuCanvasPrepareContext, GpuFrameDecision, GpuFrameInfo, PaintGpuCanvas, Quad, + RawGpuAccess, ScaledPixels, Scene, Underline, point, size, +}; +use std::{cell::RefCell, rc::Rc, sync::Arc, time::Duration}; + +/// A chart-like owner that marks a retained bake clean as soon as it records the GPU commands. +struct RetainedBake { + texture: wgpu::Texture, + dirty: bool, +} + +impl GpuCanvasDriver for RetainedBake { + fn frame(&mut self, _info: GpuFrameInfo) -> GpuFrameDecision { + GpuFrameDecision::Skip + } + + fn prepare_gpu(&mut self, ctx: &mut GpuCanvasPrepareContext<'_>) -> anyhow::Result<()> { + if self.dirty { + let RawGpuAccess::Wgpu(raw) = ctx.gpu else { + unreachable!() + }; + // The raw API lends the renderer's current encoder for this synchronous callback. + let encoder = + unsafe { &mut *raw.command_encoder.as_ptr().cast::() }; + let view = self.texture.create_view(&Default::default()); + let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("retained bake before overflow"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color { + r: 1.0, + g: 0.25, + b: 0.0, + a: 1.0, + }), + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + })], + ..Default::default() + }); + self.dirty = false; + } + Ok(()) + } + + fn draw(&mut self, _ctx: &mut GpuCanvasDrawContext<'_>) -> anyhow::Result<()> { + Ok(()) + } +} + +/// Read the completed retained texture, rather than treating a CPU clean flag as a successful bake. +fn read_pixel(device: &wgpu::Device, queue: &wgpu::Queue, texture: &wgpu::Texture) -> [u8; 4] { + let readback = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("retry pixel readback"), + size: 256, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + let mut encoder = device.create_command_encoder(&Default::default()); + encoder.copy_texture_to_buffer( + texture.as_image_copy(), + wgpu::TexelCopyBufferInfo { + buffer: &readback, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(256), + rows_per_image: None, + }, + }, + wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + ); + queue.submit([encoder.finish()]); + let (sent, received) = std::sync::mpsc::channel(); + readback + .slice(..) + .map_async(wgpu::MapMode::Read, move |result| { + sent.send(result).unwrap(); + }); + device + .poll(wgpu::PollType::Wait { + submission_index: None, + timeout: Some(Duration::from_secs(10)), + }) + .unwrap(); + received + .recv_timeout(Duration::from_secs(10)) + .unwrap() + .unwrap(); + let mapped = readback.slice(..).get_mapped_range(); + mapped[..4].try_into().unwrap() +} + +/// Dropping the failed scene encoder loses the only bake, even though the larger retry succeeds. +#[test] +#[ignore = "requires a native GPU surface and DISPLAY on Linux; run explicitly with --ignored"] +fn scene_overflow_preserves_the_already_prepared_canvas_texture() { + let window = native_window::TestWindow::new(); + let context: GpuContext = Rc::new(RefCell::new(None)); + let mut renderer = WgpuRenderer::new( + context, + &window, + WgpuSurfaceConfig { + size: size(DevicePixels(64), DevicePixels(64)), + transparent: false, + preferred_present_mode: None, + }, + None, + ) + .unwrap(); + let device = Arc::clone(&renderer.resources().device); + let queue = Arc::clone(&renderer.resources().queue); + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("retained chart bake"), + size: wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let bounds = Bounds::new( + point(ScaledPixels(0.0), ScaledPixels(0.0)), + size(ScaledPixels(64.0), ScaledPixels(64.0)), + ); + let mut scene = Scene::default(); + scene.insert_gpu_canvas( + GpuCanvasLayer::UnderScene, + PaintGpuCanvas { + order: Default::default(), + bounds, + content_mask: ContentMask { bounds }, + text_layer: GpuCanvasLayer::UnderScene, + driver: GpuCanvasHandle::new(RetainedBake { + texture: texture.clone(), + dirty: true, + }), + }, + ); + scene.insert_primitive(Quad { + bounds, + content_mask: ContentMask { bounds }, + ..Default::default() + }); + scene.insert_primitive(Underline { + order: Default::default(), + pad: 0, + bounds, + content_mask: ContentMask { bounds }, + color: gpui::black(), + thickness: ScaledPixels(1.0), + wavy: 0, + }); + scene.finish(); + // The first batch uses the old buffer; the second overflows. Its lifetime must survive grow. + let initial_capacity = std::mem::size_of::() as u64; + renderer.instance_buffer_capacity = initial_capacity; + assert!(renderer.draw(&scene)); + assert!( + renderer.instance_buffer_capacity > initial_capacity, + "the scene must actually retry" + ); + assert_eq!(read_pixel(&device, &queue, &texture), [255, 64, 0, 255]); +} diff --git a/crates/moon-gpui-wgpu/src/wgpu_renderer/tests/native_window.rs b/crates/moon-gpui-wgpu/src/wgpu_renderer/tests/native_window.rs new file mode 100644 index 0000000..191a0ba --- /dev/null +++ b/crates/moon-gpui-wgpu/src/wgpu_renderer/tests/native_window.rs @@ -0,0 +1,182 @@ +//! Minimal native surface owner for renderer tests, without a GPUI app or an added dependency. + +#[cfg(windows)] +mod platform { + use raw_window_handle::{ + DisplayHandle, HandleError, HasDisplayHandle, HasWindowHandle, RawDisplayHandle, + RawWindowHandle, Win32WindowHandle, WindowHandle, WindowsDisplayHandle, + }; + use std::{ffi::c_void, num::NonZeroIsize, sync::Arc}; + + #[link(name = "user32")] + unsafe extern "system" { + fn CreateWindowExW( + ex_style: u32, + class: *const u16, + name: *const u16, + style: u32, + x: i32, + y: i32, + width: i32, + height: i32, + parent: *mut c_void, + menu: *mut c_void, + instance: *mut c_void, + param: *mut c_void, + ) -> *mut c_void; + fn DestroyWindow(window: *mut c_void) -> i32; + } + + /// Keep the HWND alive through all clones retained by the GPU instance. + #[derive(Clone, Debug)] + pub struct TestWindow(Arc); + + #[derive(Debug)] + struct Owner(usize); + + impl TestWindow { + pub fn new() -> Self { + let class: Vec<_> = "STATIC\0".encode_utf16().collect(); + // A visible non-activating tool window also works on drivers that reject hidden surfaces. + let window = unsafe { + CreateWindowExW( + 0x08000080, + class.as_ptr(), + class.as_ptr(), + 0x90000000, + 0, + 0, + 64, + 64, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }; + assert!(!window.is_null(), "create renderer test window"); + Self(Arc::new(Owner(window as usize))) + } + } + + impl Drop for Owner { + fn drop(&mut self) { + unsafe { + DestroyWindow(self.0 as *mut c_void); + } + } + } + + impl HasWindowHandle for TestWindow { + fn window_handle(&self) -> Result, HandleError> { + let handle = Win32WindowHandle::new(NonZeroIsize::new(self.0.0 as isize).unwrap()); + Ok(unsafe { WindowHandle::borrow_raw(RawWindowHandle::Win32(handle)) }) + } + } + + impl HasDisplayHandle for TestWindow { + fn display_handle(&self) -> Result, HandleError> { + Ok(unsafe { + DisplayHandle::borrow_raw(RawDisplayHandle::Windows(WindowsDisplayHandle::new())) + }) + } + } +} + +#[cfg(target_os = "linux")] +mod platform { + use raw_window_handle::{ + DisplayHandle, HandleError, HasDisplayHandle, HasWindowHandle, RawDisplayHandle, + RawWindowHandle, WindowHandle, XlibDisplayHandle, XlibWindowHandle, + }; + use std::{ + ffi::{c_char, c_int, c_ulong, c_void}, + ptr::NonNull, + sync::Arc, + }; + + #[link(name = "X11")] + unsafe extern "C" { + fn XOpenDisplay(name: *const c_char) -> *mut c_void; + fn XDefaultScreen(display: *mut c_void) -> c_int; + fn XRootWindow(display: *mut c_void, screen: c_int) -> c_ulong; + fn XCreateSimpleWindow( + display: *mut c_void, + parent: c_ulong, + x: c_int, + y: c_int, + width: u32, + height: u32, + border_width: u32, + border: c_ulong, + background: c_ulong, + ) -> c_ulong; + fn XMapWindow(display: *mut c_void, window: c_ulong) -> c_int; + fn XFlush(display: *mut c_void) -> c_int; + fn XDestroyWindow(display: *mut c_void, window: c_ulong) -> c_int; + fn XCloseDisplay(display: *mut c_void) -> c_int; + } + + /// Keep the Xlib connection and window alive through the GPU instance's window clone. + #[derive(Clone, Debug)] + pub struct TestWindow(Arc); + + #[derive(Debug)] + struct Owner { + display: usize, + screen: i32, + window: c_ulong, + } + + impl TestWindow { + pub fn new() -> Self { + let display = unsafe { XOpenDisplay(std::ptr::null()) }; + assert!( + !display.is_null(), + "renderer test requires DISPLAY (xvfb-run is supported)" + ); + let screen = unsafe { XDefaultScreen(display) }; + let window = unsafe { + XCreateSimpleWindow(display, XRootWindow(display, screen), 0, 0, 64, 64, 0, 0, 0) + }; + unsafe { + XMapWindow(display, window); + XFlush(display); + } + Self(Arc::new(Owner { + display: display as usize, + screen, + window, + })) + } + } + + impl Drop for Owner { + fn drop(&mut self) { + unsafe { + XDestroyWindow(self.display as *mut c_void, self.window); + XCloseDisplay(self.display as *mut c_void); + } + } + } + + impl HasWindowHandle for TestWindow { + fn window_handle(&self) -> Result, HandleError> { + Ok(unsafe { + WindowHandle::borrow_raw(RawWindowHandle::Xlib(XlibWindowHandle::new( + self.0.window, + ))) + }) + } + } + + impl HasDisplayHandle for TestWindow { + fn display_handle(&self) -> Result, HandleError> { + let handle = + XlibDisplayHandle::new(NonNull::new(self.0.display as *mut c_void), self.0.screen); + Ok(unsafe { DisplayHandle::borrow_raw(RawDisplayHandle::Xlib(handle)) }) + } + } +} + +pub use platform::TestWindow; From 8417cfdbb34541de924516043b198f214413112f Mon Sep 17 00:00:00 2001 From: Moonbot-Tech Date: Fri, 6 Nov 2026 17:51:52 +0300 Subject: [PATCH 05/19] fix(wayland): keep timer polls out of the compositor callback chain The fallback timer called frame(), which registered a new wl_surface frame callback every tick. These requests remain pending until the compositor answers, so a hidden or stalled surface accumulated callbacks indefinitely. Keep one compositor chain: first configure starts it and each completed callback replaces itself. Timer polls only drive GPUI and canvas decisions, preserving the fallback wake that lets Skip become Present without input. No new callback counter or parallel timer is introduced. Validation: Linux build and full functional FireTest stages ran on the Hel1 Weston session, including idle and device recovery. Software-renderer CPU limits and intermittent diagnostic-client disconnects remain reported separately; this commit does not claim to fix either unrelated observation. --- crates/moon-gpui-linux/src/linux/wayland/window.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/moon-gpui-linux/src/linux/wayland/window.rs b/crates/moon-gpui-linux/src/linux/wayland/window.rs index 1c3a9b9..567acfd 100644 --- a/crates/moon-gpui-linux/src/linux/wayland/window.rs +++ b/crates/moon-gpui-linux/src/linux/wayland/window.rs @@ -558,7 +558,7 @@ impl WaylandWindow { WaylandWindow::gpu_canvas_refresh_interval(&state) }; - window.frame(); + window.poll_frame(); let now = std::time::Instant::now(); while instant < now { @@ -646,9 +646,19 @@ impl WaylandWindowStatePtr { !state.children.is_empty() } + /// Start the compositor callback chain on first configure, then replace each completed + /// callback once. Timer polls must not extend this chain: every request survives until Done. pub fn frame(&self) { - let mut state = self.state.borrow_mut(); + let state = self.state.borrow(); state.surface.frame(&state.globals.qh, state.surface.id()); + drop(state); + self.poll_frame(); + } + + /// Poll GPUI and its canvases without registering another Wayland callback. The fallback + /// timer keeps Skip-to-Present transitions live even while the compositor withholds Done. + fn poll_frame(&self) { + let mut state = self.state.borrow_mut(); state.resize_throttle = false; let force_render = state.force_render_after_recovery; state.force_render_after_recovery = false; From 562cc1ecefb4c5403df4d6f70ea34749c2804132 Mon Sep 17 00:00:00 2001 From: Moonbot-Tech Date: Fri, 6 Nov 2026 17:52:32 +0300 Subject: [PATCH 06/19] fix(x11): deliver setup-buffered events before the first frame Synchronous X11 setup can consume socket data into the client event buffer. Calloop then sees no readable socket, so a newly mapped window can stay blank until unrelated input. Schedule one idle drain after GPUI installs the window callbacks and explicitly wake calloop so that drain runs. This uses the existing event processor, not another polling loop. Hel1 mutation qualification reproduced a blank startup and missing chart probe when this block was removed; restoring it delivered the initial frame and passed the functional X11 FireTest stages without rescue input. --- crates/moon-gpui-linux/src/linux/x11/client.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/moon-gpui-linux/src/linux/x11/client.rs b/crates/moon-gpui-linux/src/linux/x11/client.rs index 49438df..d0a691b 100644 --- a/crates/moon-gpui-linux/src/linux/x11/client.rs +++ b/crates/moon-gpui-linux/src/linux/x11/client.rs @@ -1577,6 +1577,7 @@ impl LinuxClient for X11Client { gpui::scap_screen_capture::scap_screen_sources(&self.0.borrow().common.foreground_executor) } + /// Open a native window and deliver events buffered by its synchronous X11 setup. fn open_window( &self, handle: AnyWindowHandle, @@ -1656,6 +1657,13 @@ impl LinuxClient for X11Client { }; state.windows.insert(x_window, window_ref); + // Synchronous setup can buffer X11 events without leaving the socket readable. + // Drain after GPUI installs callbacks and maps the window, so the first frame + // does not depend on unrelated input. An idle alone does not wake calloop's poll. + state.loop_handle.insert_idle(move |client| { + client.process_x11_events(&xcb_connection).log_err(); + }); + state.common.signal.wakeup(); Ok(Box::new(window)) } From 4e736de3a22ac42c08402b660576f8a94f6a2792 Mon Sep 17 00:00:00 2001 From: Moonbot-Tech Date: Fri, 6 Nov 2026 17:52:55 +0300 Subject: [PATCH 07/19] feat(diagnostics): count actual view renders at the common dispatch point FireTest needs to distinguish an unchanged view that really rebuilt from a notification or reuse of its cached scene. Add opt-in per-App counting where the concrete Render implementation is invoked, so application code does not need a manually maintained list of instrumented panels. The default path performs only an Option check: no clock, allocation, hash or synchronization. Sampling drains the interval; disabling drops it so old samples cannot leak across diagnostic runs. Independent apps do not share the counters. Validation: runtime regressions distinguish cached child reuse from parent renders and cover enable, repeated enable, drain, disable and re-enable. The terminal native idle and form probes consume these actual counts. --- crates/moon-gpui/src/app.rs | 3 + crates/moon-gpui/src/view.rs | 28 +++++++++ crates/moon-gpui/src/view/tests.rs | 92 ++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+) create mode 100644 crates/moon-gpui/src/view/tests.rs diff --git a/crates/moon-gpui/src/app.rs b/crates/moon-gpui/src/app.rs index 57bdbdd..a846b07 100644 --- a/crates/moon-gpui/src/app.rs +++ b/crates/moon-gpui/src/app.rs @@ -683,6 +683,8 @@ pub struct App { #[cfg(any(test, feature = "test-support", debug_assertions))] pub(crate) name: Option<&'static str>, pub(crate) text_rendering_mode: Rc>, + /// Opt-in actual view renders, kept per App so independent applications/tests do not mix. + pub(crate) view_render_counts: Option>, pub(crate) window_update_stack: Vec, pub(crate) mode: GpuiMode, @@ -729,6 +731,7 @@ impl App { platform: platform.clone(), text_system, text_rendering_mode: Rc::new(Cell::new(TextRenderingMode::default())), + view_render_counts: None, mode: GpuiMode::Production, actions: Rc::new(ActionRegistry::default()), flushing_effects: false, diff --git a/crates/moon-gpui/src/view.rs b/crates/moon-gpui/src/view.rs index 55db7d8..5b3054d 100644 --- a/crates/moon-gpui/src/view.rs +++ b/crates/moon-gpui/src/view.rs @@ -11,6 +11,30 @@ use std::mem; use std::rc::Rc; use std::{any::TypeId, fmt, ops::Range}; +#[cfg(test)] +mod tests; + +impl App { + /// Collect actual `Render::render` calls by concrete view type. Disabled by default; + /// the disabled path performs no clock read, allocation, hashing, or synchronization. + /// Disabling clears the interval so re-enabling cannot report stale work. + pub fn set_view_render_tracking(&mut self, enabled: bool) { + if enabled { + self.view_render_counts.get_or_insert_default(); + } else { + self.view_render_counts = None; + } + } + + /// Drain the current render-count interval, retaining capacity for the next interval. + /// Cached scene reuse and notifications that never render are not counted. + pub fn take_view_render_counts(&mut self) -> Vec<(&'static str, u64)> { + self.view_render_counts + .as_mut() + .map_or_else(Vec::new, |counts| counts.drain().collect()) + } +} + struct AnyViewState { prepaint_range: Range, paint_range: Range, @@ -308,12 +332,16 @@ impl std::fmt::Debug for AnyWeakView { mod any_view { use crate::{AnyElement, AnyView, App, IntoElement, Render, Window}; + /// Invoke the concrete view at the common dispatch point, excluding cached scene reuse. pub(crate) fn render( view: &AnyView, window: &mut Window, cx: &mut App, ) -> AnyElement { let view = view.clone().downcast::().unwrap(); + if let Some(counts) = cx.view_render_counts.as_mut() { + *counts.entry(std::any::type_name::()).or_default() += 1; + } view.update(cx, |view, cx| view.render(window, cx).into_any_element()) } } diff --git a/crates/moon-gpui/src/view/tests.rs b/crates/moon-gpui/src/view/tests.rs new file mode 100644 index 0000000..a884eaf --- /dev/null +++ b/crates/moon-gpui/src/view/tests.rs @@ -0,0 +1,92 @@ +//! Actual-render accounting must exclude cache reuse, idle notifications, and disabled periods. + +use std::any::type_name; + +use crate::{ + AnyView, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, + StyleRefinement, TestAppContext, Window, div, +}; + +/// An uninstrumented application view: the runtime must discover its concrete type itself. +struct CountedView; + +impl Render for CountedView { + /// Return an ordinary element without any diagnostic call site. + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div().child("rendered") + } +} + +/// Keep a child cached while the uncached parent still builds on each draw. +struct CachedChild { + child: Entity, +} + +impl Render for CachedChild { + /// Return the normal AnyView cache path, without custom instrumentation. + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div().child(AnyView::from(self.child.clone()).cached(StyleRefinement::default())) + } +} + +/// A counter in request_layout/prepaint would incorrectly count cached reuse as a render. +#[test] +fn cached_reuse_does_not_count_as_rendering_the_child() { + let mut app = TestAppContext::single(); + let window = app.add_window(|_, cx| CachedChild { + child: cx.new(|_| CountedView), + }); + app.update_window(window.into(), |_, window, cx| { + cx.set_view_render_tracking(true); + window.refresh(); + window.draw(cx).clear(); + window.draw(cx).clear(); + let counts: std::collections::BTreeMap<_, _> = + cx.take_view_render_counts().into_iter().collect(); + assert_eq!(counts.get(type_name::()), Some(&2)); + assert_eq!(counts.get(type_name::()), Some(&1)); + }) + .unwrap(); +} + +/// Removing common dispatch accounting, counting notify instead, retaining old samples, or +/// leaving tracking active after disable must break this full view-render path. +#[test] +fn tracking_counts_actual_renders_and_clears_disabled_intervals() { + let mut app = TestAppContext::single(); + let window = app.add_window(|_, _| CountedView); + app.update_window(window.into(), |_, window, cx| { + window.draw(cx).clear(); + assert!( + cx.take_view_render_counts().is_empty(), + "tracking defaults off" + ); + cx.set_view_render_tracking(true); + window.draw(cx).clear(); + window.draw(cx).clear(); + assert_eq!( + cx.take_view_render_counts(), + vec![(type_name::(), 2)] + ); + assert!( + cx.take_view_render_counts().is_empty(), + "sampling drains the interval" + ); + window.draw(cx).clear(); + cx.set_view_render_tracking(true); + assert_eq!( + cx.take_view_render_counts(), + vec![(type_name::(), 1)], + "reapplying the gate preserves the current interval" + ); + window.draw(cx).clear(); + cx.set_view_render_tracking(false); + window.draw(cx).clear(); + cx.set_view_render_tracking(true); + assert!( + cx.take_view_render_counts().is_empty(), + "disabled work cannot leak into a new interval" + ); + }) + .unwrap(); +} From e586a839b2e350de96a3462a20a5d2838d6d3821 Mon Sep 17 00:00:00 2001 From: Moonbot-Tech Date: Fri, 6 Nov 2026 17:52:56 +0300 Subject: [PATCH 08/19] docs(canvas): state the Cargo identity required by raw wgpu handles Keep the borrowed raw callback API: it intentionally lets the terminal render inside MoonUI's native pass without another abstraction or copy. Document its real safety boundary instead. These are pointers to Rust objects, not a stable C ABI, so producer and consumer must resolve the same wgpu Cargo package source and version. The companion terminal change replaces its separate git-sourced wgpu with the registry package already used by MoonUI. Matching version text alone would not make two distinct Cargo package identities interchangeable. --- crates/moon-gpui/src/gpu_canvas.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/moon-gpui/src/gpu_canvas.rs b/crates/moon-gpui/src/gpu_canvas.rs index e0c4607..eea2a67 100644 --- a/crates/moon-gpui/src/gpu_canvas.rs +++ b/crates/moon-gpui/src/gpu_canvas.rs @@ -1136,6 +1136,8 @@ pub struct MetalRawAccess<'a> { } /// Opaque borrowed wgpu handles for a canvas callback. +/// These point to Rust objects, not a stable C ABI: a consumer casting them must use the +/// exact same Cargo wgpu package (source and version) as the runtime producer. #[derive(Clone, Copy, Debug)] pub struct WgpuRawAccess<'a> { /// Monotonic generation bumped when GPUI recreates wgpu resources after device loss. From 2028d565e4b1de6cb435165a3ac01654217eef2c Mon Sep 17 00:00:00 2001 From: Moonbot-Tech Date: Fri, 6 Nov 2026 17:53:09 +0300 Subject: [PATCH 09/19] fix(wgpu): preserve the first validation error in a failed frame Later draw submissions can replace a useful shader validation diagnostic with a generic invalid-pipeline error. Keep the first uncaptured error until the existing consumer drains it, retaining the actual failure cause without adding a separate error queue or changing recovery behavior. This was exercised while diagnosing the terminal's Linux candle shader; the original validation reason now survives the later invalid submissions. --- crates/moon-gpui-wgpu/src/wgpu_renderer.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/moon-gpui-wgpu/src/wgpu_renderer.rs b/crates/moon-gpui-wgpu/src/wgpu_renderer.rs index 88fff82..e4887c4 100644 --- a/crates/moon-gpui-wgpu/src/wgpu_renderer.rs +++ b/crates/moon-gpui-wgpu/src/wgpu_renderer.rs @@ -517,7 +517,8 @@ impl WgpuRenderer { let last_error_clone = Arc::clone(&last_error); device.on_uncaptured_error(Arc::new(move |error| { let mut guard = last_error_clone.lock().unwrap(); - *guard = Some(error.to_string()); + // Preserve the cause: later submissions may only report that its pipeline is invalid. + guard.get_or_insert_with(|| error.to_string()); })); let resources = WgpuResources { From dc523bc98e78b39338b1ff1733ab498cfb0db667 Mon Sep 17 00:00:00 2001 From: Moonbot-Tech Date: Fri, 6 Nov 2026 17:53:55 +0300 Subject: [PATCH 10/19] feat(diagnostics): exercise the existing shared GPU recovery path FireTest must prove that retained charts return after their GPU resources are destroyed, without depending on a driver crash or a system-wide TDR. Expose a diagnostic request through Window and PlatformWindow. Windows uses its existing VSync device owner; X11 and Wayland destroy the shared wgpu device and let the ordinary native draw path recover it. Refresh all windows sharing the device. Unsupported platforms explicitly return false. Preserve the existing adapter policy during recovery: a session already using a CPU adapter can recreate that class, while a hardware session still refuses a silent fallback to software. This makes recovery testable on the Hel1 software lab without weakening hardware-session behavior. Validation: the terminal FireTest performs two controlled losses with no new market data, checks retained history and generations on Windows and Linux, and reads exact restored own-pass image layers on DirectX. This is not a driver-hang/TDR or a native Metal recovery test. --- .../src/linux/wayland/window.rs | 6 +++++ .../moon-gpui-linux/src/linux/x11/window.rs | 6 +++++ crates/moon-gpui-wgpu/src/wgpu_renderer.rs | 22 ++++++++++++++++--- crates/moon-gpui-windows/src/window.rs | 8 +++++++ crates/moon-gpui/src/platform.rs | 6 +++++ crates/moon-gpui/src/window.rs | 11 ++++++++++ 6 files changed, 56 insertions(+), 3 deletions(-) diff --git a/crates/moon-gpui-linux/src/linux/wayland/window.rs b/crates/moon-gpui-linux/src/linux/wayland/window.rs index 567acfd..10431ad 100644 --- a/crates/moon-gpui-linux/src/linux/wayland/window.rs +++ b/crates/moon-gpui-linux/src/linux/wayland/window.rs @@ -1616,6 +1616,12 @@ impl PlatformWindow for WaylandWindow { self.borrow().renderer.gpu_specs().into() } + /// Invalidate the shared device; the next native draw runs normal recovery. + fn request_gpu_device_reset(&self) -> bool { + self.borrow().renderer.request_device_reset(); + true + } + fn play_system_bell(&self) { let state = self.borrow(); let surface = if state.surface_state.toplevel().is_some() { diff --git a/crates/moon-gpui-linux/src/linux/x11/window.rs b/crates/moon-gpui-linux/src/linux/x11/window.rs index 0e09d61..60f7a1e 100644 --- a/crates/moon-gpui-linux/src/linux/x11/window.rs +++ b/crates/moon-gpui-linux/src/linux/x11/window.rs @@ -1913,6 +1913,12 @@ impl PlatformWindow for X11Window { self.0.state.borrow().renderer.gpu_specs().into() } + /// Invalidate the shared device; the next native draw runs normal recovery. + fn request_gpu_device_reset(&self) -> bool { + self.0.state.borrow().renderer.request_device_reset(); + true + } + fn play_system_bell(&self) { // Volume 0% means don't increase or decrease from system volume let _ = self.0.xcb.bell(0); diff --git a/crates/moon-gpui-wgpu/src/wgpu_renderer.rs b/crates/moon-gpui-wgpu/src/wgpu_renderer.rs index e4887c4..e3d622f 100644 --- a/crates/moon-gpui-wgpu/src/wgpu_renderer.rs +++ b/crates/moon-gpui-wgpu/src/wgpu_renderer.rs @@ -2402,6 +2402,17 @@ impl WgpuRenderer { self.device_lost.load(std::sync::atomic::Ordering::SeqCst) } + /// Destroy the shared device and explicitly request recovery for diagnostics. + /// Ordinary device destruction intentionally does not set the lost callback's flag. + pub fn request_device_reset(&self) { + if !self + .device_lost + .swap(true, std::sync::atomic::Ordering::SeqCst) + { + self.resources().device.destroy(); + } + } + /// Returns true if a redraw is needed because GPU state was cleared. /// Calling this method clears the flag. pub fn needs_redraw(&mut self) -> bool { @@ -2443,7 +2454,9 @@ impl WgpuRenderer { *gpu_context.borrow_mut() = None; // Wait briefly for the GPU driver to stabilize, then try to - // recreate the context without software renderers. If this fails + // recreate the context. A software session must be able to recover on + // its existing adapter class; a hardware session must not silently + // fall back to software. If this fails // the caller should request another frame and retry — the real GPU // may need more time to come back (e.g. after suspend/resume). std::thread::sleep(std::time::Duration::from_millis(350)); @@ -2451,8 +2464,11 @@ impl WgpuRenderer { let instance = WgpuContext::instance(Box::new(window.clone())); let surface = create_surface(&instance, display_handle.as_raw(), window_handle.as_raw())?; - let new_context = - WgpuContext::new_rejecting_software(instance, &surface, self.compositor_gpu)?; + let new_context = if self.adapter_info.device_type == wgpu::DeviceType::Cpu { + WgpuContext::new(instance, &surface, self.compositor_gpu)? + } else { + WgpuContext::new_rejecting_software(instance, &surface, self.compositor_gpu)? + }; *gpu_context.borrow_mut() = Some(new_context); surface } else { diff --git a/crates/moon-gpui-windows/src/window.rs b/crates/moon-gpui-windows/src/window.rs index 6caa628..14be8df 100644 --- a/crates/moon-gpui-windows/src/window.rs +++ b/crates/moon-gpui-windows/src/window.rs @@ -1032,6 +1032,14 @@ impl PlatformWindow for WindowsWindow { self.state.renderer.borrow().gpu_specs().log_err() } + /// Let the existing VSync owner recreate and distribute the shared DirectX devices. + fn request_gpu_device_reset(&self) -> bool { + self.state + .invalidate_devices + .store(true, std::sync::atomic::Ordering::Release); + true + } + fn update_ime_position(&self, bounds: Bounds) { let scale_factor = self.state.scale_factor.get(); let caret_position = POINT { diff --git a/crates/moon-gpui/src/platform.rs b/crates/moon-gpui/src/platform.rs index 5f00006..110fec2 100644 --- a/crates/moon-gpui/src/platform.rs +++ b/crates/moon-gpui/src/platform.rs @@ -760,6 +760,12 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle { fn set_client_inset(&self, _inset: Pixels) {} fn gpu_specs(&self) -> Option; + /// Request the normal device-recovery path for diagnostics, without resetting the OS GPU. + /// Returns false when the platform has no such recovery path. + fn request_gpu_device_reset(&self) -> bool { + false + } + fn update_ime_position(&self, _bounds: Bounds); fn play_system_bell(&self) {} diff --git a/crates/moon-gpui/src/window.rs b/crates/moon-gpui/src/window.rs index ac71d43..2866a9b 100644 --- a/crates/moon-gpui/src/window.rs +++ b/crates/moon-gpui/src/window.rs @@ -6037,6 +6037,17 @@ impl Window { self.platform_window.gpu_specs() } + /// Exercise native device recovery and wake every window sharing the device. + /// This diagnostic request does not simulate a driver hang or an operating-system reset. + pub fn request_gpu_device_reset(&mut self, cx: &mut App) -> bool { + if !self.platform_window.request_gpu_device_reset() { + return false; + } + self.refresh(); + cx.refresh_windows(); + true + } + /// Perform titlebar double-click action. /// This is macOS specific. pub fn titlebar_double_click(&self) { From 971f2224eed38f2e9d5d3254deb00df28200496d Mon Sep 17 00:00:00 2001 From: Moonbot-Tech Date: Fri, 6 Nov 2026 17:54:17 +0300 Subject: [PATCH 11/19] fix(input): allow context menus inside popovers and across windows The app-global set of open deferred popovers disabled every Input context menu whenever any popover was open. The renderer already supports nested deferred overlays, so that guard was both obsolete and broader than the window or control it was meant to protect. Remove the guard and its registry, including Select/Combobox/Popover writes. Visibility remains with each existing owner; Root keeps overlay placement and dismissal. No replacement global state or native popup window is added. Validation: real right-click events reach the Input menu inside a popover and in another window, and the nested Root menu survives actual prepaint with non-empty bounds in both Dark and Light themes. The full component suite and final guardrails pass with the companion focus changes. --- crates/moon-ui-components/src/combobox.rs | 9 +- crates/moon-ui-components/src/global_state.rs | 27 +----- .../src/input/element/regression_tests.rs | 91 +++++++++++++++++++ crates/moon-ui-components/src/input/state.rs | 7 +- crates/moon-ui-components/src/popover.rs | 19 +--- crates/moon-ui-components/src/select.rs | 9 +- 6 files changed, 101 insertions(+), 61 deletions(-) diff --git a/crates/moon-ui-components/src/combobox.rs b/crates/moon-ui-components/src/combobox.rs index f3cac61..7cb7f00 100644 --- a/crates/moon-ui-components/src/combobox.rs +++ b/crates/moon-ui-components/src/combobox.rs @@ -13,7 +13,6 @@ use crate::{ StyleSized, StyledExt, actions::{Cancel, Confirm, SelectDown, SelectUp}, button::{ButtonVariant, MoonButtonMetrics, MoonButtonStyle}, - global_state::GlobalState, h_flex, input::{clear_button, input_style}, list::{List, ListState}, @@ -529,15 +528,9 @@ where cx.notify(); } + /// Publish dropdown visibility through its owning view. fn set_open(&mut self, open: bool, cx: &mut Context) { self.state.open = open; - - if self.state.open { - GlobalState::global_mut(cx).register_deferred_popover(&self.state.focus_handle) - } else { - GlobalState::global_mut(cx).unregister_deferred_popover(&self.state.focus_handle) - } - cx.notify(); } diff --git a/crates/moon-ui-components/src/global_state.rs b/crates/moon-ui-components/src/global_state.rs index d800d12..836dbbe 100644 --- a/crates/moon-ui-components/src/global_state.rs +++ b/crates/moon-ui-components/src/global_state.rs @@ -1,5 +1,4 @@ -use gpui::{App, ElementId, Entity, FocusHandle, Global, OwnedMenu}; -use std::collections::HashSet; +use gpui::{App, Entity, Global, OwnedMenu}; use crate::text::TextViewState; @@ -9,12 +8,9 @@ pub(crate) fn init(cx: &mut App) { impl Global for GlobalState {} +/// Shared application menus and text-selection coordination. pub struct GlobalState { pub(crate) text_view_state_stack: Vec>, - /// Set of open popover IDs that use deferred rendering. - /// When this set is not empty, we are inside at least one deferred context. - /// This is used to prevent double-deferred elements which would cause GPUI to panic. - open_deferred_popovers: HashSet, /// Application menus storage app_menus: Vec, /// When true, the window-level text selection must not start on the @@ -25,10 +21,10 @@ pub struct GlobalState { } impl GlobalState { + /// Start with no menus or active text-selection owners. pub(crate) fn new() -> Self { Self { text_view_state_stack: Vec::new(), - open_deferred_popovers: HashSet::new(), app_menus: Vec::new(), suppress_text_selection: false, } @@ -55,23 +51,6 @@ impl GlobalState { self.text_view_state_stack.last() } - /// Check if we are currently inside a deferred context (e.g., inside an open Popover). - pub(crate) fn is_in_deferred_context(&self) -> bool { - !self.open_deferred_popovers.is_empty() - } - - /// Register a popover that uses deferred rendering as open. - pub(crate) fn register_deferred_popover(&mut self, focus_handle: &FocusHandle) { - self.open_deferred_popovers - .insert(format!("{focus_handle:?}").into()); - } - - /// Unregister a popover when it closes. - pub(crate) fn unregister_deferred_popover(&mut self, focus_handle: &FocusHandle) { - let element_id: ElementId = format!("{focus_handle:?}").into(); - self.open_deferred_popovers.remove(&element_id); - } - /// Get the application menus pub fn app_menus(&self) -> &[OwnedMenu] { &self.app_menus diff --git a/crates/moon-ui-components/src/input/element/regression_tests.rs b/crates/moon-ui-components/src/input/element/regression_tests.rs index 386b72d..8d5bf65 100644 --- a/crates/moon-ui-components/src/input/element/regression_tests.rs +++ b/crates/moon-ui-components/src/input/element/regression_tests.rs @@ -19,6 +19,97 @@ use crate::{ moon::{MoonInput, MoonNumberInput, MoonSize, MoonTheme, ThemeMode}, }; +/// A real Input whose context-menu hook opens another deferred overlay through Root. +struct NestedMenuHarness { + input: Entity, + nested: bool, + requests: Rc>, +} + +impl Render for NestedMenuHarness { + /// Keep one popover open while testing both its own input and an input in another window. + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + use crate::moon::MoonContextMenuWindowExt as _; + let requests = self.requests.clone(); + let input = crate::input::Input::new(&self.input).context_menu(move |menu, window, cx| { + requests.set(requests.get() + 1); + window.open_fitted_moon_context_menu( + cx, + "nested-input-menu", + window.mouse_position(), + vec![crate::moon::MoonMenuItem::new("Input command")], + 120., + 300., + ); + // Empty NativeMenu avoids OS menu handling in the test; Root paints the real overlay. + menu + }); + let content = if self.nested { + crate::popover::Popover::new("input-parent-popover") + .open(true) + .trigger_any(gpui::div().w(px(240.)).h(px(30.)).child("Open")) + .child(gpui::div().w(px(240.)).child(input)) + .into_any_element() + } else { + gpui::div().w(px(240.)).child(input).into_any_element() + }; + gpui::div().size_full().child(content) + } +} + +/// Catches the app-global deferred-context ban: an open popover must not disable Input's menu +/// inside itself or in another window. The nested Root menu must also survive actual prepaint. +#[gpui::test] +fn input_context_menu_works_inside_popover_and_in_other_window(cx: &mut TestAppContext) { + cx.update(crate::init); + for theme in [ThemeMode::Dark, ThemeMode::Light] { + cx.update(|cx| MoonTheme::set_mode(theme, cx)); + for nested in [true, false] { + let slot = Rc::new(RefCell::new(None)); + let requests = Rc::new(Cell::new(0)); + let window = cx.add_window({ + let slot = slot.clone(); + let requests = requests.clone(); + move |window, cx| { + let input = cx.new(|cx| InputState::new(window, cx).default_value("edit me")); + slot.replace(Some(input.clone())); + let view = cx.new(|_| NestedMenuHarness { + input, + nested, + requests, + }); + Root::new(view, window, cx).bordered(false) + } + }); + let input = slot.borrow().clone().unwrap(); + let mut visual = VisualTestContext::from_window(window.into(), cx); + for _ in 0..4 { + visual.update(|window, _| window.refresh()); + } + let bounds = visual.update(|_, cx| input.read(cx).input_bounds); + assert!(!bounds.is_empty(), "the input must actually be painted"); + visual.simulate_mouse_down( + bounds.center(), + gpui::MouseButton::Right, + Modifiers::none(), + ); + visual.simulate_mouse_up(bounds.center(), gpui::MouseButton::Right, Modifiers::none()); + assert_eq!( + requests.get(), + 1, + "right click was blocked (nested={nested})" + ); + for _ in 0..4 { + visual.update(|window, _| window.refresh()); + } + let popup = visual + .debug_bounds("nested-input-menu:menu:popup") + .expect("nested menu must be painted"); + assert!(!popup.is_empty(), "nested menu must have visible geometry"); + } + } +} + /// A real input in resizable panels, counting the enclosing view's actual renders. struct SettlingInputHarness { state: Entity, diff --git a/crates/moon-ui-components/src/input/state.rs b/crates/moon-ui-components/src/input/state.rs index 1443f80..2d11194 100644 --- a/crates/moon-ui-components/src/input/state.rs +++ b/crates/moon-ui-components/src/input/state.rs @@ -1754,7 +1754,8 @@ impl InputState { cx.propagate(); } - /// Show the right-click context menu as a native OS menu. + /// Show the right-click menu for this input. Other open popovers do not own this input's + /// menu policy; nested deferred overlays are supported by the window renderer. pub(crate) fn handle_right_click_menu( &mut self, event: &MouseDownEvent, @@ -1762,10 +1763,6 @@ impl InputState { window: &mut Window, cx: &mut Context, ) { - if crate::global_state::GlobalState::global(cx).is_in_deferred_context() { - return; - } - if !self.selected_range.contains(offset) { self.move_to(offset, None, cx); } diff --git a/crates/moon-ui-components/src/popover.rs b/crates/moon-ui-components/src/popover.rs index f667635..e9d8b53 100644 --- a/crates/moon-ui-components/src/popover.rs +++ b/crates/moon-ui-components/src/popover.rs @@ -6,9 +6,7 @@ use gpui::{ }; use std::{cell::Cell, rc::Rc}; -use crate::{ - ElementExt, Selectable, StyledExt as _, actions::Cancel, global_state::GlobalState, v_flex, -}; +use crate::{ElementExt, Selectable, StyledExt as _, actions::Cancel, v_flex}; const CONTEXT: &str = "Popover"; pub(crate) fn init(cx: &mut App) { @@ -274,20 +272,9 @@ impl PopoverState { } } - fn set_open(&mut self, open: bool, cx: &mut Context) { - // Only touch the global registry on an actual transition. A CONTROLLED popover runs this - // from `render` on every frame, and `GlobalState::global_mut` schedules a global-observer - // notification (plus a key allocation) each time — enough, on a popover living in a - // high-frequency host like a chart tab strip, to feed a repaint loop back into itself. - if self.open == open { - return; - } + /// Keep visibility local; another popover does not own an input's context menu. + fn set_open(&mut self, open: bool, _cx: &mut Context) { self.open = open; - if self.open { - GlobalState::global_mut(cx).register_deferred_popover(&self.focus_handle); - } else { - GlobalState::global_mut(cx).unregister_deferred_popover(&self.focus_handle); - } } /// Remember what had focus before this popover takes it, so closing can hand it back. diff --git a/crates/moon-ui-components/src/select.rs b/crates/moon-ui-components/src/select.rs index 01c6523..c404cbb 100644 --- a/crates/moon-ui-components/src/select.rs +++ b/crates/moon-ui-components/src/select.rs @@ -13,7 +13,6 @@ use crate::{ StyleSized, StyledExt, actions::{Cancel, Confirm, SelectDown, SelectUp}, button::ButtonVariant, - global_state::GlobalState, h_flex, input::{clear_button, input_style}, list::List, @@ -424,15 +423,9 @@ where cx.notify(); } + /// Publish dropdown visibility through its owning view. fn set_open(&mut self, open: bool, cx: &mut Context) { self.state.open = open; - - if self.state.open { - GlobalState::global_mut(cx).register_deferred_popover(&self.state.focus_handle) - } else { - GlobalState::global_mut(cx).unregister_deferred_popover(&self.state.focus_handle) - } - cx.notify(); } From 795f3e58f17877202e9eb4083e7b8398d448de19 Mon Sep 17 00:00:00 2001 From: Moonbot-Tech Date: Fri, 6 Nov 2026 17:54:46 +0300 Subject: [PATCH 12/19] fix(dropdown): dismiss the owning popover before opening a dialog A row callback could open a modal while the menu still owned focus. The later menu close then either stole the modal's focus or left its return target on a removed menu. Route selection through the actual PopoverState and dismiss it before invoking the row action or selection callback. Popover close snapshots its descendant/tracked focus before content leaves the dispatch tree. Deferred restoration runs only while that exact focus is still owned; it never steals focus already transferred elsewhere. This removes the duplicate dropdown close plan and parallel open-state updates rather than adding another focus-restoration mechanism. Validation: real Root dialog/menu flows in Dark and Light cover opening a modal, a menu inside that modal, return to the original trigger, tracked and descendant focus, no previous focus, and deliberate transfer elsewhere. Existing selection behavior tests remain; removed policy-only assertions are replaced by lifecycle tests that fail when close ordering is reversed. --- .../moon-ui-components/src/moon/dropdown.rs | 1 - .../src/moon/dropdown/selection.rs | 94 ++---- .../src/moon/dropdown/selection/tests.rs | 61 +--- .../src/moon/dropdown/trigger.rs | 15 +- crates/moon-ui-components/src/popover.rs | 133 +++----- .../moon-ui-components/src/popover/tests.rs | 290 ++++++++++++++++++ 6 files changed, 364 insertions(+), 230 deletions(-) create mode 100644 crates/moon-ui-components/src/popover/tests.rs diff --git a/crates/moon-ui-components/src/moon/dropdown.rs b/crates/moon-ui-components/src/moon/dropdown.rs index 6e978c3..4bdc11a 100644 --- a/crates/moon-ui-components/src/moon/dropdown.rs +++ b/crates/moon-ui-components/src/moon/dropdown.rs @@ -75,7 +75,6 @@ use selection::{ MoonDropdownSelectionContext, menu_item_click_handler, moon_menu_item_accepts_click, }; pub use trigger::MoonDropdown; -use trigger::MoonDropdownState; #[cfg(test)] use trigger::fit_dropdown_trigger_label; diff --git a/crates/moon-ui-components/src/moon/dropdown/selection.rs b/crates/moon-ui-components/src/moon/dropdown/selection.rs index 0f72d1d..1d66604 100644 --- a/crates/moon-ui-components/src/moon/dropdown/selection.rs +++ b/crates/moon-ui-components/src/moon/dropdown/selection.rs @@ -1,9 +1,10 @@ //! Private selection and click-dispatch policy for dropdown menu rows. use crate::moon::foundation::{MoonClickHandler, MoonSelectHandler}; -use gpui::{App, Entity, EntityId, Window}; +use crate::popover::PopoverState; +use gpui::Entity; -use super::{MoonDropdownState, MoonMenuItem, MoonMenuItemKind}; +use super::{MoonMenuItem, MoonMenuItemKind}; #[cfg(test)] use std::sync::atomic::{AtomicUsize, Ordering}; @@ -37,82 +38,32 @@ pub(super) fn moon_menu_item_accepts_click( } } -/// Planned menu-close effects for one resolved selection. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct MoonDropdownSelectPlan { - close_menu: bool, - update_internal_open: bool, -} - -/// Shared dropdown behavior applied lazily only when a menu row is rendered. +/// Shared selection callbacks and the actual popover whose focus must close before dispatch. pub(super) struct MoonDropdownSelectionContext { close_on_select: bool, on_select: Option, - state: Entity, - controlled_open: Option, - on_open_change: Option>, - parent_view: EntityId, + popover: Entity, } impl MoonDropdownSelectionContext { - /// Retain dropdown-level selection behavior for lazily rendered rows. + /// Retain the visible menu's callbacks and lifecycle owner for lazy row handlers. /// - /// Args: - /// close_on_select: Whole-menu close policy. - /// on_select: Optional callback receiving the selected row key. - /// state: Keyed internal open state for an uncontrolled dropdown. - /// controlled_open: Caller-owned open state, when present. - /// on_open_change: Optional callback receiving requested open-state changes. - /// parent_view: View notified after internal state changes. - /// - /// Returns: - /// A retained selection context shared by visible row handlers. + /// `close_on_select` supplies the default when a row has no override; `on_select` receives its + /// key after dismissal. `popover` owns focus restoration and controlled-state notifications. + /// Returns a context shared by the rows of this visible menu. pub(super) fn new( close_on_select: bool, on_select: Option, - state: Entity, - controlled_open: Option, - on_open_change: Option>, - parent_view: EntityId, + popover: Entity, ) -> Self { Self { close_on_select, on_select, - state, - controlled_open, - on_open_change, - parent_view, + popover, } } } -/// Decide what one row's click does to the menu around it. -/// -/// `close_on_select` is a whole-menu policy, but a single menu can legitimately hold both kinds of -/// row: checkbox rows that must leave a multi-select menu standing, and a row that opens a dialog -/// and must also take the menu down during that click — an open popup is deferred ABOVE the dialog -/// layer, so a menu left standing paints over the modal it just opened. `item_closes_menu` is that -/// row's own answer, and it wins; `None` follows the menu. -/// -/// Args: -/// close_on_select: The dropdown's whole-menu policy. -/// item_closes_menu: The clicked row's override, if it declared one. -/// controlled_open: `Some` while the consumer owns the open state. -/// -/// Returns: -/// Whether to close, and whether this dropdown owns the state that records it. -fn moon_dropdown_select_plan( - close_on_select: bool, - item_closes_menu: Option, - controlled_open: Option, -) -> MoonDropdownSelectPlan { - let close_menu = item_closes_menu.unwrap_or(close_on_select); - MoonDropdownSelectPlan { - close_menu, - update_internal_open: close_menu && controlled_open.is_none(), - } -} - /// Resolve one rendered row's native and dropdown-level click behavior. /// /// Args: @@ -136,28 +87,19 @@ pub(super) fn menu_item_click_handler( let key = item.key.clone(); let closes_menu = item.closes_menu; Some(std::rc::Rc::new(move |event, window, cx| { - let plan = moon_dropdown_select_plan( - dropdown.close_on_select, - closes_menu, - dropdown.controlled_open, - ); + // Restore the trigger's focus BEFORE a callback opens a modal/native dialog. Root then + // remembers a live return target rather than the menu handle this click is removing. + if closes_menu.unwrap_or(dropdown.close_on_select) { + dropdown + .popover + .update(cx, |state, cx| state.dismiss(window, cx)); + } if let Some(existing_handler) = existing_handler.as_ref() { existing_handler(event, window, cx); } if let Some(on_select) = dropdown.on_select.as_ref() { on_select(&key, window, cx); } - if plan.close_menu { - if let Some(on_open_change) = dropdown.on_open_change.as_ref() { - on_open_change(false, window, cx); - } - if plan.update_internal_open { - dropdown.state.update(cx, |state, _| { - state.open = false; - }); - cx.notify(dropdown.parent_view); - } - } })) } diff --git a/crates/moon-ui-components/src/moon/dropdown/selection/tests.rs b/crates/moon-ui-components/src/moon/dropdown/selection/tests.rs index 106b744..dbeffab 100644 --- a/crates/moon-ui-components/src/moon/dropdown/selection/tests.rs +++ b/crates/moon-ui-components/src/moon/dropdown/selection/tests.rs @@ -2,8 +2,8 @@ use super::super::{MoonDropdown, MoonMenuItem, MoonMenuItemKind}; use super::{ - MENU_DROPDOWN_HANDLER_PROBE_PREFIX, MoonDropdownSelectPlan, moon_dropdown_select_plan, - moon_menu_item_accepts_click, take_dropdown_handler_probe_count, + MENU_DROPDOWN_HANDLER_PROBE_PREFIX, moon_menu_item_accepts_click, + take_dropdown_handler_probe_count, }; use crate::moon::{MoonPalette, MoonTheme}; use std::{cell::RefCell, rc::Rc}; @@ -170,63 +170,6 @@ fn rendered_action_label_dispatches_while_static_label_stays_inert(cx: &mut gpui } } -/// Catches closing keep-open rows or mutating controlled state in -/// `selection.rs:moon_dropdown_select_plan`, which would dismiss persistent menus or desynchronize -/// a controlled dropdown from its owner. -#[test] -fn dropdown_select_plan_respects_close_and_controlled_state() { - assert_eq!( - moon_dropdown_select_plan(true, None, None), - MoonDropdownSelectPlan { - close_menu: true, - update_internal_open: true, - } - ); - assert_eq!( - moon_dropdown_select_plan(true, None, Some(true)), - MoonDropdownSelectPlan { - close_menu: true, - update_internal_open: false, - } - ); - assert_eq!( - moon_dropdown_select_plan(false, None, None), - MoonDropdownSelectPlan { - close_menu: false, - update_internal_open: false, - } - ); -} - -/// A row's own `closes_menu` override must win over the dropdown's whole-menu `close_on_select` -/// policy in `selection.rs:moon_dropdown_select_plan`, and `update_internal_open` must follow the -/// RESOLVED `close_menu` rather than the dropdown's raw policy. Folding the override back down to -/// `close_on_select` leaves a dialog-opening row unable to force its host menu shut, so the menu -/// paints over the modal it just opened; deriving `update_internal_open` from `close_on_select` -/// instead of `close_menu` leaves an uncontrolled dropdown's internal `open` flag out of sync with -/// a row-forced close, so the menu re-opens on the next repaint. -#[test] -fn dropdown_select_plan_row_override_wins_over_dropdown_policy() { - // Row forces a close on a dropdown configured to stay open (checkbox-menu policy). - assert_eq!( - moon_dropdown_select_plan(false, Some(true), None), - MoonDropdownSelectPlan { - close_menu: true, - update_internal_open: true, - }, - "closes_menu(true) must close a close_on_select(false) dropdown and clear its internal open state" - ); - // Row forces staying open on a dropdown configured to close. - assert_eq!( - moon_dropdown_select_plan(true, Some(false), None), - MoonDropdownSelectPlan { - close_menu: false, - update_internal_open: false, - }, - "closes_menu(false) must keep a close_on_select(true) dropdown open and leave its internal open state untouched" - ); -} - /// Open a large dropdown whose per-row dropdown handlers are counted when resolved. struct LargeHandlerProbeMenuHarness; diff --git a/crates/moon-ui-components/src/moon/dropdown/trigger.rs b/crates/moon-ui-components/src/moon/dropdown/trigger.rs index 69c35af..4ea8624 100644 --- a/crates/moon-ui-components/src/moon/dropdown/trigger.rs +++ b/crates/moon-ui-components/src/moon/dropdown/trigger.rs @@ -663,14 +663,8 @@ impl RenderOnce for MoonDropdown { let menu_header = self.menu_header.clone(); let menu_header_height = self.menu_header_height; let popup_level = MoonMenuLevel::from_parts(std::rc::Rc::new(items), menu_layout); - let dropdown_selection = std::rc::Rc::new(MoonDropdownSelectionContext::new( - self.close_on_select, - self.on_select, - state.clone(), - controlled_open, - self.on_open_change.clone(), - parent_view, - )); + let close_on_select = self.close_on_select; + let on_select = self.on_select; let mut popover = CorePopover::new(ElementId::from(self.id.clone())) .appearance(false) @@ -679,6 +673,11 @@ impl RenderOnce for MoonDropdown { .open(open) .trigger_any(trigger) .content(move |_, window, cx| { + let dropdown_selection = std::rc::Rc::new(MoonDropdownSelectionContext::new( + close_on_select, + on_select.clone(), + cx.entity(), + )); let tokens = MoonTheme::active_tokens(cx); let menu_size = menu_size.unwrap_or_else(|| MoonMenuSize::from_theme(&tokens)); let mut menu = MoonPopupMenu::new(menu_id.clone()) diff --git a/crates/moon-ui-components/src/popover.rs b/crates/moon-ui-components/src/popover.rs index e9d8b53..6d4b7d0 100644 --- a/crates/moon-ui-components/src/popover.rs +++ b/crates/moon-ui-components/src/popover.rs @@ -272,11 +272,6 @@ impl PopoverState { } } - /// Keep visibility local; another popover does not own an input's context menu. - fn set_open(&mut self, open: bool, _cx: &mut Context) { - self.open = open; - } - /// Remember what had focus before this popover takes it, so closing can hand it back. /// /// Must run while the popover is still CLOSED and from a synchronous path: reading it later @@ -286,14 +281,31 @@ impl PopoverState { self.previous_focus_handle = window.focused(cx); } - /// Apply the focus and dismiss-subscription work that belongs to the CURRENT value of `open`. + /// Capture the current content focus before a close removes its dispatch-tree ancestors. + /// Returns no handle when focus has already moved to a dialog or another overlay. + fn closing_focus(&self, window: &Window, cx: &App) -> Option { + window.focused(cx).filter(|focused| { + self.focus_handle.contains(focused, window) + || self + .tracked_focus_handle + .as_ref() + .is_some_and(|tracked| tracked.contains(focused, window)) + }) + } + + /// Apply focus/subscription changes; `closing_focus` is captured before removing the content. /// /// Split out of `toggle_open` because a CONTROLLED popover (`Popover::open(bool)`, driven by a - /// caller-held flag) never went through it: `render` flipped the flag with `set_open` alone, + /// caller-held flag) never went through it: `render` flipped the flag alone, /// which left focus parked on the popover's about-to-be-removed handle and the dismiss /// subscription alive. That emptied the window's focus path, so element-level key handlers fell /// off the dispatch tree and application hotkeys stopped arriving until the user clicked. - pub(crate) fn sync_open_focus(&mut self, window: &mut Window, cx: &mut Context) { + pub(crate) fn sync_open_focus( + &mut self, + closing_focus: Option, + window: &mut Window, + cx: &mut Context, + ) { if self.open { let state = cx.entity(); let focus_handle = if let Some(tracked_focus_handle) = self.tracked_focus_handle.clone() @@ -315,30 +327,31 @@ impl PopoverState { ); } else { self._dismiss_subscription = None; - // Hand focus back. `focus_contains` rather than `contains_focused`: focus may sit on a - // DESCENDANT of the popover (an input, a list row) or on a tracked handle, and on the - // deferred close path the popover is already gone from the rendered frame. - let holds_focus = window - .focused(cx) - .is_some_and(|focused| focused == self.focus_handle); - if let Some(prev) = self.previous_focus_handle.take() { - prev.focus(window, cx); - } else if holds_focus { - // Nothing to restore — the common case for a header or strip gear opened while - // nothing was focused. Blur explicitly: leaving focus on the handle that is about - // to stop rendering is exactly what strands the dispatch path and kills hotkeys. - window.blur(); + let previous = self.previous_focus_handle.take(); + // A deferred close must not steal focus transferred after its ownership snapshot. + if closing_focus.is_some() && window.focused(cx) == closing_focus { + if let Some(previous) = previous { + previous.focus(window, cx); + } else { + window.blur(); + } } } } + /// Change visibility together with its focus, subscription and caller notification. fn toggle_open(&mut self, window: &mut Window, cx: &mut Context) { let opening = !self.open; if opening { self.remember_previous_focus(window, cx); } - self.set_open(opening, cx); - self.sync_open_focus(window, cx); + let closing_focus = if opening { + None + } else { + self.closing_focus(window, cx) + }; + self.open = opening; + self.sync_open_focus(closing_focus, window, cx); if let Some(callback) = self.on_open_change.as_ref() { callback(&self.open, window, cx); @@ -427,12 +440,17 @@ impl RenderOnce for Popover { // deferred below — focusing mid-render is not safe — but skipping it entirely is what used // to strand the window's focus (see `sync_open_focus`). let open_changed = force_open.is_some_and(|force_open| state.read(cx).open != force_open); + let closing_focus = if open_changed && force_open == Some(false) { + state.read(cx).closing_focus(window, cx) + } else { + None + }; if let Some(force_open) = force_open { state.update(cx, |state, cx| { if open_changed && force_open { state.remember_previous_focus(window, cx); } - state.set_open(force_open, cx); + state.open = force_open; }); } @@ -450,7 +468,9 @@ impl RenderOnce for Popover { if open_changed { let state = state.clone(); window.defer(cx, move |window, cx| { - state.update(cx, |state, cx| state.sync_open_focus(window, cx)); + state.update(cx, |state, cx| { + state.sync_open_focus(closing_focus, window, cx) + }); }); } @@ -473,7 +493,7 @@ impl RenderOnce for Popover { state.update(cx, |state, cx| { // We force set open to false to toggle it correctly. // Because if the mouse down out will toggle open first. - state.set_open(open, cx); + state.open = open; state.toggle_open(window, cx); }); cx.notify(parent_view_id); @@ -553,63 +573,4 @@ impl RenderOnce for Popover { } #[cfg(test)] -mod tests { - use super::*; - use gpui::MouseButton; - - #[test] - fn test_popover_builder_chaining() { - let popover = Popover::new("test") - .anchor(Anchor::BottomCenter) - .mouse_button(MouseButton::Right) - .default_open(true) - .appearance(false) - .overlay_closable(false); - - assert_eq!(popover.anchor, Anchor::BottomCenter); - assert_eq!(popover.mouse_button, MouseButton::Right); - assert!(popover.default_open); - assert!(!popover.appearance); - assert!(!popover.overlay_closable); - } - - #[test] - fn test_resolved_corner_top_positions() { - use gpui::px; - - let bounds = Bounds { - origin: Point { - x: px(100.), - y: px(100.), - }, - size: gpui::Size { - width: px(200.), - height: px(50.), - }, - }; - - let pos = Popover::resolved_corner(Anchor::TopLeft, bounds); - assert_eq!(pos.x, px(100.)); - assert_eq!(pos.y, px(100.)); - - let pos = Popover::resolved_corner(Anchor::TopCenter, bounds); - assert_eq!(pos.x, px(200.)); - assert_eq!(pos.y, px(100.)); - - let pos = Popover::resolved_corner(Anchor::TopRight, bounds); - assert_eq!(pos.x, px(300.)); - assert_eq!(pos.y, px(100.)); - - let pos = Popover::resolved_corner(Anchor::BottomLeft, bounds); - assert_eq!(pos.x, px(100.)); - assert_eq!(pos.y, px(50.)); - - let pos = Popover::resolved_corner(Anchor::BottomCenter, bounds); - assert_eq!(pos.x, px(200.)); - assert_eq!(pos.y, px(50.)); - - let pos = Popover::resolved_corner(Anchor::BottomRight, bounds); - assert_eq!(pos.x, px(300.)); - assert_eq!(pos.y, px(50.)); - } -} +mod tests; diff --git a/crates/moon-ui-components/src/popover/tests.rs b/crates/moon-ui-components/src/popover/tests.rs new file mode 100644 index 0000000..9e96eff --- /dev/null +++ b/crates/moon-ui-components/src/popover/tests.rs @@ -0,0 +1,290 @@ +use super::Popover; +use gpui::MouseButton; +use gpui::{Anchor, Bounds, Point}; + +#[test] +fn test_popover_builder_chaining() { + let popover = Popover::new("test") + .anchor(Anchor::BottomCenter) + .mouse_button(MouseButton::Right) + .default_open(true) + .appearance(false) + .overlay_closable(false); + + assert_eq!(popover.anchor, Anchor::BottomCenter); + assert_eq!(popover.mouse_button, MouseButton::Right); + assert!(popover.default_open); + assert!(!popover.appearance); + assert!(!popover.overlay_closable); +} + +#[test] +fn test_resolved_corner_top_positions() { + use gpui::px; + + let bounds = Bounds { + origin: Point { + x: px(100.), + y: px(100.), + }, + size: gpui::Size { + width: px(200.), + height: px(50.), + }, + }; + + let pos = Popover::resolved_corner(Anchor::TopLeft, bounds); + assert_eq!(pos.x, px(100.)); + assert_eq!(pos.y, px(100.)); + + let pos = Popover::resolved_corner(Anchor::TopCenter, bounds); + assert_eq!(pos.x, px(200.)); + assert_eq!(pos.y, px(100.)); + + let pos = Popover::resolved_corner(Anchor::TopRight, bounds); + assert_eq!(pos.x, px(300.)); + assert_eq!(pos.y, px(100.)); + + let pos = Popover::resolved_corner(Anchor::BottomLeft, bounds); + assert_eq!(pos.x, px(100.)); + assert_eq!(pos.y, px(50.)); + + let pos = Popover::resolved_corner(Anchor::BottomCenter, bounds); + assert_eq!(pos.x, px(200.)); + assert_eq!(pos.y, px(50.)); + + let pos = Popover::resolved_corner(Anchor::BottomRight, bounds); + assert_eq!(pos.x, px(300.)); + assert_eq!(pos.y, px(50.)); +} + +use crate::{ + Root, WindowExt as _, + moon::{MoonDropdown, MoonMenuItem, MoonTheme, ThemeMode}, +}; +use gpui::{ + AppContext as _, Context, FocusHandle, InteractiveElement as _, IntoElement, + ParentElement as _, Render, Styled as _, VisualTestContext, Window, div, px, +}; +use std::{cell::Cell, rc::Rc}; + +/// A window-root trigger whose menu opens a real Root dialog with its own focus target. +struct DialogFocusHarness { + open: Rc>, + origin: FocusHandle, + field: FocusHandle, +} + +impl Render for DialogFocusHarness { + /// Mount the real dropdown selection handler, not a duplicate selection policy. + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let field = self.field.clone(); + let open = self.open.clone(); + let owner = cx.entity_id(); + div().track_focus(&self.origin).child( + MoonDropdown::new("dialog-opener") + .label("Open") + .open(self.open.get()) + .on_open_change(move |value, _, cx| { + open.set(value); + cx.notify(owner); + }) + .item( + MoonMenuItem::with_key("modal", "Modal") + .closes_menu(true) + .on_click(move |_, window, cx| { + let body_focus = field.clone(); + window.open_dialog(cx, move |dialog, _, _| { + let body_focus = body_focus.clone(); + dialog.content(move |body, _, _| { + body.child( + div() + .track_focus(&body_focus) + .w(px(180.)) + .h(px(40.)) + .child( + div() + .debug_selector(|| { + "dialog-inner-trigger".into() + }) + .w(px(80.)) + .h(px(30.)) + .child( + MoonDropdown::new("dialog-inner") + .label("Nested") + .item(MoonMenuItem::new( + "Nested action", + )), + ), + ), + ) + }) + }); + field.focus(window, cx); + }), + ), + ) + } +} + +/// Closing a menu after its callback opened a dialog must neither steal the field's focus nor +/// leave the dialog's return focus on a hidden menu. Reordering close after the callback reddens +/// the final origin assertion; unconditional deferred restoration reddens the field assertion. +#[gpui::test] +fn dropdown_select_plan_respects_close_and_controlled_state(cx: &mut gpui::TestAppContext) { + cx.update(crate::init); + for theme in [ThemeMode::Dark, ThemeMode::Light] { + cx.update(|cx| MoonTheme::set_mode(theme, cx)); + let open = Rc::new(Cell::new(false)); + let handles = Rc::new(std::cell::RefCell::new(None)); + let window = cx.add_window({ + let open = open.clone(); + let handles = handles.clone(); + move |window, cx| { + let origin = cx.focus_handle(); + let field = cx.focus_handle(); + *handles.borrow_mut() = Some((origin.clone(), field.clone())); + let view = cx.new(|_| DialogFocusHarness { + open, + origin, + field, + }); + Root::new(view, window, cx) + } + }); + let (origin, field) = handles.borrow().clone().unwrap(); + let mut visual = VisualTestContext::from_window(window.into(), cx); + visual.run_until_parked(); + visual.update(|window, cx| { + origin.focus(window, cx); + open.set(true); + window.refresh(); + }); + visual.run_until_parked(); + let row = visual + .debug_bounds("dialog-opener:menu:item:0") + .expect("open menu row"); + visual.simulate_click(row.center(), gpui::Modifiers::default()); + visual.run_until_parked(); + visual.update(|window, cx| { + assert_eq!( + window.focused(cx), + Some(field.clone()), + "menu close stole modal focus" + ); + assert!(window.has_active_dialog(cx)); + }); + let nested_trigger = visual + .debug_bounds("dialog-inner-trigger") + .expect("dialog menu trigger"); + visual.simulate_click(nested_trigger.center(), gpui::Modifiers::default()); + visual.run_until_parked(); + let nested_row = visual + .debug_bounds("dialog-inner:menu:item:0") + .expect("menu inside dialog must open"); + visual.simulate_click(nested_row.center(), gpui::Modifiers::default()); + visual.run_until_parked(); + visual.update(|window, cx| { + assert_eq!( + window.focused(cx), + Some(field.clone()), + "nested menu must return to modal field" + ); + window.close_dialog(cx); + assert_eq!( + window.focused(cx), + Some(origin.clone()), + "dialog returned to hidden menu" + ); + }); + } +} + +/// A controlled popover with either a descendant or explicitly tracked content focus. +struct ClosingFocusHarness { + open: Rc>, + origin: FocusHandle, + child: FocusHandle, + other: FocusHandle, + tracked: bool, +} + +impl Render for ClosingFocusHarness { + /// Render the same ownership tree used by a retained input inside a controlled popover. + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let child = self.child.clone(); + let mut popup = Popover::new("closing-owner") + .open(self.open.get()) + .trigger_any(div().w(px(20.)).h(px(20.))) + .content(move |_, _, _| div().track_focus(&child).w(px(100.)).h(px(40.))); + if self.tracked { + popup = popup.track_focus(&self.child); + } + div() + .track_focus(&self.origin) + .child(popup) + .child(div().track_focus(&self.other)) + } +} + +/// Restoring only the popover's exact handle strands descendant/tracked input focus after close; +/// restoring no-prior focus to the removed handle also strands application hotkeys. +#[gpui::test] +fn controlled_close_releases_descendant_and_tracked_focus(cx: &mut gpui::TestAppContext) { + cx.update(crate::init); + for theme in [ThemeMode::Dark, ThemeMode::Light] { + cx.update(|cx| MoonTheme::set_mode(theme, cx)); + for tracked in [false, true] { + for (previous, transfer) in [(false, false), (true, false), (true, true)] { + let open = Rc::new(Cell::new(false)); + let window = cx.add_window({ + let open = open.clone(); + move |_, cx| ClosingFocusHarness { + open, + origin: cx.focus_handle(), + child: cx.focus_handle(), + other: cx.focus_handle(), + tracked, + } + }); + let (origin, child, other) = window + .read_with(cx, |view, _| { + (view.origin.clone(), view.child.clone(), view.other.clone()) + }) + .unwrap(); + let mut visual = VisualTestContext::from_window(window.into(), cx); + visual.run_until_parked(); + visual.update(|window, cx| { + if previous { + origin.focus(window, cx); + } else { + window.blur(); + } + open.set(true); + window.refresh(); + }); + visual.run_until_parked(); + visual.update(|window, cx| { + child.focus(window, cx); + if transfer { + other.focus(window, cx); + } + open.set(false); + window.refresh(); + }); + visual.run_until_parked(); + visual.update(|window, cx| { + assert_eq!( + window.focused(cx), + if transfer { + Some(other.clone()) + } else { + previous.then(|| origin.clone()) + }, + "close must release removed content, tracked={tracked}, previous={previous}" + ); + }); + } + } + } +} From e2ce1e9fe2700c26d3a94fbaf20e1e94ab2c34dc Mon Sep 17 00:00:00 2001 From: Moonbot-Tech Date: Fri, 6 Nov 2026 17:54:47 +0300 Subject: [PATCH 13/19] chore(components): record the reviewed input and overlay donor drift Update the existing fork reasons and byte/hash ledger for the component fixes in this series: convergent Input/List/Resizable invalidation and local popover ownership supporting nested input menus. Keep the original donor references and audit baseline; do not broaden drift budgets or conceal unrelated upstream differences. The calendar class migration is recorded with its own behavior commit. These records describe the final component source already covered by the runtime and component regressions, so future donor updates can distinguish intentional fixes from accidental drift. Full component API, mirror and audit guardrails pass against this exact snapshot. --- .../component-manifest.json | 12 ++-- docs/component-mirror-baseline.json | 62 +++++++++---------- 2 files changed, 37 insertions(+), 37 deletions(-) diff --git a/crates/moon-ui-components/component-manifest.json b/crates/moon-ui-components/component-manifest.json index 8f50e0d..2ae759b 100644 --- a/crates/moon-ui-components/component-manifest.json +++ b/crates/moon-ui-components/component-manifest.json @@ -150,7 +150,7 @@ "theme_source": "MoonTheme bridge through MoonCombobox facade", "public_path": "moon_ui::MoonCombobox", "upstream_ref": "Longbridge::combobox@cda0fc7fd4e4809dd2a8bae0337ac43b49e9f675", - "fork_reason": "Reviewed TrackedFork drift: Moon trigger variant/size and menu-look popup hooks live in the base combobox and searchable-list rows, so a combobox can stand in a row of Moon dropdowns; Longbridge combobox behavior remains source-owned", + "fork_reason": "Reviewed TrackedFork drift: Moon trigger variant/size and menu-look popup hooks live in the base combobox and searchable-list rows; dropdown visibility stays local so other inputs can open nested context menus", "donor_drift_budget": 1, "contracts": ["gallery.visual_coverage"] }, @@ -348,7 +348,7 @@ "theme_source": "MoonTheme tokens through theme bridge / Moon wrapper", "public_path": "moon_ui::MoonInput", "upstream_ref": "Longbridge::input@cda0fc7fd4e4809dd2a8bae0337ac43b49e9f675", - "fork_reason": "Reviewed TrackedFork drift: Moon tone/theme hooks plus UTF-8, mask, number, and search hardening live in base input; Longbridge editor engine remains source-owned", + "fork_reason": "Reviewed TrackedFork drift: Moon tone/theme hooks, UTF-8/mask/number/search hardening, convergent paint/scroll/property invalidation, and nested context-menu support live in base input; Longbridge editor engine remains source-owned", "donor_drift_budget": 20, "contracts": ["input.utf8_boundary_clamp", "input.mask_contract", "gallery.visual_coverage"] }, @@ -409,7 +409,7 @@ "theme_source": "MoonTheme bridge through MoonList facade", "public_path": "moon_ui::MoonList", "upstream_ref": "Longbridge::list@cda0fc7fd4e4809dd2a8bae0337ac43b49e9f675", - "fork_reason": "Reviewed TrackedFork drift: Moon list item/separator styling and optional per-section virtual chrome geometry/tests live in base list files; remaining Longbridge list behavior stays source-owned", + "fork_reason": "Reviewed TrackedFork drift: Moon list styling, optional per-section virtual chrome, and idempotent search-mode invalidation live in base list files; remaining Longbridge list behavior stays source-owned", "donor_drift_budget": 6, "contracts": ["gallery.visual_coverage"] }, @@ -420,7 +420,7 @@ "theme_source": "MoonTheme tokens through theme bridge / Moon wrapper", "public_path": "moon_ui::MoonPopover", "upstream_ref": "Longbridge::popover@cda0fc7fd4e4809dd2a8bae0337ac43b49e9f675", - "fork_reason": "Reviewed TrackedFork drift: Moon placement/style hook lives in base popover; Longbridge popover lifecycle remains source-owned", + "fork_reason": "Reviewed TrackedFork drift: Moon placement/style hooks and local visibility ownership allow nested context menus; Longbridge popover lifecycle remains source-owned", "donor_drift_budget": 1, "contracts": [ "popover.open_close_lifecycle", @@ -491,7 +491,7 @@ "theme_source": "MoonTheme tokens through theme bridge / Moon wrapper", "public_path": "moon_ui::MoonSelect", "upstream_ref": "Longbridge::select@cda0fc7fd4e4809dd2a8bae0337ac43b49e9f675", - "fork_reason": "Reviewed TrackedFork drift: Moon trigger variant/style and configurable deferred menu layer live in base select; MoonSelect explicitly opts popover-hosted menus above their parent, while ordinary selects retain layer 1 and Longbridge selection behavior", + "fork_reason": "Reviewed TrackedFork drift: Moon trigger variant/style, configurable deferred menu layer, and local visibility ownership live in base select; MoonSelect places popover-hosted menus above their parent while ordinary selects retain layer 1", "donor_drift_budget": 1, "contracts": ["select.open_select_lifecycle", "gallery.visual_coverage"] }, @@ -661,7 +661,7 @@ "theme_source": "MoonTheme bridge through resize handles", "public_path": "moon_ui::MoonResizablePanelGroup", "upstream_ref": "Longbridge::resizable@cda0fc7fd4e4809dd2a8bae0337ac43b49e9f675", - "fork_reason": "Reviewed TrackedFork drift: Moon resize handle/panel style hooks live in base resizable files; Longbridge resize behavior remains source-owned", + "fork_reason": "Reviewed TrackedFork drift: Moon resize handle/panel style hooks and convergent measured-layout invalidation live in base resizable files; Longbridge resize behavior remains source-owned", "donor_drift_budget": 3, "contracts": ["gallery.visual_coverage"] }, diff --git a/docs/component-mirror-baseline.json b/docs/component-mirror-baseline.json index 4e528ba..e125ec4 100644 --- a/docs/component-mirror-baseline.json +++ b/docs/component-mirror-baseline.json @@ -229,12 +229,12 @@ "local_paths": [ "combobox.rs" ], - "local_hash": "4c7d616e46a4fae8", + "local_hash": "84472eb8d6fba05b", "local_files": [ { "path": "combobox.rs", - "hash": "b87ebd3a62f14230", - "bytes": 52706 + "hash": "7499b13b03302754", + "bytes": 52492 } ], "donor_hash": "2324438aee108e17", @@ -439,7 +439,7 @@ "local_paths": [ "input" ], - "local_hash": "4c1232652526f5a8", + "local_hash": "0c2eebee27b8392e", "local_files": [ { "path": "input/blink_cursor.rs", @@ -498,13 +498,13 @@ }, { "path": "input/element.rs", - "hash": "a3ec163629b2e82f", - "bytes": 97481 + "hash": "39d064778889f3cd", + "bytes": 97647 }, { "path": "input/element/regression_tests.rs", - "hash": "bec5af9ae7e1324d", - "bytes": 9830 + "hash": "0fde35ab25ffadda", + "bytes": 18020 }, { "path": "input/indent.rs", @@ -623,8 +623,8 @@ }, { "path": "input/state.rs", - "hash": "dd71e6ca18f229f3", - "bytes": 134622 + "hash": "5a72cf8bbd588e66", + "bytes": 134925 } ], "donor_hash": "018ee239acbf3f7c", @@ -841,7 +841,7 @@ "local_paths": [ "list" ], - "local_hash": "ad8914702317fa18", + "local_hash": "d7d0003ee9bb2d68", "local_files": [ { "path": "list/cache.rs", @@ -855,13 +855,13 @@ }, { "path": "list/list.rs", - "hash": "4867689acbd4bd3a", - "bytes": 26406 + "hash": "feab4ba70bc7d5fb", + "bytes": 26549 }, { "path": "list/list/tests.rs", - "hash": "af6d792dd1a5a37c", - "bytes": 8154 + "hash": "eeba5740c3ee861d", + "bytes": 9541 }, { "path": "list/list_item.rs", @@ -1052,12 +1052,12 @@ "local_paths": [ "popover.rs" ], - "local_hash": "529dd018e2c7f81b", + "local_hash": "93b6226d8ab8e37e", "local_files": [ { "path": "popover.rs", - "hash": "b3316c5b02f1d758", - "bytes": 23073 + "hash": "eecb6be006ac2b3e", + "bytes": 21041 } ], "donor_hash": "da9409c2c18e2a14", @@ -1176,12 +1176,12 @@ "local_paths": [ "resizable" ], - "local_hash": "43392145fd09db69", + "local_hash": "02e61424934fe294", "local_files": [ { "path": "resizable/mod.rs", - "hash": "6c0aaba0b72b5b8e", - "bytes": 12678 + "hash": "85076a14dbf93d98", + "bytes": 12891 }, { "path": "resizable/panel.rs", @@ -1226,12 +1226,12 @@ "local_paths": [ "select.rs" ], - "local_hash": "8da0aea74824b10f", + "local_hash": "8b763297bb92a070", "local_files": [ { "path": "select.rs", - "hash": "f068cbebb4147784", - "bytes": 32242 + "hash": "d79708fd374c195e", + "bytes": 32028 } ], "donor_hash": "245acdb36368c661", @@ -1514,7 +1514,7 @@ "local_paths": [ "input" ], - "local_hash": "4c1232652526f5a8", + "local_hash": "0c2eebee27b8392e", "local_files": [ { "path": "input/blink_cursor.rs", @@ -1573,13 +1573,13 @@ }, { "path": "input/element.rs", - "hash": "a3ec163629b2e82f", - "bytes": 97481 + "hash": "39d064778889f3cd", + "bytes": 97647 }, { "path": "input/element/regression_tests.rs", - "hash": "bec5af9ae7e1324d", - "bytes": 9830 + "hash": "0fde35ab25ffadda", + "bytes": 18020 }, { "path": "input/indent.rs", @@ -1698,8 +1698,8 @@ }, { "path": "input/state.rs", - "hash": "dd71e6ca18f229f3", - "bytes": 134622 + "hash": "5a72cf8bbd588e66", + "bytes": 134925 } ], "donor_hash": "018ee239acbf3f7c", From d0b4bc6ab55be23d3d5f284930767cf8b8faf98a Mon Sep 17 00:00:00 2001 From: Moonbot-Tech Date: Fri, 6 Nov 2026 20:04:40 +0300 Subject: [PATCH 14/19] fix(input): honor explicit height for single-line fields MoonInput Custom metrics supplied a height, but the shared Input applied it only in multiline mode. A 19px field became 22px with the terminal font delta, breaking the wrapper geometry contract. Apply the existing optional height after the multiline auto-height choice. Preserve default sizing and update only the Input/TextArea donor-drift baseline. Regression: measure unconstrained real GPUI layout for Compact, Small, Normal, Custom and minimum-height clamping in Dark and Light. The old code fails at 22px versus 19px; the fix passes on Windows and Linux. MoonUI guardrails and terminal workspace checks pass. --- crates/moon-ui-components/src/input/input.rs | 10 +- crates/moon-ui-components/src/moon/input.rs | 3 + .../src/moon/input/tests.rs | 100 ++++++++++++++++++ docs/component-mirror-baseline.json | 12 +-- 4 files changed, 113 insertions(+), 12 deletions(-) create mode 100644 crates/moon-ui-components/src/moon/input/tests.rs diff --git a/crates/moon-ui-components/src/input/input.rs b/crates/moon-ui-components/src/input/input.rs index 2ebeddd..74c4fe5 100644 --- a/crates/moon-ui-components/src/input/input.rs +++ b/crates/moon-ui-components/src/input/input.rs @@ -190,13 +190,13 @@ impl Input { self } - /// Set full height of the input (Multi-line only). + /// Fill the available height for either single-line or multi-line input. pub fn h_full(mut self) -> Self { self.height = Some(relative(1.)); self } - /// Set height of the input (Multi-line only). + /// Override the input height, preserving auto-height only when no height is supplied. pub fn h(mut self, height: impl Into) -> Self { self.height = Some(height.into()); self @@ -475,10 +475,8 @@ impl RenderOnce for Input { )) .when(!self.disabled, |this| this.cursor_text()) .items_center() - .when(state.mode.is_multi_line(), |this| { - this.h_auto() - .when_some(self.height, |this, height| this.h(height)) - }) + .when(state.mode.is_multi_line(), |this| this.h_auto()) + .when_some(self.height, |this, height| this.h(height)) .when(self.appearance, |this| { this.bg(bg) .rounded(metrics.radius) diff --git a/crates/moon-ui-components/src/moon/input.rs b/crates/moon-ui-components/src/moon/input.rs index 290e909..1662f52 100644 --- a/crates/moon-ui-components/src/moon/input.rs +++ b/crates/moon-ui-components/src/moon/input.rs @@ -308,3 +308,6 @@ fn size_for(size: MoonInputSize) -> Size { MoonInputSize::Custom { height, .. } => Size::Size(px(height)), } } + +#[cfg(test)] +mod tests; diff --git a/crates/moon-ui-components/src/moon/input/tests.rs b/crates/moon-ui-components/src/moon/input/tests.rs new file mode 100644 index 0000000..e01d3b0 --- /dev/null +++ b/crates/moon-ui-components/src/moon/input/tests.rs @@ -0,0 +1,100 @@ +//! Layout checks for custom input metrics, including the terminal's normal font delta. + +use gpui::{ + AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _, + Render, Styled as _, TestAppContext, VisualTestContext, Window, div, px, +}; + +use super::{MoonInput, MoonInputSize, MoonInputState}; +use crate::{ + Root, + moon::{MoonTheme, MoonThemeConfig, ThemeMode}, + v_flex, +}; + +/// The following sibling measures the input's real layout height rather than its builder fields. +struct HeightHarness { + input: Entity, + size: MoonInputSize, +} + +impl Render for HeightHarness { + /// Leave vertical size unconstrained so a wrong input height cannot be hidden by the host. + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + v_flex() + .w(px(240.)) + .debug_selector(|| "input-height-host".into()) + .child( + MoonInput::new("height-input") + .state(&self.input) + .size(self.size), + ) + .child( + div() + .w_full() + .h(px(1.)) + .debug_selector(|| "after-input".into()), + ) + } +} + +/// Restoring the multiline-only height override makes the Custom case 22px instead of 19px. +#[gpui::test] +fn custom_height_uses_its_own_text_metrics(cx: &mut TestAppContext) { + cx.update(crate::init); + for mode in [ThemeMode::Dark, ThemeMode::Light] { + cx.update(|cx| { + let mut config = MoonThemeConfig::default().with_font_delta(3.); + config.mode = mode; + MoonTheme::install_config(config, cx); + }); + for (size, expected) in [ + (MoonInputSize::Compact, 23.), + (MoonInputSize::Small, 25.), + (MoonInputSize::Normal, 31.), + ( + MoonInputSize::Custom { + height: 19., + radius: 2., + font_size: 10., + line_height: 16., + pad_x: 4., + pad_y: 0., + gap: 2., + }, + 19., + ), + ( + MoonInputSize::Custom { + height: 0., + radius: 2., + font_size: 10., + line_height: 16., + pad_x: 4., + pad_y: 0., + gap: 2., + }, + 19., + ), + ] { + let window = cx.add_window(move |window, cx| { + let input = cx.new(|cx| MoonInputState::new(window, cx).default_value("123")); + let view = cx.new(|_| HeightHarness { input, size }); + Root::new(view, window, cx).bordered(false) + }); + let mut visual = VisualTestContext::from_window(window.into(), cx); + visual.run_until_parked(); + let host = visual + .debug_bounds("input-height-host") + .expect("host rendered"); + let next = visual + .debug_bounds("after-input") + .expect("next sibling rendered"); + assert_eq!( + next.top() - host.top(), + px(expected), + "size={size:?}, mode={mode:?}" + ); + } + } +} diff --git a/docs/component-mirror-baseline.json b/docs/component-mirror-baseline.json index e125ec4..120d9eb 100644 --- a/docs/component-mirror-baseline.json +++ b/docs/component-mirror-baseline.json @@ -439,7 +439,7 @@ "local_paths": [ "input" ], - "local_hash": "0c2eebee27b8392e", + "local_hash": "323d2518270e40af", "local_files": [ { "path": "input/blink_cursor.rs", @@ -513,8 +513,8 @@ }, { "path": "input/input.rs", - "hash": "3b614fdff94c34a7", - "bytes": 22572 + "hash": "6e159fafcb8b076a", + "bytes": 22594 }, { "path": "input/lsp/code_actions.rs", @@ -1514,7 +1514,7 @@ "local_paths": [ "input" ], - "local_hash": "0c2eebee27b8392e", + "local_hash": "323d2518270e40af", "local_files": [ { "path": "input/blink_cursor.rs", @@ -1588,8 +1588,8 @@ }, { "path": "input/input.rs", - "hash": "3b614fdff94c34a7", - "bytes": 22572 + "hash": "6e159fafcb8b076a", + "bytes": 22594 }, { "path": "input/lsp/code_actions.rs", From 0e1c69052390b2043993cbb8e486d55521ce4191 Mon Sep 17 00:00:00 2001 From: Moonbot-Tech Date: Fri, 6 Nov 2026 20:04:56 +0300 Subject: [PATCH 15/19] fix(text-area): expose the public size argument through MoonUI MoonTextArea::size accepted MoonTextAreaSize from a private module, so downstream users could not name the supported variants. Re-export the existing enum through the Moon facade and record the exact public signature. No new wrapper or sizing behavior is introduced. Validated with MoonUI component/gallery/API guardrails and the full terminal workspace. --- crates/moon-ui-components/src/moon/mod.rs | 2 +- docs/component-api-baseline.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/moon-ui-components/src/moon/mod.rs b/crates/moon-ui-components/src/moon/mod.rs index 654b6ad..55b0e8c 100644 --- a/crates/moon-ui-components/src/moon/mod.rs +++ b/crates/moon-ui-components/src/moon/mod.rs @@ -210,7 +210,7 @@ pub use tag::MoonTag; // Exported on its own line so the recorded API item above stays byte-identical. pub use tag::{MoonTagRadius, MoonTagVariant}; pub use text::MoonText; -pub use text_area::{MoonTextArea, MoonTextAreaEvent, MoonTextAreaState}; +pub use text_area::{MoonTextArea, MoonTextAreaEvent, MoonTextAreaSize, MoonTextAreaState}; pub use theme::{ MoonScale, MoonTextMetrics, MoonTheme, MoonThemeConfig, MoonThemeConfigError, MoonThemeTokens, MoonTypography, diff --git a/docs/component-api-baseline.json b/docs/component-api-baseline.json index b7c8a90..4279ad0 100644 --- a/docs/component-api-baseline.json +++ b/docs/component-api-baseline.json @@ -2827,7 +2827,7 @@ }, { "file": "crates/moon-ui-components/src/moon/mod.rs", - "signature": "pub use text_area::{MoonTextArea, MoonTextAreaEvent, MoonTextAreaState};" + "signature": "pub use text_area::{MoonTextArea, MoonTextAreaEvent, MoonTextAreaSize, MoonTextAreaState};" }, { "file": "crates/moon-ui-components/src/moon/mod.rs", From 967afcdc3c2f0e1bfd92765622fae8174a2395cd Mon Sep 17 00:00:00 2001 From: Moonbot-Tech Date: Fri, 6 Nov 2026 20:05:06 +0300 Subject: [PATCH 16/19] fix(window): share user-close policy with client-side frame controls The Linux MoonWindowFrame close button removed its window directly, bypassing the native should-close callback. Applications could neither veto that close nor distinguish it from owner teardown. Retain one replaceable policy on Window and expose request_close for client-drawn controls. Native close uses the same policy; remove_window remains an unconditional teardown operation. Preserve the existing callback registration API and release its borrow before invoking user code. Real GPUI test windows verify veto, callback replacement, accepted/default close, forced removal and the FrameButton path on Windows and Linux. Native compositor click/lifecycle qualification remains separate; no macOS or Linux visual proof is claimed by these tests. --- crates/moon-gpui/src/window.rs | 23 ++++++++- crates/moon-gpui/src/window/tests.rs | 50 +++++++++++++++++++ .../src/moon/window_frame.rs | 7 +-- .../src/moon/window_frame/tests.rs | 46 +++++++++++++++++ 4 files changed, 121 insertions(+), 5 deletions(-) diff --git a/crates/moon-gpui/src/window.rs b/crates/moon-gpui/src/window.rs index 2866a9b..9647ba8 100644 --- a/crates/moon-gpui/src/window.rs +++ b/crates/moon-gpui/src/window.rs @@ -1232,11 +1232,15 @@ mod input_modality_tests { } } +/// Shared close policy used by both native and client-drawn window controls. +type WindowCloseCallback = Rc bool>; + /// Holds the state for a specific window. pub struct Window { pub(crate) handle: AnyWindowHandle, pub(crate) invalidator: WindowInvalidator, pub(crate) removed: bool, + should_close: RefCell>, pub(crate) platform_window: Box, display_id: Option, sprite_atlas: Arc, @@ -1888,6 +1892,7 @@ impl Window { handle, invalidator, removed: false, + should_close: RefCell::new(None), platform_window, display_id, sprite_atlas, @@ -2078,11 +2083,24 @@ impl Window { } } - /// Close this window. + /// Close this window unconditionally, including during owner teardown. pub fn remove_window(&mut self) { self.removed = true; } + /// Request a user close, respecting the same veto as the native window controls. + pub fn request_close(&mut self, cx: &mut App) { + if self.can_close(cx) { + self.remove_window(); + } + } + + /// Release the callback borrow before invoking user code, which may replace the policy. + fn can_close(&mut self, cx: &mut App) -> bool { + let callback = self.should_close.borrow().clone(); + callback.is_none_or(|callback| callback(self, cx)) + } + /// Whether the focused element is taking typed text on the frame that was drawn last. /// /// An application binding a bare key — Caps Lock, a lone modifier, a single letter — has to @@ -5982,9 +6000,10 @@ impl Window { cx: &App, f: impl Fn(&mut Window, &mut App) -> bool + 'static, ) { + *self.should_close.borrow_mut() = Some(Rc::new(f)); let mut cx = self.to_async(cx); self.platform_window.on_should_close(Box::new(move || { - cx.update(|window, cx| f(window, cx)).unwrap_or(true) + cx.update(|window, cx| window.can_close(cx)).unwrap_or(true) })) } diff --git a/crates/moon-gpui/src/window/tests.rs b/crates/moon-gpui/src/window/tests.rs index 8e33433..0767ae3 100644 --- a/crates/moon-gpui/src/window/tests.rs +++ b/crates/moon-gpui/src/window/tests.rs @@ -206,6 +206,56 @@ fn open_window( (app, any) } +/// Native and client-drawn close use one replaceable policy; a veto keeps the real window alive. +#[test] +fn user_close_respects_and_replaces_the_native_policy() { + let (mut app, any) = open_window(|_, _| EmptyView); + let calls = Rc::new(Cell::new(0)); + app.update_window(any, |_, window, cx| { + let calls = calls.clone(); + window.on_window_should_close(cx, move |_, _| { + calls.set(calls.get() + 1); + false + }); + }) + .unwrap(); + let platform = app.test_window(any); + let mut native = platform.0.lock().should_close_handler.take().unwrap(); + assert!(!native()); + app.update_window(any, |_, window, cx| window.request_close(cx)) + .unwrap(); + assert_eq!(calls.get(), 2); + app.update_window(any, |_, window, cx| { + assert!(!window.removed); + window.on_window_should_close(cx, |_, _| true); + window.request_close(cx); + }) + .unwrap(); + assert!( + app.update_window(any, |_, _, _| ()).is_err(), + "accepted close must release the window" + ); + assert_eq!(calls.get(), 2, "the superseded veto must not run"); +} + +/// Teardown is unconditional; a window without a policy still accepts an ordinary user close. +#[test] +fn forced_close_bypasses_policy_and_default_user_close_succeeds() { + let (mut app, any) = open_window(|_, _| EmptyView); + app.update_window(any, |_, window, cx| { + window.on_window_should_close(cx, |_, _| { + panic!("forced close must not ask the user policy") + }); + window.remove_window(); + }) + .unwrap(); + assert!(app.update_window(any, |_, _, _| ()).is_err()); + let (mut app, any) = open_window(|_, _| EmptyView); + app.update_window(any, |_, window, cx| window.request_close(cx)) + .unwrap(); + assert!(app.update_window(any, |_, _, _| ()).is_err()); +} + /// A static scene still owes its last frame after a transient backend refusal, without new input. #[test] fn deferred_idle_present_retries_then_settles() { diff --git a/crates/moon-ui-components/src/moon/window_frame.rs b/crates/moon-ui-components/src/moon/window_frame.rs index 963ba3c..c2ad9f9 100644 --- a/crates/moon-ui-components/src/moon/window_frame.rs +++ b/crates/moon-ui-components/src/moon/window_frame.rs @@ -69,11 +69,12 @@ impl FrameButton { } } - fn invoke(self, window: &mut Window) { + /// Client-drawn controls preserve the native distinction between user and forced close. + fn invoke(self, window: &mut Window, cx: &mut App) { match self { Self::Minimize => window.minimize_window(), Self::Maximize => window.zoom_window(), - Self::Close => window.remove_window(), + Self::Close => window.request_close(cx), } } @@ -325,7 +326,7 @@ impl MoonWindowFrame { .when(cfg!(not(target_os = "windows")), move |this| { this.on_mouse_down(MouseButton::Left, move |_event, window, cx| { cx.stop_propagation(); - button.invoke(window); + button.invoke(window, cx); }) }) .child(button.icon(default_color, hover_color, group)), diff --git a/crates/moon-ui-components/src/moon/window_frame/tests.rs b/crates/moon-ui-components/src/moon/window_frame/tests.rs index 701a6d7..95ad2fd 100644 --- a/crates/moon-ui-components/src/moon/window_frame/tests.rs +++ b/crates/moon-ui-components/src/moon/window_frame/tests.rs @@ -1,5 +1,51 @@ //! Regression tests for reusable window-frame title behavior. +use gpui::{AppContext as _, Context, IntoElement, Render, TestAppContext, Window, div}; +use std::{cell::Cell, rc::Rc}; + +/// A real window whose lifetime is controlled through the reusable frame button. +struct CloseHost; + +impl Render for CloseHost { + /// No layout is needed to test the button's shared invocation path. + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + } +} + +/// Replacing request_close with remove_window would destroy the window despite its veto. +#[gpui::test] +fn frame_close_preserves_veto_and_marks_an_accepted_user_close(cx: &mut TestAppContext) { + let allow = Rc::new(Cell::new(false)); + let requests = Rc::new(Cell::new(0)); + let window = cx.add_window({ + let allow = allow.clone(); + let requests = requests.clone(); + move |window, cx| { + window.on_window_should_close(cx, move |_, _| { + requests.set(requests.get() + 1); + allow.get() + }); + CloseHost + } + }); + window + .update(cx, |_, window, cx| { + super::FrameButton::Close.invoke(window, cx) + }) + .unwrap(); + assert_eq!(requests.get(), 1); + assert!(cx.update_window(window.into(), |_, _, _| ()).is_ok()); + allow.set(true); + window + .update(cx, |_, window, cx| { + super::FrameButton::Close.invoke(window, cx) + }) + .unwrap(); + assert_eq!(requests.get(), 2); + assert!(cx.update_window(window.into(), |_, _, _| ()).is_err()); +} + /// Removing `.truncate()` from `window_frame.rs:MoonWindowFrame::title_cluster` lets a narrow /// window title wrap onto a second line and overrun the fixed-height title bar. #[test] From b3b3349a47c54f679488da5df3b5b4e64a117de7 Mon Sep 17 00:00:00 2001 From: Moonbot-Tech Date: Fri, 6 Nov 2026 20:05:31 +0300 Subject: [PATCH 17/19] test(gpu): expose native frame delivery to downstream canvas tests A downstream retained chart must exercise prepare_text inside the real window frame callback to detect an already-served frame being requested again. Expose the existing test-window callback through VisualTestContext and the test-support feature instead of constructing an artificial public text context. Production behavior is unchanged. Validated by the terminal readout RED/GREEN regression and the full MoonUI runtime/component suites. --- crates/moon-gpui/src/app/test_context.rs | 6 ++++++ crates/moon-gpui/src/platform/test/window.rs | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/moon-gpui/src/app/test_context.rs b/crates/moon-gpui/src/app/test_context.rs index 9e32c5d..cd03ccd 100644 --- a/crates/moon-gpui/src/app/test_context.rs +++ b/crates/moon-gpui/src/app/test_context.rs @@ -909,6 +909,12 @@ impl VisualTestContext { self.background_executor.run_until_parked(); } + /// Run one native frame callback, including retained GPU canvas text preparation. + pub fn simulate_frame(&mut self) { + self.test_window(self.window).simulate_frame(); + self.run_until_parked(); + } + /// Simulates the user closing the window. /// Returns true if the window was closed. pub fn simulate_close(&mut self) -> bool { diff --git a/crates/moon-gpui/src/platform/test/window.rs b/crates/moon-gpui/src/platform/test/window.rs index 50d1dfd..912c616 100644 --- a/crates/moon-gpui/src/platform/test/window.rs +++ b/crates/moon-gpui/src/platform/test/window.rs @@ -114,7 +114,7 @@ impl TestWindow { } /// Deliver the actual frame callback after releasing the platform-state lock. - #[cfg(test)] + #[cfg(any(test, feature = "test-support"))] pub(crate) fn simulate_frame(&self) { let callback = self.0.lock().request_frame_callback.take(); if let Some(mut callback) = callback { From 76579977147a92bea27efcb63221f1517deaa13d Mon Sep 17 00:00:00 2001 From: Moonbot-Tech Date: Fri, 6 Nov 2026 20:38:30 +0300 Subject: [PATCH 18/19] fix(dock): synchronize side-root activity when visibility changes Closing a side root stopped rendering its subtree but left its selected panel active. That hidden subtree could no longer deliver its own deactivation. This is a library API defect; MoonTerminal currently does not call these public side-toggle methods. Pass Window through set_dock_open and toggle_dock so the existing Panel lifecycle can run at the visibility transition. Synchronize only the changed side subtree and preserve its selected-tab runtime. A currently valid zoom keeps sole visibility and receives no activity callbacks when an unrelated side flag changes. No new activity registry, timer or retained state is introduced. API change: both methods now require the host Window, matching the lifecycle operations they perform. Update their two recorded public signatures; no in-repository production caller requires migration. Validation: a real GPUI window/probe regression fails with the old active panel after close. An independently reviewed broader-sync variant also fails with four extra zoom callbacks. The final implementation passes close/reopen, no-op, selected-index and zoom-preservation checks. MoonUI full guardrails pass (647 component tests, 12 gallery tests); terminal workspace Clippy passes. Astra reviewed the final correction. --- crates/moon-ui-components/src/moon/dock.rs | 34 +++++++-- .../moon-ui-components/src/moon/dock/tests.rs | 71 +++++++++++++++++++ docs/component-api-baseline.json | 4 +- 3 files changed, 101 insertions(+), 8 deletions(-) diff --git a/crates/moon-ui-components/src/moon/dock.rs b/crates/moon-ui-components/src/moon/dock.rs index 7082f6d..c94b704 100644 --- a/crates/moon-ui-components/src/moon/dock.rs +++ b/crates/moon-ui-components/src/moon/dock.rs @@ -1484,17 +1484,19 @@ impl DockArea { Some(canonical) } + /// Change a side root's visibility and synchronize panel activity before the hidden tree stops rendering. pub fn set_dock_open( &mut self, placement: DockPlacement, open: bool, + window: &mut Window, cx: &mut Context, ) -> bool { - let slot = match placement { + let (root, slot) = match placement { DockPlacement::Center => return false, - DockPlacement::Left => self.left.as_mut(), - DockPlacement::Right => self.right.as_mut(), - DockPlacement::Bottom => self.bottom.as_mut(), + DockPlacement::Left => (DockRoot::Left, self.left.as_mut()), + DockPlacement::Right => (DockRoot::Right, self.right.as_mut()), + DockPlacement::Bottom => (DockRoot::Bottom, self.bottom.as_mut()), }; let Some((_, _, current_open)) = slot else { return false; @@ -1503,19 +1505,39 @@ impl DockArea { return false; } *current_open = open; + // A zoomed panel remains the sole visible surface regardless of normal side-root flags. + if self + .zoomed_panel + .as_ref() + .and_then(|name| self.find_panel_named(name.as_ref(), cx)) + .is_none() + && let Some(item) = self.root_item(root) + { + if open { + self.sync_item_active(root, &[], item, window, cx); + } else { + self.sync_item_inactive(root, &[], item, window, cx); + } + } cx.emit(DockEvent::LayoutChanged); cx.notify(); true } - pub fn toggle_dock(&mut self, placement: DockPlacement, cx: &mut Context) -> bool { + /// Toggle a side root through the same visibility and activity transition as an explicit change. + pub fn toggle_dock( + &mut self, + placement: DockPlacement, + window: &mut Window, + cx: &mut Context, + ) -> bool { let open = match placement { DockPlacement::Center => return false, DockPlacement::Left => self.left.as_ref().map(|(_, _, open)| !*open), DockPlacement::Right => self.right.as_ref().map(|(_, _, open)| !*open), DockPlacement::Bottom => self.bottom.as_ref().map(|(_, _, open)| !*open), }; - open.map(|open| self.set_dock_open(placement, open, cx)) + open.map(|open| self.set_dock_open(placement, open, window, cx)) .unwrap_or(false) } diff --git a/crates/moon-ui-components/src/moon/dock/tests.rs b/crates/moon-ui-components/src/moon/dock/tests.rs index 5c32ed4..bf964da 100644 --- a/crates/moon-ui-components/src/moon/dock/tests.rs +++ b/crates/moon-ui-components/src/moon/dock/tests.rs @@ -710,6 +710,77 @@ fn layout_replacement_clears_zoom_before_removing_the_panel(cx: &mut gpui::TestA .unwrap(); } +/// Closing a side root must stop its selected panel without waiting for another hidden render. +#[gpui::test] +fn side_root_open_state_drives_activity_and_preserves_zoom(cx: &mut gpui::TestAppContext) { + let window = cx.add_window(|_, _| DockTestHarness); + cx.update_window(window.into(), |_, window, cx| { + let assets = Rc::new(PanelProbe::default()); + let log = Rc::new(PanelProbe::default()); + let chart = Rc::new(PanelProbe::default()); + let items = vec![ + tracking_panel("assets", assets.clone(), cx), + tracking_panel("log", log.clone(), cx), + ]; + let runtime = cx.new(|_| MoonTabPanelRuntimeState::default()); + let center = tracking_panel("chart", chart.clone(), cx); + let dock = cx.new(|_| DockArea::test_with_center(DockItem::Panel(center))); + dock.update(cx, |dock, cx| { + dock.right = Some(( + DockItem::Tabs { + items, + active_ix: 1, + }, + 240., + true, + )); + dock.tab_runtime_states + .insert("test-dock:right".into(), runtime.downgrade()); + dock.sync_layout_active(window, cx); + assert!(!assets.active.get()); + assert!(log.active.get()); + + assert!(dock.set_dock_open(super::DockPlacement::Right, false, window, cx)); + assert!( + !log.active.get(), + "a closed side root cannot keep its panel active" + ); + assert_eq!(runtime.read(cx).notified_active, Some((None, 2))); + let calls = log.active_calls.get(); + assert!(!dock.set_dock_open(super::DockPlacement::Right, false, window, cx)); + assert_eq!( + log.active_calls.get(), + calls, + "unchanged state has no lifecycle edge" + ); + + assert!(dock.toggle_dock(super::DockPlacement::Right, window, cx)); + assert!(log.active.get()); + assert!(!assets.active.get()); + assert_eq!(runtime.read(cx).active_ix, 1); + + dock.toggle_zoom_panel("chart", window, cx); + assert!(!log.active.get()); + let chart_calls = chart.active_calls.get(); + assert!(dock.set_dock_open(super::DockPlacement::Right, false, window, cx)); + assert!(dock.toggle_dock(super::DockPlacement::Right, window, cx)); + assert_eq!( + chart.active_calls.get(), + chart_calls, + "unchanged zoom has no activity edges" + ); + assert!( + !log.active.get(), + "reopening must not activate behind another panel's zoom" + ); + dock.clear_zoom(window, cx); + assert!(log.active.get()); + assert!(!assets.active.get()); + }); + }) + .unwrap(); +} + /// Catches making `dock.rs:DockArea::sync_layout_active` ignore `zoomed_panel`, which would keep /// the hidden normal tab active while the visible zoom surface reports no active panel. #[gpui::test] diff --git a/docs/component-api-baseline.json b/docs/component-api-baseline.json index 4279ad0..61cd6e9 100644 --- a/docs/component-api-baseline.json +++ b/docs/component-api-baseline.json @@ -1499,7 +1499,7 @@ }, { "file": "crates/moon-ui-components/src/moon/dock.rs", - "signature": "pub fn set_dock_open( &mut self, placement: DockPlacement, open: bool, cx: &mut Context, ) -> bool" + "signature": "pub fn set_dock_open( &mut self, placement: DockPlacement, open: bool, window: &mut Window, cx: &mut Context, ) -> bool" }, { "file": "crates/moon-ui-components/src/moon/dock.rs", @@ -1575,7 +1575,7 @@ }, { "file": "crates/moon-ui-components/src/moon/dock.rs", - "signature": "pub fn toggle_dock(&mut self, placement: DockPlacement, cx: &mut Context) -> bool" + "signature": "pub fn toggle_dock( &mut self, placement: DockPlacement, window: &mut Window, cx: &mut Context, ) -> bool" }, { "file": "crates/moon-ui-components/src/moon/dock.rs", From aca1811d52d23459ee7d6a72895685a6278eda78 Mon Sep 17 00:00:00 2001 From: Moonbot-Tech Date: Fri, 6 Nov 2026 22:05:53 +0300 Subject: [PATCH 19/19] feat(theme): add the optional Obsidian facet skin Add a selectable dark Obsidian palette and a palette-driven Facet skin for Moon components. Keep existing Flat themes, their default values, layout geometry, interaction owners and persistence identity unchanged. Adapt the existing button/input/select/progress/dock engines instead of duplicating their lifecycle. Preserve colored dropdown variants and use one donor Progress animation engine with track/fill paint hooks. Keep snapshot theme identity distinct from dark/light mode, so Obsidian cannot overwrite Dark captures. Preserve the custom single-line input-height fix and its existing regression when integrating the author's tests. Validation: 732 component tests, 13 gallery tests, bundled fonts, formatting and component audit/API/donor mirror checks pass. Nineteen of twenty native Dark/Light pages match pre-integration PNGs byte-for-byte; the remaining difference is one hovered segment. Ten Obsidian pages and native Obsidian -> Light -> Dark switching were inspected. Historical repository PNGs predate the current gallery and were not rewritten to hide their mismatch. Independent Sol/Astra reviews completed. --- .../component-manifest.json | 12 +- .../moon-ui-components/src/button/button.rs | 276 ++++++- .../src/button/button/tests.rs | 700 +++++++++++++++++- crates/moon-ui-components/src/checkbox.rs | 50 +- crates/moon-ui-components/src/combobox.rs | 21 +- .../moon-ui-components/src/dialog/dialog.rs | 28 + crates/moon-ui-components/src/input/input.rs | 162 +++- crates/moon-ui-components/src/moon/badge.rs | 32 + .../src/moon/badge/tests.rs | 176 ++++- crates/moon-ui-components/src/moon/button.rs | 64 +- .../src/moon/button/scene_probe.rs | 345 +++++++++ .../src/moon/button/tests.rs | 372 +++++++++- .../src/moon/checkbox/tests.rs | 148 ++++ .../src/moon/collapsible.rs | 45 +- .../src/moon/collapsible/tests.rs | 47 ++ .../src/moon/color_picker.rs | 35 +- .../src/moon/color_picker/tests.rs | 43 +- .../moon-ui-components/src/moon/data_table.rs | 58 +- .../src/moon/data_table/header.rs | 20 +- .../src/moon/data_table/tests.rs | 84 +++ crates/moon-ui-components/src/moon/dock.rs | 103 ++- .../src/moon/dock/tab_panel.rs | 219 +++++- .../moon-ui-components/src/moon/dock/tests.rs | 573 ++++++++++++++ .../src/moon/dropdown/popup.rs | 40 +- .../src/moon/dropdown/popup/tests.rs | 47 ++ .../moon-ui-components/src/moon/group_box.rs | 71 +- .../src/moon/group_box/tests.rs | 62 ++ .../src/moon/input/tests.rs | 244 +++++- crates/moon-ui-components/src/moon/kbd.rs | 24 +- .../moon-ui-components/src/moon/kbd/tests.rs | 89 +++ crates/moon-ui-components/src/moon/mod.rs | 5 + crates/moon-ui-components/src/moon/popover.rs | 62 +- .../src/moon/popover/tests.rs | 65 ++ .../moon-ui-components/src/moon/progress.rs | 77 +- .../src/moon/progress/tests.rs | 118 +++ crates/moon-ui-components/src/moon/radio.rs | 10 + .../src/moon/radio/tests.rs | 32 + crates/moon-ui-components/src/moon/segment.rs | 109 ++- .../src/moon/segment/tests.rs | 66 +- .../src/moon/selectable_card.rs | 116 ++- .../src/moon/selectable_card/tests.rs | 93 +++ .../moon-ui-components/src/moon/selector.rs | 73 +- .../src/moon/selector/tests.rs | 268 +++++++ crates/moon-ui-components/src/moon/skin.rs | 539 ++++++++++++++ .../moon-ui-components/src/moon/skin/tests.rs | 540 ++++++++++++++ crates/moon-ui-components/src/moon/slider.rs | 3 + .../src/moon/slider/tests.rs | 161 ++++ .../moon-ui-components/src/moon/status_bar.rs | 61 +- .../src/moon/status_bar/tests.rs | 44 ++ crates/moon-ui-components/src/moon/tab.rs | 70 +- .../moon-ui-components/src/moon/tab/tests.rs | 45 ++ crates/moon-ui-components/src/moon/table.rs | 46 ++ crates/moon-ui-components/src/moon/tag.rs | 51 ++ .../moon-ui-components/src/moon/tag/tests.rs | 173 ++++- crates/moon-ui-components/src/moon/theme.rs | 25 + .../src/moon/theme/tests.rs | 115 +++ crates/moon-ui-components/src/moon/toggle.rs | 208 +++++- .../src/moon/toggle/tests.rs | 242 +++++- crates/moon-ui-components/src/moon/tokens.rs | 76 ++ .../src/moon/tokens/tests.rs | 44 ++ crates/moon-ui-components/src/moon/tooltip.rs | 109 ++- .../src/moon/tooltip/tests.rs | 106 +++ .../src/progress/progress.rs | 14 + crates/moon-ui-components/src/root.rs | 18 +- crates/moon-ui-components/src/select.rs | 22 +- crates/moon-ui-components/src/sidebar/menu.rs | 83 ++- .../src/sidebar/menu/tests.rs | 53 ++ crates/moon-ui-components/src/sidebar/mod.rs | 25 + crates/moon-ui-components/src/slider.rs | 176 ++++- .../themes/moon-obsidian.toml | 144 ++++ crates/moon-ui-gallery/src/gallery.rs | 67 +- crates/moon-ui-gallery/src/gallery/tests.rs | 124 +++- crates/moon-ui-gallery/src/handoff.rs | 12 +- crates/moon-ui-gallery/src/launch.rs | 10 +- crates/moon-ui-gallery/src/main.rs | 105 ++- docs/PALETTE_SPEC.md | 46 +- docs/component-api-baseline.json | 72 ++ docs/component-mirror-baseline.json | 83 ++- xtask/src/component_audit/contracts.rs | 2 +- 79 files changed, 8665 insertions(+), 333 deletions(-) create mode 100644 crates/moon-ui-components/src/moon/button/scene_probe.rs create mode 100644 crates/moon-ui-components/src/moon/group_box/tests.rs create mode 100644 crates/moon-ui-components/src/moon/progress/tests.rs create mode 100644 crates/moon-ui-components/src/moon/selector/tests.rs create mode 100644 crates/moon-ui-components/src/moon/skin.rs create mode 100644 crates/moon-ui-components/src/moon/skin/tests.rs create mode 100644 crates/moon-ui-components/src/moon/slider/tests.rs create mode 100644 crates/moon-ui-components/src/moon/tab/tests.rs create mode 100644 crates/moon-ui-components/src/moon/tooltip/tests.rs create mode 100644 crates/moon-ui-components/themes/moon-obsidian.toml diff --git a/crates/moon-ui-components/component-manifest.json b/crates/moon-ui-components/component-manifest.json index 2ae759b..9eceefc 100644 --- a/crates/moon-ui-components/component-manifest.json +++ b/crates/moon-ui-components/component-manifest.json @@ -533,8 +533,8 @@ "theme_source": "MoonTheme bridge through MoonSidebar facade", "public_path": "moon_ui::MoonSidebar", "upstream_ref": "Longbridge::sidebar@cda0fc7fd4e4809dd2a8bae0337ac43b49e9f675", - "fork_reason": "Reviewed TrackedFork drift: Moon footer/header/menu styling hooks live in base sidebar; contiguous hit rows (group/menu + menu tests) live there too; Longbridge sidebar behavior remains source-owned", - "donor_drift_budget": 5, + "fork_reason": "Reviewed TrackedFork drift: Moon footer/header/menu styling hooks live in base sidebar; contiguous hit rows (group/menu + menu tests) live there too; the faceted skin's unfilled sidebar surface and fading edge are resolved in sidebar/mod.rs; Longbridge sidebar behavior remains source-owned", + "donor_drift_budget": 6, "contracts": ["gallery.visual_coverage"] }, { @@ -619,8 +619,8 @@ "theme_source": "MoonTheme tokens through MoonProgress wrapper", "public_path": "moon_ui::MoonProgress", "upstream_ref": "Longbridge::progress@cda0fc7fd4e4809dd2a8bae0337ac43b49e9f675", - "fork_reason": "Reviewed TrackedFork drift: Moon progress circle style hook shares the base progress module; Longbridge progress behavior remains source-owned", - "donor_drift_budget": 1, + "fork_reason": "Moon progress circle and linear track/fill style hooks share the donor animation engines without duplicating their lifecycle", + "donor_drift_budget": 2, "contracts": ["gallery.visual_coverage"] }, { @@ -640,8 +640,8 @@ "theme_source": "MoonTheme bridge through MoonProgressCircle wrapper", "public_path": "moon_ui::MoonProgressCircle", "upstream_ref": "Longbridge::progress::ProgressCircle@cda0fc7fd4e4809dd2a8bae0337ac43b49e9f675", - "fork_reason": "Reviewed TrackedFork drift: Moon progress circle style hook lives in base progress module; Longbridge progress circle behavior remains source-owned", - "donor_drift_budget": 1, + "fork_reason": "The shared progress module contains circle and linear track/fill style hooks; both keep the donor animation engines", + "donor_drift_budget": 2, "contracts": ["gallery.visual_coverage"] }, { diff --git a/crates/moon-ui-components/src/button/button.rs b/crates/moon-ui-components/src/button/button.rs index db831e1..1c9d544 100644 --- a/crates/moon-ui-components/src/button/button.rs +++ b/crates/moon-ui-components/src/button/button.rs @@ -5,16 +5,27 @@ use crate::{ StyledExt, button::ButtonIcon, h_flex, - moon::{MoonPalette, MoonTheme, rgba_from}, + moon::{ + MoonFacet, MoonFacetKind, MoonFacetTint, MoonPalette, MoonTheme, MoonThemeTokens, facet, + facet_glow, facet_tinted, rgba_from, + }, tooltip::{ManagedTooltipExt as _, Tooltip}, }; use gpui::{ AnyElement, App, ClickEvent, Corners, Div, Edges, ElementId, Hsla, InteractiveElement, Interactivity, IntoElement, MouseButton, ParentElement, Pixels, RenderOnce, SharedString, Stateful, StatefulInteractiveElement as _, StyleRefinement, Styled, Window, div, - prelude::FluentBuilder as _, px, relative, + linear_color_stop, linear_gradient, prelude::FluentBuilder as _, px, relative, }; +/// Opacity of the second glow a solid faceted button gains under the pointer. +const SOLID_HOVER_GLOW_ALPHA: f32 = 0.26; +/// Blur of that second glow as the design gives it, in design-reference pixels. +const SOLID_HOVER_GLOW_BLUR: f32 = 18.0; +/// Opacity of a loading button's faceted fill and edge relative to its resting ones; the same +/// share the flat colours of a loading button are faded to. +const LOADING_FACET_OPACITY: f32 = 0.8; + #[derive(Default, Clone, Copy)] pub enum ButtonRounded { None, @@ -38,6 +49,9 @@ pub struct ButtonCustomVariant { shadow: bool, hover: Hsla, active: Hsla, + /// The tone, as `0xRRGGBB`, this variant is painted in as a solid facet under the Facet + /// skin. `None` keeps the flat colours above on every skin. + facet_tone: Option, } pub trait ButtonVariants: Sized { @@ -147,9 +161,25 @@ impl ButtonCustomVariant { hover: cx.theme().transparent, active: cx.theme().transparent, shadow: false, + facet_tone: None, } } + /// Paint this variant as a solid facet of `tone` under the Facet skin. + /// + /// The flat colours set through the other builders still paint it under the Flat skin, and + /// the foreground is the same under both. + /// + /// Args: + /// tone: The fill tone as `0xRRGGBB`. + /// + /// Returns: + /// The updated variant. + pub(crate) fn facet_solid(mut self, tone: u32) -> Self { + self.facet_tone = Some(tone); + self + } + /// Set background color, default is transparent. pub fn color(mut self, color: Hsla) -> Self { self.color = color; @@ -595,6 +625,10 @@ impl RenderOnce for Button { /// /// The window supplies focus state, the app supplies theme tokens, and the returned element /// owns all mouse, focus, tooltip, and click wiring for this one-shot button. + /// + /// Every state is painted flat first, exactly as a flat theme draws it. Under the Facet skin + /// the faceted surface of the same state, where the variant has one, is then painted over + /// its background, border colour and shadows; text colours are the flat ones on both skins. fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let style: ButtonVariant = self.variant; let clickable = self.clickable(); @@ -606,6 +640,11 @@ impl RenderOnce for Button { .unwrap_or_else(|| MoonButtonMetrics::for_size(self.size, cx)); let normal_style = style.normal(self.outline, cx); let icon_size = Size::Size(metrics.icon_size); + // Resolved once: the facet of each state is read off these tokens, and is `None` for + // every state under the Flat skin. + let tokens = MoonTheme::active_tokens(cx); + let outline = self.outline; + let facet_in = |state: ButtonVisualState| style.resolve_facet(&tokens, outline, state); let focus_handle = window .use_keyed_state(self.id.clone(), cx, |_, cx| cx.focus_handle()) @@ -672,37 +711,51 @@ impl RenderOnce for Button { this.bg(selected_style.bg) .border_color(selected_style.border) .text_color(selected_style.fg) + .map(|this| faceted(this, facet_in(ButtonVisualState::Selected))) .when(pointer_feedback, |this| { this.hover(|this| { let hover_style = style.selected_hovered(self.outline, cx); - this.bg(hover_style.bg) - .border_color(hover_style.border) - .text_color(hover_style.fg) + faceted( + this.bg(hover_style.bg) + .border_color(hover_style.border) + .text_color(hover_style.fg), + facet_in(ButtonVisualState::SelectedHovered), + ) }) .active(|this| { let active_style = style.selected_active(self.outline, cx); - this.bg(active_style.bg) - .border_color(active_style.border) - .text_color(active_style.fg) + faceted( + this.bg(active_style.bg) + .border_color(active_style.border) + .text_color(active_style.fg), + facet_in(ButtonVisualState::SelectedActive), + ) }) }) }) .when(!self.disabled && !self.selected, |this| { this.border_color(normal_style.border) .bg(normal_style.bg) + .map(|this| faceted(this, facet_in(ButtonVisualState::Normal))) .when(normal_style.underline, |this| this.text_decoration_1()) .when(pointer_feedback, |this| { this.hover(|this| { let hover_style = style.hovered(self.outline, cx); - this.bg(hover_style.bg) - .border_color(hover_style.border) - .text_color(hover_style.fg) + faceted( + this.bg(hover_style.bg) + .border_color(hover_style.border) + .text_color(hover_style.fg), + facet_in(ButtonVisualState::Hovered), + ) }) .active(|this| { let active_style = style.active(self.outline, cx); - this.bg(active_style.bg) - .border_color(active_style.border) - .text_color(active_style.fg) + faceted( + this.bg(active_style.bg) + .border_color(active_style.border) + .text_color(active_style.fg), + facet_in(ButtonVisualState::Active), + ) }) }) }) @@ -780,6 +833,17 @@ impl RenderOnce for Button { this.bg(normal_style.bg.opacity(0.8)) .border_color(normal_style.border.opacity(0.8)) .text_color(normal_style.fg.opacity(0.8)) + .map(|this| { + let resting = facet_in(ButtonVisualState::Normal); + faceted( + this, + resting.map(|surface| MoonFacet { + fill: surface.fill.opacity(LOADING_FACET_OPACITY), + edge: surface.edge.opacity(LOADING_FACET_OPACITY), + shadows: surface.shadows, + }), + ) + }) }) .map(|this| { if let Some(builder) = self.tooltip_builder { @@ -827,8 +891,11 @@ pub(crate) struct MoonButtonStyle { } /// Internal visual states used to resolve one button variant. -#[derive(Clone, Copy)] -enum ButtonVisualState { +/// +/// Crate-visible so a Moon wrapper's tests can ask [`ButtonVariant::resolve_facet`] for the +/// surface of one state. +#[derive(Clone, Copy, Debug)] +pub(crate) enum ButtonVisualState { Normal, Hovered, Active, @@ -866,6 +933,107 @@ fn pointer_feedback_enabled(disabled: bool, loading: bool) -> bool { !disabled && !loading } +/// Paint a faceted surface over the flat style already set on `this`. +/// +/// Args: +/// this: The element or style refinement carrying one state's flat style. +/// surface: The faceted surface of that state; `None` under the Flat skin and for a variant +/// with no facet. +/// +/// Returns: +/// `this` with the surface's fill, edge colour and shadow stack replacing the flat +/// background, border colour and shadows, or `this` untouched when there is no surface. +fn faceted(this: S, surface: Option) -> S { + match surface { + Some(surface) => surface.paint(this), + None => this, + } +} + +/// Strip a faceted surface of every shadow cast outside it, its drop shadow and its glow, so +/// that it reads as pressed flush with the panel. The highlight inside the surface is kept. +fn flush(mut surface: MoonFacet) -> MoonFacet { + surface.shadows.retain(|shadow| shadow.inset); + surface +} + +/// Resolve the surface of a pressed raised control under the Facet skin. +/// +/// It is the [`MoonFacetKind::Raised`] surface lit from below: the two ends of that fill +/// swapped, so `panel` is on top and `panel_high` at the bottom, with the same edge and no drop +/// shadow. Crate-visible so every raised control presses the same way. +/// +/// Args: +/// tokens: The theme side to resolve it for; supplies the palette and the UI scale. +/// +/// Returns: +/// The pressed surface. +pub(crate) fn raised_pressed_facet(tokens: &MoonThemeTokens) -> MoonFacet { + let p = tokens.palette; + let raised = facet(MoonFacetKind::Raised, tokens); + flush(MoonFacet { + fill: linear_gradient( + 180.0, + linear_color_stop(rgba_from(p.panel, 1.0), 0.0), + linear_color_stop(rgba_from(p.panel_high, 1.0), 1.0), + ), + ..raised + }) +} + +/// Resolve the surface of a neutral raised button in `state`: raised at rest, relit under the +/// pointer and lit from below while pressed. Selection does not change it. +fn raised_button_facet(state: ButtonVisualState, tokens: &MoonThemeTokens) -> MoonFacet { + if state.is_active() { + raised_pressed_facet(tokens) + } else if state.is_hovered() { + facet(MoonFacetKind::RaisedHover, tokens) + } else { + facet(MoonFacetKind::Raised, tokens) + } +} + +/// Resolve the surface of a button washed with `tone` (`0xRRGGBB`) in `state`. +/// +/// At rest it is the soft wash. Under the pointer and when selected it is the selected wash, +/// with its glow. A selected button under the pointer also takes the tone's full edge, and a +/// pressed one, selected or not, is the selected wash pressed flush. +fn tinted_button_facet(tone: u32, state: ButtonVisualState, tokens: &MoonThemeTokens) -> MoonFacet { + let lit = || facet_tinted(tone, MoonFacetTint::Selected, tokens); + if state.is_active() { + flush(lit()) + } else if state.is_hovered() && state.is_selected() { + MoonFacet { + edge: rgba_from(tone, 1.0), + ..lit() + } + } else if state.is_hovered() || state.is_selected() { + lit() + } else { + facet_tinted(tone, MoonFacetTint::Soft, tokens) + } +} + +/// Resolve the surface of a button filled with `tone` (`0xRRGGBB`) in `state`: the solid facet +/// at rest and when selected, with a second glow under the pointer, and pressed flush while +/// pressed. +fn solid_button_facet(tone: u32, state: ButtonVisualState, tokens: &MoonThemeTokens) -> MoonFacet { + let mut solid = facet_tinted(tone, MoonFacetTint::Solid, tokens); + if state.is_active() { + flush(solid) + } else if state.is_hovered() { + solid.shadows.push(facet_glow( + tone, + SOLID_HOVER_GLOW_ALPHA, + SOLID_HOVER_GLOW_BLUR, + tokens, + )); + solid + } else { + solid + } +} + impl ButtonVariant { /// Return whether the variant uses the shared neutral interaction surface. fn uses_neutral_interaction_surface(&self) -> bool { @@ -1188,6 +1356,82 @@ impl ButtonVariant { } } + /// Resolve the faceted surface one state is painted with under the Facet skin. + /// + /// The neutral variants (`Default`, `Panel`, `Secondary`, `Soft`) are raised controls. The + /// tone variants (`Primary`/`Blue`/`Info`, `Warning`/`Amber`, `Success`/`Green`, + /// `Danger`/`Red`) are a wash of their flat fill's tone, as is a selected `Default`, which + /// takes the accent. Where the flat fill of a tone variant is opaque, its ink was chosen + /// for a solid fill, so the surface is the solid facet of that tone. A custom variant is a + /// solid facet only when it names a tone through [`ButtonCustomVariant::facet_solid`]; like + /// its flat colours, it draws every selected state as the pressed one. + /// + /// Args: + /// tokens: The theme side to resolve for; supplies the skin, the palette and the UI + /// scale. + /// outline: Whether the button is drawn in its outline style. + /// state: The visual state to resolve. + /// + /// Returns: + /// The surface to paint over the flat style of `state`. `None` under the Flat skin, for + /// a disabled button, for an outlined tone variant, and for the variants that have no + /// surface of their own (`Ghost`, `Bare`, `Link`, `Text`, `OutlineAmber`, `OutlineRed` + /// and a custom variant with no facet tone): all of those keep the flat style. + pub(crate) fn resolve_facet( + &self, + tokens: &MoonThemeTokens, + outline: bool, + state: ButtonVisualState, + ) -> Option { + if !tokens.skin.is_facet() || matches!(state, ButtonVisualState::Disabled) { + return None; + } + let p = tokens.palette; + let selected = state.is_selected(); + + match self { + Self::Default if selected => Some(tinted_button_facet(p.accent, state, tokens)), + Self::Default | Self::Panel | Self::Secondary | Self::Soft => { + Some(raised_button_facet(state, tokens)) + } + Self::Primary + | Self::Blue + | Self::Info + | Self::Warning + | Self::Amber + | Self::Success + | Self::Green + | Self::Danger + | Self::Red => { + if outline { + return None; + } + let flat = self.moon_style(p, outline, selected)?; + Some(if flat.bg_alpha >= 1.0 { + solid_button_facet(flat.bg, state, tokens) + } else { + tinted_button_facet(flat.bg, state, tokens) + }) + } + Self::Custom(colors) => { + let state = if selected { + ButtonVisualState::Active + } else { + state + }; + colors + .facet_tone + .map(|tone| solid_button_facet(tone, state, tokens)) + } + Self::Ghost + | Self::Bare + | Self::Link + | Self::Text + | Self::OutlineAmber + | Self::OutlineRed => None, + } + } + /// Resolve custom colors or delegate a built-in variant to the active Moon palette. fn resolve(&self, outline: bool, state: ButtonVisualState, cx: &mut App) -> ButtonVariantStyle { if let Self::Custom(colors) = self { diff --git a/crates/moon-ui-components/src/button/button/tests.rs b/crates/moon-ui-components/src/button/button/tests.rs index c5836c9..c68ce1e 100644 --- a/crates/moon-ui-components/src/button/button/tests.rs +++ b/crates/moon-ui-components/src/button/button/tests.rs @@ -1,14 +1,17 @@ //! Unit coverage for base button builders, interaction states, and Moon sizing. use super::{ - Button, ButtonRounded, ButtonVariant, ButtonVariantStyle, ButtonVariants, ButtonVisualState, - MoonButtonMetrics, pointer_feedback_enabled, + Button, ButtonCustomVariant, ButtonRounded, ButtonVariant, ButtonVariantStyle, ButtonVariants, + ButtonVisualState, MoonButtonMetrics, pointer_feedback_enabled, raised_pressed_facet, }; use crate::{ Disableable as _, Selectable as _, Sizable as _, Size, - moon::{MoonPalette, rgba_from}, + moon::{ + MoonFacet, MoonFacetKind, MoonFacetTint, MoonPalette, MoonSkin, MoonTheme, MoonThemeConfig, + MoonThemeTokens, ThemeMode, facet, facet_tinted, rgba_from, + }, }; -use gpui::px; +use gpui::{Background, BoxShadow, Hsla, linear_color_stop, linear_gradient, point, px}; /// Catches removing an assignment from a `Button` builder, which would make the corresponding /// public modifier silently render with its default value. @@ -256,3 +259,692 @@ fn test_moon_button_variant_tokens_match_terminal_palette() { assert_eq!(soft.bg_alpha, 0.02); assert_eq!(soft.hover_bg_alpha, 0.055); } + +// --- Facet skin ----------------------------------------------------------------------------- + +/// Every visual state a button resolves. +const ALL_STATES: [ButtonVisualState; 7] = [ + ButtonVisualState::Normal, + ButtonVisualState::Hovered, + ButtonVisualState::Active, + ButtonVisualState::Selected, + ButtonVisualState::SelectedHovered, + ButtonVisualState::SelectedActive, + ButtonVisualState::Disabled, +]; + +/// The neutral variants, which the Facet skin draws as raised controls. +const RAISED_VARIANTS: [ButtonVariant; 4] = [ + ButtonVariant::Default, + ButtonVariant::Panel, + ButtonVariant::Secondary, + ButtonVariant::Soft, +]; + +/// The built-in variants the Facet skin leaves flat. +const FLAT_ONLY_VARIANTS: [ButtonVariant; 6] = [ + ButtonVariant::Ghost, + ButtonVariant::Bare, + ButtonVariant::Link, + ButtonVariant::Text, + ButtonVariant::OutlineAmber, + ButtonVariant::OutlineRed, +]; + +/// The tone variants, each with the palette entry that fills it on a dark palette. +fn dark_tone_variants(p: MoonPalette) -> [(ButtonVariant, u32); 9] { + [ + (ButtonVariant::Primary, p.blue), + (ButtonVariant::Blue, p.blue), + (ButtonVariant::Info, p.blue), + (ButtonVariant::Warning, p.amber), + (ButtonVariant::Amber, p.amber), + (ButtonVariant::Success, p.green), + (ButtonVariant::Green, p.green), + (ButtonVariant::Danger, p.red), + (ButtonVariant::Red, p.red), + ] +} + +/// Theme tokens painting `palette` with `skin` at the reference scale. +fn tokens_with(palette: MoonPalette, skin: MoonSkin) -> MoonThemeTokens { + MoonThemeTokens { + skin, + palette, + ..MoonThemeTokens::default() + } +} + +/// An opaque gradient from `top` at the top edge to `bottom` at the bottom edge, written out +/// here rather than taken from the code under test. +fn top_to_bottom(top: u32, bottom: u32) -> Background { + linear_gradient( + 180.0, + linear_color_stop(rgba_from(top, 1.0), 0.0), + linear_color_stop(rgba_from(bottom, 1.0), 1.0), + ) +} + +/// A shadow with no horizontal offset and no spread, written out here rather than taken from +/// the code under test. `design_blur` is the blur as the design gives it, a CSS `box-shadow` +/// blur. +fn shadow(y: f32, design_blur: f32, color: Hsla, inset: bool) -> BoxShadow { + BoxShadow { + color, + offset: point(px(0.0), px(y)), + // The renderer's blur radius is the gaussian's standard deviation: half a CSS blur. + blur_radius: px(design_blur / 2.0), + spread_radius: px(0.0), + inset, + } +} + +/// Keep only the shadows drawn inside a surface. +fn inset_only(surface: &MoonFacet) -> Vec { + surface + .shadows + .iter() + .filter(|shadow| shadow.inset) + .cloned() + .collect() +} + +/// One variant family's flat paint on the dark terminal palette, as it was before the Facet +/// skin existed. Every pair is `(0xRRGGBB, alpha)`. +struct FlatFamily { + variants: &'static [ButtonVariant], + /// The fill at rest, under the pointer, pressed and selected. + fills: [(u32, f32); 4], + /// The border in the same four states. + borders: [(u32, f32); 4], + /// The ink at rest, under the pointer and pressed. + ink: (u32, f32), + /// The ink when selected. + selected_ink: (u32, f32), +} + +/// The flat paint of every variant the Facet skin restyles, on the dark terminal palette `p`. +/// +/// The alphas, and the two colours that are not palette entries, are copied from the flat +/// design rather than read back from `ButtonVariant::moon_style`. +fn flat_dark_families(p: MoonPalette) -> Vec { + const DEFAULT_FILL: u32 = 0x1F2126; + const WHITE: u32 = 0xFFFFFF; + let neutral_hover = (p.overlay, 0.10); + let neutral_pressed = (p.overlay, 0.06); + let hover_border = (p.border_hover, 1.0); + let tone = |color: u32, fills: [f32; 4], borders: [f32; 4]| { + ( + fills.map(|alpha| (color, alpha)), + borders.map(|alpha| (color, alpha)), + ) + }; + let (blue_fills, blue_borders) = + tone(p.blue, [0.10, 0.18, 0.12, 0.18], [0.22, 0.42, 0.30, 0.38]); + let (amber_fills, amber_borders) = + tone(p.amber, [0.10, 0.18, 0.12, 0.18], [0.22, 0.42, 0.30, 0.38]); + let (green_fills, green_borders) = + tone(p.green, [0.14, 0.22, 0.14, 0.22], [0.30, 0.44, 0.34, 0.30]); + let (red_fills, red_borders) = tone(p.red, [0.10, 0.22, 0.14, 0.10], [0.38, 0.48, 0.36, 0.38]); + let (danger_fills, danger_borders) = + tone(p.red, [0.14, 0.22, 0.14, 0.14], [0.38, 0.48, 0.36, 0.38]); + + vec![ + FlatFamily { + variants: &[ButtonVariant::Default], + fills: [ + (DEFAULT_FILL, 1.0), + neutral_hover, + neutral_pressed, + (p.accent, p.accent_tint_a), + ], + borders: [(p.border, 1.0), hover_border, hover_border, (p.accent, 1.0)], + ink: (p.text, 0.86), + selected_ink: (p.accent_fg, 1.0), + }, + FlatFamily { + variants: &[ButtonVariant::Panel], + fills: [ + (p.shell_high, 1.0), + neutral_hover, + neutral_pressed, + (p.shell_high, 1.0), + ], + borders: [(p.border, 1.0), hover_border, hover_border, (p.border, 1.0)], + ink: (p.text, 1.0), + selected_ink: (p.text, 1.0), + }, + FlatFamily { + variants: &[ButtonVariant::Secondary, ButtonVariant::Soft], + fills: [(WHITE, 0.02), neutral_hover, neutral_pressed, (WHITE, 0.02)], + borders: [(WHITE, 0.05), hover_border, hover_border, (WHITE, 0.05)], + ink: (p.text_soft, 1.0), + selected_ink: (p.text_soft, 1.0), + }, + FlatFamily { + variants: &[ + ButtonVariant::Primary, + ButtonVariant::Blue, + ButtonVariant::Info, + ], + fills: blue_fills, + borders: blue_borders, + ink: (p.blue, 1.0), + selected_ink: (p.blue, 1.0), + }, + FlatFamily { + variants: &[ButtonVariant::Warning, ButtonVariant::Amber], + fills: amber_fills, + borders: amber_borders, + ink: (p.amber, 1.0), + selected_ink: (p.amber, 1.0), + }, + FlatFamily { + variants: &[ButtonVariant::Success, ButtonVariant::Green], + fills: green_fills, + borders: green_borders, + ink: (p.green, 1.0), + selected_ink: (p.green, 1.0), + }, + FlatFamily { + variants: &[ButtonVariant::Red], + fills: red_fills, + borders: red_borders, + ink: (p.red, 1.0), + selected_ink: (p.red, 1.0), + }, + FlatFamily { + variants: &[ButtonVariant::Danger], + fills: danger_fills, + borders: danger_borders, + ink: (p.red, 1.0), + selected_ink: (p.red, 1.0), + }, + ] +} + +/// One variant family's flat resting paint on the light palette, as it was before the Facet +/// skin existed. Every pair is `(0xRRGGBB, alpha)`. +struct FlatLightFamily { + variants: &'static [ButtonVariant], + fill: (u32, f32), + border: (u32, f32), + ink: (u32, f32), +} + +/// The flat resting paint of every variant the Facet skin restyles, on the light palette `p`, +/// with the alphas copied from the flat design. +fn flat_light_families(p: MoonPalette) -> Vec { + vec![ + FlatLightFamily { + variants: &[ButtonVariant::Default], + fill: (p.surface, 1.0), + border: (p.border_soft, 1.0), + ink: (p.text, 0.86), + }, + FlatLightFamily { + variants: &[ButtonVariant::Panel], + fill: (p.surface, 1.0), + border: (p.border_soft, 1.0), + ink: (p.text, 1.0), + }, + FlatLightFamily { + variants: &[ButtonVariant::Secondary, ButtonVariant::Soft], + fill: (p.surface, 1.0), + border: (p.border_soft, 1.0), + ink: (p.text_soft, 1.0), + }, + FlatLightFamily { + variants: &[ + ButtonVariant::Primary, + ButtonVariant::Blue, + ButtonVariant::Info, + ], + fill: (p.accent, 0.10), + border: (p.accent, 0.22), + ink: (p.accent, 1.0), + }, + FlatLightFamily { + variants: &[ButtonVariant::Warning, ButtonVariant::Amber], + fill: (p.amber, 0.10), + border: (p.amber, 0.22), + ink: (p.amber, 1.0), + }, + FlatLightFamily { + variants: &[ButtonVariant::Success, ButtonVariant::Green], + fill: (p.green_btn, 0.14), + border: (p.green_btn, 0.30), + ink: (p.green_text, 1.0), + }, + FlatLightFamily { + variants: &[ButtonVariant::Red], + fill: (p.red, 0.10), + border: (p.red, 0.38), + ink: (p.red_text, 1.0), + }, + FlatLightFamily { + variants: &[ButtonVariant::Danger], + fill: (p.red, 0.14), + border: (p.red, 0.38), + ink: (p.red_text, 1.0), + }, + ] +} + +/// Catches the Facet skin leaking into a flat theme: `button.rs:resolve_facet` answering for +/// the Flat skin, or a restyled variant's flat colours drifting while the facet was added. +/// Either one repaints every button of every existing theme. +#[gpui::test] +fn flat_theme_buttons_keep_their_colours_and_resolve_no_facet(cx: &mut gpui::TestAppContext) { + cx.update(crate::init); + cx.update(|cx| MoonTheme::install_config(MoonThemeConfig::default(), cx)); + let paint = |(color, alpha): (u32, f32)| rgba_from(color, alpha); + + cx.update(|cx| { + assert_eq!(MoonSkin::active(cx), MoonSkin::Flat); + let tokens = MoonTheme::active_tokens(cx); + let p = tokens.palette; + assert!(!p.is_light(), "the default theme starts on its dark side"); + + for family in flat_dark_families(p) { + for variant in family.variants { + let resolved = [ + ("resting", variant.normal(false, cx)), + ("hovered", variant.hovered(false, cx)), + ("pressed", variant.active(false, cx)), + ("selected", variant.selected(false, cx)), + ]; + for (ix, (state, style)) in resolved.iter().enumerate() { + let ink = if ix == 3 { + family.selected_ink + } else { + family.ink + }; + assert_eq!( + (style.bg, style.border, style.fg), + ( + paint(family.fills[ix]), + paint(family.borders[ix]), + paint(ink) + ), + "flat {state} {variant:?} drifted on the dark palette" + ); + } + for state in ALL_STATES { + assert_eq!( + variant.resolve_facet(&tokens, false, state), + None, + "{variant:?} {state:?} paints a facet under the Flat skin" + ); + } + } + } + }); + + cx.update(|cx| MoonTheme::set_mode(ThemeMode::Light, cx)); + cx.update(|cx| { + assert_eq!(MoonSkin::active(cx), MoonSkin::Flat); + let tokens = MoonTheme::active_tokens(cx); + let p = tokens.palette; + assert!(p.is_light()); + + for family in flat_light_families(p) { + for variant in family.variants { + let style = variant.normal(false, cx); + assert_eq!( + (style.bg, style.border, style.fg), + (paint(family.fill), paint(family.border), paint(family.ink)), + "flat resting {variant:?} drifted on the light palette" + ); + for state in ALL_STATES { + assert_eq!( + variant.resolve_facet(&tokens, false, state), + None, + "{variant:?} {state:?} paints a facet under the Flat skin" + ); + } + } + } + }); +} + +/// Catches `button.rs:resolve_facet` losing the raised surface of a neutral variant on the +/// faceted theme, swapping its hover and resting surfaces, or pressing it without turning the +/// light: the Obsidian theme would keep flat slabs for buttons, or they would not move under +/// the pointer. +#[gpui::test] +fn faceted_theme_raises_the_neutral_variants(cx: &mut gpui::TestAppContext) { + cx.update(crate::init); + cx.update(|cx| MoonTheme::install_config(MoonThemeConfig::moon_obsidian(), cx)); + + cx.update(|cx| { + let tokens = MoonTheme::active_tokens(cx); + let p = tokens.palette; + assert_eq!(tokens.skin, MoonSkin::Facet); + + for variant in RAISED_VARIANTS { + let rest = variant + .resolve_facet(&tokens, false, ButtonVisualState::Normal) + .unwrap_or_else(|| panic!("{variant:?} has no resting facet")); + assert_eq!( + rest.fill.as_solid(), + None, + "{variant:?} must rest on a gradient, not a solid fill" + ); + assert_eq!(rest.fill, top_to_bottom(p.panel_high, p.panel)); + assert!( + !rest.shadows.is_empty(), + "{variant:?} must rest with a shadow stack" + ); + assert_eq!(rest, facet(MoonFacetKind::Raised, &tokens)); + + assert_eq!( + variant.resolve_facet(&tokens, false, ButtonVisualState::Hovered), + Some(facet(MoonFacetKind::RaisedHover, &tokens)), + "{variant:?} under the pointer" + ); + + let pressed = variant + .resolve_facet(&tokens, false, ButtonVisualState::Active) + .unwrap_or_else(|| panic!("{variant:?} has no pressed facet")); + assert_eq!( + pressed.fill, + top_to_bottom(p.panel, p.panel_high), + "pressed {variant:?} is lit from below: the resting ends swapped" + ); + assert_eq!(pressed.edge, rest.edge); + assert_eq!( + pressed.shadows, + vec![shadow(1.0, 0.0, rgba_from(p.overlay, 0.16), true)], + "pressed {variant:?} keeps its highlight and casts no drop shadow" + ); + assert_eq!(pressed, raised_pressed_facet(&tokens)); + } + + // Selection does not restyle a panel or soft button under the Flat skin either. + for variant in [ + ButtonVariant::Panel, + ButtonVariant::Secondary, + ButtonVariant::Soft, + ] { + assert_eq!( + variant.resolve_facet(&tokens, false, ButtonVisualState::Selected), + Some(facet(MoonFacetKind::Raised, &tokens)), + "selected {variant:?}" + ); + } + }); + + cx.update(|cx| MoonTheme::set_mode(ThemeMode::Light, cx)); + cx.update(|cx| { + let tokens = MoonTheme::active_tokens(cx); + for variant in RAISED_VARIANTS { + for state in ALL_STATES { + assert_eq!( + variant.resolve_facet(&tokens, false, state), + None, + "the light side of Obsidian is flat: {variant:?} {state:?}" + ); + } + } + }); +} + +/// Catches `button.rs:resolve_facet` washing a tone variant with the wrong tone or the wrong +/// strength: a danger button tinted like a neutral one, or a hovered or selected button that +/// does not light up. +#[test] +fn faceted_tone_variants_wash_soft_at_rest_and_light_up_when_hovered_or_selected() { + let p = MoonPalette::OBSIDIAN; + let tokens = tokens_with(p, MoonSkin::Facet); + + for (variant, tone) in dark_tone_variants(p) { + let at = |state: ButtonVisualState| { + variant + .resolve_facet(&tokens, false, state) + .unwrap_or_else(|| panic!("{variant:?} {state:?} has no facet")) + }; + let soft = facet_tinted(tone, MoonFacetTint::Soft, &tokens); + let lit = facet_tinted(tone, MoonFacetTint::Selected, &tokens); + + assert_eq!(at(ButtonVisualState::Normal), soft, "resting {variant:?}"); + assert_eq!(at(ButtonVisualState::Hovered), lit, "hovered {variant:?}"); + assert_eq!(at(ButtonVisualState::Selected), lit, "selected {variant:?}"); + + let selected_hover = at(ButtonVisualState::SelectedHovered); + assert_eq!(selected_hover.edge, rgba_from(tone, 1.0)); + assert_eq!( + (selected_hover.fill, &selected_hover.shadows), + (lit.fill, &lit.shadows), + "a selected {variant:?} under the pointer changes its edge only" + ); + + for state in [ButtonVisualState::Active, ButtonVisualState::SelectedActive] { + let pressed = at(state); + assert_eq!((pressed.fill, pressed.edge), (lit.fill, lit.edge)); + assert_eq!( + pressed.shadows, + inset_only(&lit), + "{state:?} {variant:?} drops its drop shadow and glow" + ); + assert!(pressed.shadows.len() < lit.shadows.len()); + } + + assert_eq!( + variant.resolve_facet(&tokens, false, ButtonVisualState::Disabled), + None, + "a disabled {variant:?} keeps the flat disabled look" + ); + for state in ALL_STATES { + assert_eq!( + variant.resolve_facet(&tokens, true, state), + None, + "an outlined {variant:?} keeps its flat outline in {state:?}" + ); + } + } +} + +/// Catches a selected neutral button falling back to the raised surface in +/// `button.rs:resolve_facet`, which would hide which toolbar toggle is on, and catches its +/// ink changing with the skin. +#[test] +fn a_selected_neutral_button_is_an_accent_facet_with_its_flat_ink() { + let p = MoonPalette::OBSIDIAN; + let tokens = tokens_with(p, MoonSkin::Facet); + let variant = ButtonVariant::Default; + + assert_eq!( + variant.resolve_facet(&tokens, false, ButtonVisualState::Selected), + Some(facet_tinted(p.accent, MoonFacetTint::Selected, &tokens)) + ); + assert_eq!( + variant + .resolve_moon(p, false, ButtonVisualState::Selected) + .fg, + rgba_from(p.accent_fg, 1.0), + "the selected ink is the flat one" + ); +} + +/// Catches a variant with no surface of its own picking one up in `button.rs:resolve_facet`: +/// a ghost toolbar button or a text link drawn as a raised slab on the faceted theme. +#[test] +fn ghost_bare_link_text_and_outline_variants_stay_flat_under_the_facet_skin() { + for palette in [MoonPalette::OBSIDIAN, MoonPalette::LIGHT] { + let tokens = tokens_with(palette, MoonSkin::Facet); + for variant in FLAT_ONLY_VARIANTS { + for outline in [false, true] { + for state in ALL_STATES { + assert_eq!( + variant.resolve_facet(&tokens, outline, state), + None, + "{variant:?} {state:?} (outline={outline}, light={})", + palette.is_light() + ); + } + } + } + } +} + +/// Catches a faceted state resolving to the surface of the state before it in +/// `button.rs:resolve_facet`, which would make a faceted button look inert under the pointer +/// or under a press, on a dark palette or a light one. +#[test] +fn faceted_buttons_keep_pointer_feedback_on_dark_and_light_palettes() { + for palette in [MoonPalette::OBSIDIAN, MoonPalette::LIGHT] { + let tokens = tokens_with(palette, MoonSkin::Facet); + let tones = dark_tone_variants(palette).map(|(variant, _)| variant); + for variant in RAISED_VARIANTS.into_iter().chain(tones) { + let at = |state: ButtonVisualState| { + variant + .resolve_facet(&tokens, false, state) + .unwrap_or_else(|| panic!("{variant:?} {state:?} has no facet")) + }; + let light = palette.is_light(); + + assert_ne!( + at(ButtonVisualState::Normal), + at(ButtonVisualState::Hovered), + "{variant:?} has no hover feedback (light={light})" + ); + assert_ne!( + at(ButtonVisualState::Hovered), + at(ButtonVisualState::Active), + "{variant:?} has no pressed feedback (light={light})" + ); + assert_ne!( + at(ButtonVisualState::Normal), + at(ButtonVisualState::Active), + "{variant:?} has no distinct pressed state (light={light})" + ); + assert_ne!( + at(ButtonVisualState::Selected), + at(ButtonVisualState::SelectedHovered), + "selected {variant:?} has no hover feedback (light={light})" + ); + assert_ne!( + at(ButtonVisualState::Selected), + at(ButtonVisualState::SelectedActive), + "selected {variant:?} has no distinct pressed state (light={light})" + ); + assert_ne!( + at(ButtonVisualState::SelectedHovered), + at(ButtonVisualState::SelectedActive), + "selected {variant:?} has no pressed feedback (light={light})" + ); + } + } +} + +/// Catches `button.rs:resolve_facet` washing a tone variant whose flat fill is opaque: the +/// light palette's selected green button carries white ink for a solid green fill, and on a +/// pale green wash that label would be unreadable. +#[test] +fn an_opaque_tone_fill_becomes_a_solid_facet() { + let p = MoonPalette::LIGHT; + let tokens = tokens_with(p, MoonSkin::Facet); + let variant = ButtonVariant::Green; + + assert_eq!( + variant.resolve_facet(&tokens, false, ButtonVisualState::Normal), + Some(facet_tinted(p.green_btn, MoonFacetTint::Soft, &tokens)), + "the translucent resting fill stays a wash" + ); + assert_eq!( + variant.resolve_facet(&tokens, false, ButtonVisualState::Selected), + Some(facet_tinted(p.green_btn, MoonFacetTint::Solid, &tokens)) + ); + assert_eq!( + variant + .resolve_moon(p, false, ButtonVisualState::Selected) + .fg, + rgba_from(p.on_accent, 1.0), + "the ink this fill has to carry" + ); +} + +/// Catches a custom variant's facet tone being ignored, or honoured under the Flat skin, in +/// `button.rs:resolve_facet`: the solid primary button would stay a flat accent slab on the +/// faceted theme, or would change on every flat one. Also catches a custom variant with no +/// tone picking a facet up. +#[gpui::test] +fn a_custom_variant_is_a_solid_facet_only_with_a_tone_and_only_under_the_facet_skin( + cx: &mut gpui::TestAppContext, +) { + cx.update(crate::init); + cx.update(|cx| MoonTheme::install_config(MoonThemeConfig::default(), cx)); + + cx.update(|cx| { + let p = MoonPalette::OBSIDIAN; + let tone = p.accent; + let fill = rgba_from(tone, 1.0); + let hover = rgba_from(tone, 0.9); + let active = rgba_from(tone, 0.8); + let label = rgba_from(p.shell, 1.0); + let plain = ButtonCustomVariant::new(cx) + .color(fill) + .hover(hover) + .active(active) + .foreground(label); + let toned = ButtonVariant::Custom(plain.facet_solid(tone)); + let plain = ButtonVariant::Custom(plain); + + // Under the Flat skin the tone changes nothing: the flat colours are the ones set. + let flat = MoonTheme::active_tokens(cx); + assert_eq!(flat.skin, MoonSkin::Flat); + for (state, expected) in [ + (ButtonVisualState::Normal, fill), + (ButtonVisualState::Hovered, hover), + (ButtonVisualState::Active, active), + (ButtonVisualState::Selected, active), + ] { + let style = toned.resolve(false, state, cx); + assert_eq!((style.bg, style.fg), (expected, label), "flat {state:?}"); + assert_eq!(toned.resolve_facet(&flat, false, state), None); + } + + let tokens = tokens_with(p, MoonSkin::Facet); + let solid = facet_tinted(tone, MoonFacetTint::Solid, &tokens); + assert_eq!( + toned.resolve_facet(&tokens, false, ButtonVisualState::Normal), + Some(solid.clone()) + ); + + let hovered = toned + .resolve_facet(&tokens, false, ButtonVisualState::Hovered) + .expect("a hovered solid button has a facet"); + let mut with_second_glow = solid.shadows.clone(); + with_second_glow.push(shadow(0.0, 18.0, rgba_from(tone, 0.26), false)); + assert_eq!((hovered.fill, hovered.edge), (solid.fill, solid.edge)); + assert_eq!(hovered.shadows, with_second_glow); + + // Like its flat colours, a custom variant draws every selected state as the pressed one. + for state in [ + ButtonVisualState::Active, + ButtonVisualState::Selected, + ButtonVisualState::SelectedHovered, + ButtonVisualState::SelectedActive, + ] { + let pressed = toned + .resolve_facet(&tokens, false, state) + .unwrap_or_else(|| panic!("solid {state:?} has no facet")); + assert_eq!((pressed.fill, pressed.edge), (solid.fill, solid.edge)); + assert_eq!(pressed.shadows, inset_only(&solid), "solid {state:?}"); + assert!(pressed.shadows.len() < solid.shadows.len()); + } + assert_eq!( + toned.resolve_facet(&tokens, false, ButtonVisualState::Disabled), + None + ); + + for state in ALL_STATES { + assert_eq!( + plain.resolve_facet(&tokens, false, state), + None, + "a custom variant with no tone keeps its own colours in {state:?}" + ); + } + }); +} diff --git a/crates/moon-ui-components/src/checkbox.rs b/crates/moon-ui-components/src/checkbox.rs index d92b3e5..ff0af1b 100644 --- a/crates/moon-ui-components/src/checkbox.rs +++ b/crates/moon-ui-components/src/checkbox.rs @@ -4,7 +4,10 @@ use crate::{ Disableable, Selectable, Sizable, Size, StyledExt as _, moon::MoonTone, moon::foundation::{snap_border, snap_centered}, - moon::{MoonColors, MoonPalette, MoonTheme, MoonThemeTokens, rgba_from, svg::moon_svg}, + moon::{ + MoonColors, MoonFacet, MoonFacetKind, MoonFacetTint, MoonPalette, MoonTheme, + MoonThemeTokens, facet, facet_tinted, rgba_from, svg::moon_svg, + }, text::Text, tooltip::ComponentTooltip, v_flex, @@ -515,6 +518,41 @@ impl ChoiceColors { }, } } + + /// Resolves the faceted surface of a checkbox box or a radio circle. + /// + /// Only the Facet skin paints one. An unchecked box is a socket sunk into its surface; a checked + /// or indeterminate one is its tone lit from above — the control's `tone`, or the palette + /// accent that `bg_brand_solid` stands for. The mark keeps the ink [`Self::resolve`] gives it, + /// which is the ink that reads on that same tone. A disabled box keeps the flat colours at + /// half opacity, so it resolves no facet. + /// + /// Args: + /// tokens: The active theme side; supplies the skin, the palette and the UI scale. + /// tone: The tone set on the control, or `None` for the accent. + /// checked: Whether the box is checked or indeterminate. + /// disabled: Whether the control is disabled. + /// + /// Returns: + /// The facet to paint over the box's flat fill and border, or `None` where the flat + /// colours stand: under the Flat skin and on a disabled control. + pub(crate) fn box_facet( + tokens: &MoonThemeTokens, + tone: Option, + checked: bool, + disabled: bool, + ) -> Option { + if !tokens.skin.is_facet() || disabled { + return None; + } + let p = tokens.palette; + Some(if checked { + let tone = tone.map_or(p.accent, |tone| tone.color(p)); + facet_tinted(tone, MoonFacetTint::Solid, tokens) + } else { + facet(MoonFacetKind::Socket, tokens) + }) + } } /// Returns the focus ring for a checkbox or radio box whose corners have `box_radius`, to add as @@ -701,6 +739,16 @@ impl RenderOnce for Checkbox { .rounded(metrics.radius) .bg(colors.fill) .opacity(colors.box_opacity) + // Under the Facet skin an enabled box takes a faceted surface in place of + // the flat fill and border above; the border keeps its width. + .when_some( + ChoiceColors::box_facet(&tokens, self.tone, checked, self.disabled), + |this, facet| { + this.bg(facet.fill) + .border_color(facet.edge) + .shadow(facet.shadows) + }, + ) .when(is_focused, |this| { this.child(choice_focus_ring( &self.id, diff --git a/crates/moon-ui-components/src/combobox.rs b/crates/moon-ui-components/src/combobox.rs index 7cb7f00..53476e3 100644 --- a/crates/moon-ui-components/src/combobox.rs +++ b/crates/moon-ui-components/src/combobox.rs @@ -14,7 +14,7 @@ use crate::{ actions::{Cancel, Confirm, SelectDown, SelectUp}, button::{ButtonVariant, MoonButtonMetrics, MoonButtonStyle}, h_flex, - input::{clear_button, input_style}, + input::{clear_button, input_style, trigger_facets}, list::{List, ListState}, moon::{ MoonButtonSize, MoonPalette, MoonSize, MoonTheme, resolve_button_size, rgba_from, @@ -711,6 +711,7 @@ where bg, fg, trigger_style, + self.trigger_variant, trigger_metrics, outline_visible, allow_open, @@ -1018,6 +1019,7 @@ fn render_trigger_container( bg: Hsla, fg: Hsla, trigger_style: Option, + trigger_variant: Option, trigger_metrics: Option, outline_visible: bool, allow_open: bool, @@ -1031,6 +1033,16 @@ fn render_trigger_container( || cx.theme().input, |style| rgba_from(style.border, style.border_alpha), ); + // Under the Facet skin the trigger is a surface of its own, with its own hover and press + // ramp. A caller that set a background on it keeps that background. + let trigger_facet = trigger_facets( + &MoonTheme::active_tokens(cx), + trigger_variant, + outline_visible, + disabled, + ) + .filter(|_| appearance && style.background.is_none()); + let flat_ramp = !disabled && trigger_facet.is_none(); div() .id("input") .relative() @@ -1061,7 +1073,7 @@ fn render_trigger_container( .line_height(metrics.line_height) }) .refine_style(style) - .when(!disabled, |this| { + .when(flat_ramp, |this| { this.when_some(trigger_style, |this, style| { this.hover(move |this| { this.bg(rgba_from(style.bg, style.hover_bg_alpha)) @@ -1073,6 +1085,11 @@ fn render_trigger_container( }) }) }) + .when_some(trigger_facet, |this, [rest, hovered, pressed]| { + rest.paint(this) + .hover(move |this| hovered.paint(this)) + .active(move |this| pressed.paint(this)) + }) .when(outline_visible, |this| this.focused_border(cx)) .when(allow_open, |this| { this.when_some(toggle_handler, |this, handler| this.on_click(handler)) diff --git a/crates/moon-ui-components/src/dialog/dialog.rs b/crates/moon-ui-components/src/dialog/dialog.rs index 061ad71..87a5aaf 100644 --- a/crates/moon-ui-components/src/dialog/dialog.rs +++ b/crates/moon-ui-components/src/dialog/dialog.rs @@ -14,6 +14,7 @@ use crate::{ animation::cubic_bezier, button::{Button, ButtonVariant, ButtonVariants as _}, dialog::{DialogContent, DialogTitle}, + moon::{MoonFacetKind, MoonTheme, facet}, scroll::ScrollableElement as _, v_flex, }; @@ -480,6 +481,18 @@ impl RenderOnce for Dialog { let animation = Animation::new(*ANIMATION_DURATION).with_easing(cubic_bezier(0.32, 0.72, 0., 1.)); + // Under the Facet skin the dialog box is a popup surface: gradient fill, lit edge and + // the popup shadow stack. `None` is the Flat skin, which keeps the theme background, + // the theme border and the `shadow_xl` pair below. + let tokens = MoonTheme::active_tokens(cx); + let facet_surface = tokens + .skin + .is_facet() + .then(|| facet(MoonFacetKind::Popup, &tokens)); + let facet_shadows = facet_surface + .as_ref() + .map(|surface| surface.shadows.clone()); + anchored() .position(point(window_paddings.left, window_paddings.top)) .snap_to_window() @@ -528,6 +541,10 @@ impl RenderOnce for Dialog { .border_1() .border_color(cx.theme().border) .rounded(cx.theme().radius_lg) + // Ahead of `refine_style`, so a caller's own fill still wins. + .when_some(facet_surface, |this, surface| { + this.bg(surface.fill).border_color(surface.edge) + }) .min_h_24() .pt(paddings.top) .pb(paddings.bottom) @@ -645,6 +662,17 @@ impl RenderOnce for Dialog { } }) .with_animation("slide-down", animation.clone(), move |this, delta| { + // A faceted dialog fades its popup shadows in the same way. + if let Some(shadows) = &facet_shadows { + let shadow = shadows + .iter() + .map(|shadow| BoxShadow { + color: shadow.color.opacity(delta), + ..shadow.clone() + }) + .collect(); + return this.top(y * delta).shadow(shadow); + } // This is equivalent to `shadow_xl` with an extra opacity. let shadow = vec![ BoxShadow { diff --git a/crates/moon-ui-components/src/input/input.rs b/crates/moon-ui-components/src/input/input.rs index 74c4fe5..7fce5e0 100644 --- a/crates/moon-ui-components/src/input/input.rs +++ b/crates/moon-ui-components/src/input/input.rs @@ -2,15 +2,18 @@ use std::rc::Rc; use gpui::prelude::FluentBuilder as _; use gpui::{ - AnyElement, App, DefiniteLength, Edges, EdgesRefinement, ElementId, Entity, Hsla, - InteractiveElement as _, IntoElement, MouseButton, ParentElement as _, Pixels, RenderOnce, - StyleRefinement, Styled, TextAlign, Window, div, px, relative, + AnyElement, App, Background, BoxShadow, DefiniteLength, Edges, EdgesRefinement, ElementId, + Entity, Hsla, InteractiveElement as _, IntoElement, MouseButton, ParentElement as _, Pixels, + RenderOnce, StyleRefinement, Styled, TextAlign, Window, div, px, relative, }; use crate::Sizable; use crate::button::{Button, ButtonVariants as _}; use crate::input::clear_button; -use crate::moon::{MoonPalette, MoonTheme, MoonTone, rgba_from}; +use crate::moon::{ + MoonFacet, MoonFacetKind, MoonPalette, MoonTheme, MoonThemeTokens, MoonTone, facet, facet_glow, + rgba_from, +}; use crate::native_menu::NativeMenu; use crate::spinner::Spinner; use crate::{ActiveTheme, v_flex}; @@ -20,15 +23,142 @@ use crate::{Selectable, StyledExt, h_flex}; use super::{InputState, element::EditorScrollbar}; /// Returns `(background, foreground)` colors for input-like components. +/// +/// Under the Facet skin an enabled field is sunk into its surface, so its background is the +/// floor of the skin's well, the palette's `gutter`. A field that can paint the whole well — an +/// input, a select — lays it over this colour. One drawn by mirrored donor code, such as the +/// date field, has only this pair to go by, and still reads as sunk beside them. pub(crate) fn input_style(disabled: bool, cx: &App) -> (Hsla, Hsla) { - let p = MoonPalette::active(cx); + input_style_for(disabled, &MoonTheme::active_tokens(cx)) +} + +/// Resolves [`input_style`] for the theme side `tokens`. +pub(crate) fn input_style_for(disabled: bool, tokens: &MoonThemeTokens) -> (Hsla, Hsla) { + let p = tokens.palette; if disabled { (rgba_from(p.panel, 0.55), rgba_from(p.text_muted, 0.45)) + } else if tokens.skin.is_facet() { + (rgba_from(p.gutter, 1.0), rgba_from(p.text_soft, 1.0)) } else { (rgba_from(p.shell_high, 1.0), rgba_from(p.text_soft, 1.0)) } } +/// The frame an enabled input paints under the Facet skin, in place of its flat fill and border: +/// the skin's well, sunk into the surface the input sits on. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct InputFacet { + /// The well's fill, which the input keeps at rest, under the pointer and in focus. + pub(crate) fill: Background, + /// The colour of the one-pixel edge. + pub(crate) edge: Hsla, + /// The colour of that edge under the pointer. + pub(crate) hover_edge: Hsla, + /// The well's shadows, followed by the focus glow while the input shows its focus. + pub(crate) shadows: Vec, +} + +impl InputFacet { + /// Resolves the frame of an input under the Facet skin. + /// + /// At rest the frame is the skin's well. The edge keeps the colours the flat input gives it + /// where they carry meaning: `accent` at 78% on a selected input, 82% under the pointer, and + /// 92% in focus, where a glow in the same colour joins the well's shadows. An input with + /// neither takes the well's own edge, lit to the skin's hover edge under the pointer. An + /// input in focus keeps its focus edge under the pointer, so the edge never contradicts the + /// glow around it. + /// + /// A disabled input keeps the flat disabled look, so it resolves no frame. + /// + /// Args: + /// tokens: The active theme side; supplies the skin, the palette and the UI scale. + /// accent: The input's tone as `0xRRGGBB`: the selected and focused edge colour. + /// selected: Whether the input is marked selected. + /// focused: Whether the input shows its focus: it has focus and draws a focus edge. + /// disabled: Whether the input is disabled. + /// + /// Returns: + /// The frame to paint, or `None` where the flat frame stands: under the Flat skin and on + /// a disabled input. + pub(crate) fn resolve( + tokens: &MoonThemeTokens, + accent: u32, + selected: bool, + focused: bool, + disabled: bool, + ) -> Option { + if !tokens.skin.is_facet() || disabled { + return None; + } + let well = facet(MoonFacetKind::Well, tokens); + let mut shadows = well.shadows; + let (edge, hover_edge) = if focused { + shadows.push(facet_glow(accent, 0.28, 8.0, tokens)); + let focus = rgba_from(accent, 0.92); + (focus, focus) + } else if selected { + (rgba_from(accent, 0.78), rgba_from(accent, 0.82)) + } else { + // The skin's hover edge is the highlight ink at 20% on a dark palette and the + // palette's own hover border on a light one. + (well.edge, facet(MoonFacetKind::RaisedHover, tokens).edge) + }; + Some(Self { + fill: well.fill, + edge, + hover_edge, + shadows, + }) + } +} + +/// Resolves the frame of a select-like trigger under the Facet skin: at rest, under the +/// pointer and pressed. +/// +/// A trigger styled as a button is a raised control, as the button beside it is. One left with +/// the input look is a well, as the input beside it is, lit along its edge under the pointer +/// and glowing in the Info tone while it shows its focus or holds its menu open. +/// +/// Args: +/// tokens: The active theme side; supplies the skin, the palette and the UI scale. +/// variant: The button variant to preserve, or `None` for the input look. +/// focused: Whether the trigger shows its focus or holds its menu open. +/// disabled: Whether the trigger is disabled. +/// +/// Returns: +/// The three surfaces, or `None` where the flat frame stands: under the Flat skin and on a +/// disabled trigger. +pub(crate) fn trigger_facets( + tokens: &MoonThemeTokens, + variant: Option, + focused: bool, + disabled: bool, +) -> Option<[MoonFacet; 3]> { + if !tokens.skin.is_facet() || disabled { + return None; + } + if let Some(variant) = variant { + use crate::button::ButtonVisualState::{Active, Hovered, Normal}; + return Some([ + variant.resolve_facet(tokens, false, Normal)?, + variant.resolve_facet(tokens, false, Hovered)?, + variant.resolve_facet(tokens, false, Active)?, + ]); + } + let accent = MoonTone::Info.color(tokens.palette); + let frame = InputFacet::resolve(tokens, accent, false, focused, disabled)?; + let surface = |edge: Hsla| MoonFacet { + fill: frame.fill, + edge, + shadows: frame.shadows.clone(), + }; + Some([ + surface(frame.edge), + surface(frame.hover_edge), + surface(frame.hover_edge), + ]) +} + #[derive(Clone, Copy)] struct MoonInputMetrics { height: Pixels, @@ -361,6 +491,16 @@ impl RenderOnce for Input { } else { bg }; + // Under the Facet skin an enabled input is a well. A code editor keeps the flat frame: + // its background is the editor's own colour, which a well's fill would replace. + let frame = InputFacet::resolve( + &MoonTheme::active_tokens(cx), + accent, + self.selected, + focused && self.focus_bordered && self.bordered, + state.disabled, + ) + .filter(|_| self.appearance && !state.mode.is_code_editor()); let prefix = self.prefix; let suffix = self.suffix; @@ -477,7 +617,7 @@ impl RenderOnce for Input { .items_center() .when(state.mode.is_multi_line(), |this| this.h_auto()) .when_some(self.height, |this, height| this.h(height)) - .when(self.appearance, |this| { + .when(self.appearance && frame.is_none(), |this| { this.bg(bg) .rounded(metrics.radius) .when(self.bordered, |this| { @@ -503,6 +643,16 @@ impl RenderOnce for Input { }) }) }) + .when_some(frame, |this, frame| { + let hover_edge = frame.hover_edge; + this.bg(frame.fill) + .rounded(metrics.radius) + .shadow(frame.shadows) + .when(self.bordered, |this| { + this.border_color(frame.edge).border_1() + }) + .hover(move |this| this.border_color(hover_edge)) + }) .items_center() .gap(gap_x) .refine_style(&self.style) diff --git a/crates/moon-ui-components/src/moon/badge.rs b/crates/moon-ui-components/src/moon/badge.rs index 22c83a5..184ed99 100644 --- a/crates/moon-ui-components/src/moon/badge.rs +++ b/crates/moon-ui-components/src/moon/badge.rs @@ -6,6 +6,7 @@ use gpui::*; use super::{ foundation::MoonSize, icons::moon_icon, + tag::facet_outline_wash, theme::{MoonTheme, MoonThemeTokens}, tokens::{MoonPalette, MoonRect, MoonTone, rgba_from}, }; @@ -236,11 +237,15 @@ impl MoonBadge { } /// Render with the supplied palette and tokens, scaling text and chrome exactly once. + /// + /// Under the Facet skin of `tokens`, an outline badge carries the tone wash of + /// [`Self::outline_wash`] in place of its flat fill. pub fn render_with_theme(self, p: MoonPalette, tokens: MoonThemeTokens) -> impl IntoElement { let metrics = self.metrics(&tokens); let style = self.style(p); let disabled_scale = if self.disabled { 0.5 } else { 1.0 }; let border_alpha = style.border_alpha * disabled_scale; + let wash = self.outline_wash(style, disabled_scale, &tokens); let is_dot = matches!(self.content, MoonBadgeContent::Dot); let mut badge = div() @@ -258,6 +263,7 @@ impl MoonBadge { .justify_center() .rounded(px(metrics.radius)) .bg(rgba_from(style.bg, style.bg_alpha * disabled_scale)) + .when_some(wash, |this, wash| this.bg(wash)) .whitespace_nowrap() .when(border_alpha > 0.0, |this| { this.border(px(1.0)) @@ -353,6 +359,32 @@ impl MoonBadge { } } + /// Resolve the wash an outline badge paints in place of its flat fill. + /// + /// The wash takes the colour of the resolved fill, so a caller's fill colour still tints + /// it. A caller who set the fill opacity asked for that exact fill and gets no wash. + /// + /// Args: + /// style: This badge's resolved style. + /// strength: The share both ends of the wash are scaled by: the same factor a disabled + /// badge fades its flat fill by, 1.0 otherwise. + /// tokens: The theme side to resolve for; supplies the skin. + /// + /// Returns: + /// The wash for an outline badge under the Facet skin. `None` under the Flat skin, for + /// the solid and soft variants, and when the fill opacity was set explicitly. + fn outline_wash( + &self, + style: BadgeStyle, + strength: f32, + tokens: &MoonThemeTokens, + ) -> Option { + let washed = tokens.skin.is_facet() + && self.variant == MoonBadgeVariant::Outline + && self.bg_alpha.is_none(); + washed.then(|| facet_outline_wash(style.bg, strength)) + } + fn style(&self, p: MoonPalette) -> BadgeStyle { let tone = self.tone.color(p); let defaults = match self.variant { diff --git a/crates/moon-ui-components/src/moon/badge/tests.rs b/crates/moon-ui-components/src/moon/badge/tests.rs index c3a7cac..8f3d64e 100644 --- a/crates/moon-ui-components/src/moon/badge/tests.rs +++ b/crates/moon-ui-components/src/moon/badge/tests.rs @@ -1,6 +1,9 @@ -//! Badge density, tier and custom-scaling regression coverage. -use super::super::{foundation::MoonSize, theme::MoonThemeTokens}; -use super::{MoonBadge, MoonBadgeSize}; +//! Badge density, tier, custom-scaling and Facet-skin regression coverage. +use super::super::{ + MoonPalette, MoonSkin, MoonTone, foundation::MoonSize, rgba_from, theme::MoonThemeTokens, +}; +use super::{MoonBadge, MoonBadgeSize, MoonBadgeVariant}; +use gpui::{linear_color_stop, linear_gradient}; /// Catches selecting control height or legacy font scaling: badges must stay text-height at every density. #[test] @@ -63,3 +66,170 @@ fn badge_custom_preserves_legacy_scaling_and_fit() { (28.0, 20.0, 25.0, 6.0) ); } + +/// Theme tokens painting `palette` with `skin` at the reference scale. +fn tokens_with(palette: MoonPalette, skin: MoonSkin) -> MoonThemeTokens { + MoonThemeTokens { + skin, + palette, + ..MoonThemeTokens::default() + } +} + +/// The wash of `color` from `top` opacity along the top edge to `bottom` along the bottom +/// edge, written out here rather than taken from the code under test. +fn wash(color: u32, top: f32, bottom: f32) -> gpui::Background { + linear_gradient( + 180.0, + linear_color_stop(rgba_from(color, top), 0.0), + linear_color_stop(rgba_from(color, bottom), 1.0), + ) +} + +/// Catches `badge.rs:outline_wash` losing the wash of an outline badge on the faceted theme, +/// tinting it with the wrong colour, or ignoring the strength a faded badge passes. The oracle +/// is the depth study's outlined chip: the tone at 14% along the top down to 4%. +#[test] +fn an_outline_badge_under_the_facet_skin_is_washed_with_its_tone() { + for palette in [MoonPalette::OBSIDIAN, MoonPalette::LIGHT] { + let tokens = tokens_with(palette, MoonSkin::Facet); + let tone = MoonTone::Warning.color(palette); + let badge = || { + MoonBadge::new("3") + .variant(MoonBadgeVariant::Outline) + .tone(MoonTone::Warning) + }; + + let plain = badge(); + assert_eq!( + plain.outline_wash(plain.style(palette), 1.0, &tokens), + Some(wash(tone, 0.14, 0.04)) + ); + + // Rendering passes the factor a disabled badge fades its flat fill by; both ends of + // the wash follow it. + assert_eq!( + plain.outline_wash(plain.style(palette), 0.5, &tokens), + Some(wash(tone, 0.07, 0.02)) + ); + + // A caller's fill colour still tints the badge. + let recoloured = badge().bg_color(palette.blue); + assert_eq!( + recoloured.outline_wash(recoloured.style(palette), 1.0, &tokens), + Some(wash(palette.blue, 0.14, 0.04)) + ); + } +} + +/// Catches `badge.rs:MoonBadge::render_with_theme` painting a wash on a flat theme, not +/// painting the wash it resolves on the faceted one, leaving a disabled badge's wash at full +/// strength, or washing over a soft badge or a fill the caller set. The flat colours are the +/// flat design's own: the tone at 8% under a 27% border, both halved when disabled. +#[test] +fn only_the_outline_badge_of_the_faceted_theme_paints_a_wash() { + use super::super::MoonThemeConfig; + use super::super::button::scene_probe::{Painted, Pointer, painted}; + + let p = MoonPalette::OBSIDIAN; + let tone = MoonTone::Warning.color(p); + let obsidian_with = |skin: MoonSkin| { + let mut config = MoonThemeConfig::moon_obsidian(); + config.dark.skin = skin; + config + }; + let outline = || { + MoonBadge::new("3") + .variant(MoonBadgeVariant::Outline) + .tone(MoonTone::Warning) + }; + let flat_outline = Painted::flat(rgba_from(tone, 0.08), rgba_from(tone, 0.27)); + + assert_eq!( + painted(obsidian_with(MoonSkin::Flat), Pointer::Away, outline), + flat_outline + ); + assert_eq!( + painted(obsidian_with(MoonSkin::Flat), Pointer::Away, move || { + outline().disabled(true) + }), + Painted::flat(rgba_from(tone, 0.04), rgba_from(tone, 0.135)) + ); + + assert_eq!( + painted(obsidian_with(MoonSkin::Facet), Pointer::Away, outline), + Painted { + fill: Some(wash(tone, 0.14, 0.04)), + ..flat_outline.clone() + } + ); + assert_eq!( + painted(obsidian_with(MoonSkin::Facet), Pointer::Away, move || { + outline().disabled(true) + }), + Painted { + fill: Some(wash(tone, 0.07, 0.02)), + border: Some(rgba_from(tone, 0.135)), + shadows: Vec::new(), + }, + "a disabled badge halves the wash with the rest of its paint" + ); + + assert_eq!( + painted(obsidian_with(MoonSkin::Facet), Pointer::Away, move || { + outline().bg_alpha(0.3) + }), + Painted::flat(rgba_from(tone, 0.3), rgba_from(tone, 0.27)), + "an explicit fill opacity is painted as given" + ); + assert_eq!( + painted(obsidian_with(MoonSkin::Facet), Pointer::Away, || { + MoonBadge::new("3").tone(MoonTone::Warning) + }), + Painted::flat(rgba_from(tone, 0.14), rgba_from(tone, 0.0)), + "a soft badge keeps its own fill" + ); +} + +/// Catches `badge.rs:outline_wash` answering for the Flat skin, which would repaint the +/// outline badges of every existing theme, for a solid or soft badge, whose own fill it would +/// replace, or for a badge whose caller set the fill opacity and must get exactly that fill. +#[test] +fn flat_solid_soft_and_explicitly_filled_badges_get_no_wash() { + for palette in [MoonPalette::OBSIDIAN, MoonPalette::LIGHT] { + let faceted = tokens_with(palette, MoonSkin::Facet); + let flat = tokens_with(palette, MoonSkin::Flat); + let wash_of = |badge: MoonBadge, tokens: &MoonThemeTokens| { + badge.outline_wash(badge.style(palette), 1.0, tokens) + }; + + for variant in [ + MoonBadgeVariant::Solid, + MoonBadgeVariant::Soft, + MoonBadgeVariant::Outline, + ] { + assert_eq!( + wash_of(MoonBadge::new("3").variant(variant), &flat), + None, + "{variant:?} is washed under the Flat skin" + ); + } + for variant in [MoonBadgeVariant::Solid, MoonBadgeVariant::Soft] { + assert_eq!( + wash_of(MoonBadge::new("3").variant(variant), &faceted), + None, + "{variant:?} keeps its own fill under the Facet skin" + ); + } + assert_eq!( + wash_of( + MoonBadge::new("3") + .variant(MoonBadgeVariant::Outline) + .bg_alpha(0.3), + &faceted + ), + None, + "an explicit fill opacity is kept as given" + ); + } +} diff --git a/crates/moon-ui-components/src/moon/button.rs b/crates/moon-ui-components/src/moon/button.rs index 71a751e..cc22090 100644 --- a/crates/moon-ui-components/src/moon/button.rs +++ b/crates/moon-ui-components/src/moon/button.rs @@ -123,6 +123,10 @@ pub(super) fn native_variant(variant: MoonButtonVariant, cx: &App) -> ButtonVari /// Build the native custom variant that paints a [`MoonButtonVariant::Solid`] button. /// +/// The variant also names the fill as its facet tone, so under the Facet skin the native button +/// paints it as a solid facet of the accent instead of the flat colours; the label is the same +/// under both skins. +/// /// Args: /// p: Palette the button paints with. /// cx: Application context the native custom variant is seeded from. @@ -136,7 +140,38 @@ fn solid_variant(p: MoonPalette, cx: &App) -> ButtonVariant { .color(rgba_from(colors.fill, 1.0)) .hover(rgba_from(colors.fill, colors.hover_alpha)) .active(rgba_from(colors.fill, colors.active_alpha)) - .foreground(rgba_from(colors.label, 1.0)), + .foreground(rgba_from(colors.label, 1.0)) + .facet_solid(colors.fill), + ) +} + +/// Resolve the ink for text or a glyph a caller coloured `color`, on a raised faceted control. +/// +/// A raised facet is lit to `panel_high` along its top, where `text_muted` falls under the AA +/// contrast floor; `text_soft` is the quiet ink of a raised control. Under the Flat skin, and +/// for every other colour, the caller's colour is kept. +/// +/// Args: +/// color: The caller's colour as `0xRRGGBB`. +/// tokens: The active theme side; supplies the skin and the palette. +/// +/// Returns: +/// `text_soft` in place of `text_muted` under the Facet skin, `color` otherwise. +pub(super) fn raised_ink(color: u32, tokens: &MoonThemeTokens) -> u32 { + let p = tokens.palette; + if tokens.skin.is_facet() && color == p.text_muted { + p.text_soft + } else { + color + } +} + +/// Return whether `variant` is painted as a raised facet under the Facet skin, the surface +/// [`raised_ink`] applies to. +fn is_raised_variant(variant: MoonButtonVariant) -> bool { + matches!( + variant, + MoonButtonVariant::Neutral | MoonButtonVariant::Panel | MoonButtonVariant::Soft ) } @@ -377,15 +412,22 @@ impl MoonButtonIconSlot { /// Args: /// cx: Application context used to resolve the active Moon scale. /// fallback: Unscaled glyph edge used when [`Self::size`] was not set. + /// raised: Whether the glyph sits on a raised faceted button, where [`raised_ink`] + /// picks the ink for the slot's colour; `false` keeps the colour as given. /// /// Returns: /// The configured icon with scaling, colour, alpha, and any non-zero rotation applied. - fn icon(self, cx: &App, fallback: f32) -> Icon { + fn icon(self, cx: &App, fallback: f32, raised: bool) -> Icon { let tokens = MoonTheme::active_tokens(cx); let mut icon = Icon::default() .path(self.path) .size(px(tokens.ui(self.size.unwrap_or(fallback)))); if let Some(color) = self.color { + let color = if raised { + raised_ink(color, &tokens) + } else { + color + }; icon = icon.text_color(rgba_from_u32(color, self.alpha)); } if self.rotation != 0.0 { @@ -676,6 +718,9 @@ impl RenderOnce for MoonButton { MoonButtonSize::Tier(tier) => MoonButtonSize::tier_icon_size(tier), MoonButtonSize::Custom { .. } => 12.0, }; + // Whether caller-coloured segments and icons go through `raised_ink`: a disabled button + // keeps the flat disabled look on every skin, and with it the caller's colours. + let raised = is_raised_variant(self.variant) && !self.disabled; let mut button = Button::new(self.id) .with_variant(native_variant(self.variant, cx)) .selected(self.selected) @@ -730,10 +775,10 @@ impl RenderOnce for MoonButton { pair => pair, }; if let Some(icon) = leading_icon { - button = button.icon(icon.icon(cx, icon_fallback)); + button = button.icon(icon.icon(cx, icon_fallback, raised)); } if let Some(icon) = self.loading_icon { - button = button.loading_icon(icon.icon(cx, icon_fallback)); + button = button.loading_icon(icon.icon(cx, icon_fallback, raised)); } if let Some(tooltip) = self.tooltip { button = button.tooltip(tooltip); @@ -791,6 +836,11 @@ impl RenderOnce for MoonButton { .when(mono, |this| this.font_family(tokens.font_family(true))) .child(segment.text); if let Some(color) = segment.color { + let color = if raised { + raised_ink(color, &tokens) + } else { + color + }; text = text.text_color(rgba_from_u32(color, segment.alpha)); } text.into_any_element() @@ -801,7 +851,7 @@ impl RenderOnce for MoonButton { // segments, or a leading icon beside it. Only the trailing-ONLY case is absent here, // because the promotion above moved that icon into the leading slot. .when_some(trailing_icon, |this, icon| { - this.child(icon.icon(cx, icon_fallback)) + this.child(icon.icon(cx, icon_fallback, raised)) }) } } @@ -929,5 +979,9 @@ fn rgba_from_u32(color: u32, alpha: f32) -> Hsla { color } +// Test support shared with the other controls the Facet skin restyles (selector pill, tag, +// badge, kbd): it reads what a control painted, which their sibling tests need as well. +#[cfg(test)] +pub(super) mod scene_probe; #[cfg(test)] mod tests; diff --git a/crates/moon-ui-components/src/moon/button/scene_probe.rs b/crates/moon-ui-components/src/moon/button/scene_probe.rs new file mode 100644 index 0000000..2a4157f --- /dev/null +++ b/crates/moon-ui-components/src/moon/button/scene_probe.rs @@ -0,0 +1,345 @@ +//! Test support for the controls the Facet skin restyles: draws one control in a headless +//! window and reports what was painted for it. +//! +//! A unit test can resolve the surface a control should have, but only the scene shows whether +//! `render` painted it, and whether a flat theme still paints what it always has. The probe +//! swaps the headless renderer for a recorder, draws the control with the pointer away from +//! it, over it or pressing it, and reads the control's own fill, border and shadows back out +//! of the recorded scene. +//! +//! Nothing here is compiled outside tests, and nothing is public: the items are visible to the +//! Moon modules only. + +use std::{ + borrow::Cow, + cell::{Cell, RefCell}, + rc::Rc, + sync::Arc, +}; + +use gpui::{ + AnyElement, AnyWindowHandle, AppContext as _, AtlasKey, AtlasTile, Background, Bounds, + BoxShadow, Context, DevicePixels, HeadlessAppContext, Hsla, IntoElement, Modifiers, + MouseButton, MouseDownEvent, MouseMoveEvent, NoopTextSystem, ParentElement as _, Pixels, + PlatformAtlas, PlatformHeadlessRenderer, PlatformInput, Quad, Render, ScaledPixels, Scene, + Shadow, Size, Styled as _, Window, div, point, px, size, +}; + +use crate::moon::{MoonFacet, MoonTheme, MoonThemeConfig}; + +/// Distance from the window's top-left corner to the probed control, in pixels: room for the +/// control's shadows and glow, so that none of them is clipped by the window. +const MARGIN: f32 = 60.0; + +/// How far a recorded edge may sit from the laid-out one and still be the same edge, in device +/// pixels. Painting snaps bounds to whole device pixels; layout does not. +const SNAP_TOLERANCE: f32 = 1.0; + +/// Frames drawn after the window opens and after each pointer event, so that state a frame +/// hands to the next one, such as a hover or a press, has reached the scene that is read. +const SETTLE_FRAMES: usize = 3; + +/// Where the pointer is while the probed control is drawn. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(in crate::moon) enum Pointer { + /// Outside the control: its resting look. + Away, + /// Over the middle of the control, with no button held. + Over, + /// Over the middle of the control, with the left button held down. + Pressed, +} + +/// What a control painted for itself, as opposed to what its children painted. +#[derive(Clone, Debug, PartialEq)] +pub(in crate::moon) struct Painted { + /// The control's fill. `None` when it painted no fill, or a fully transparent one. + pub(in crate::moon) fill: Option, + /// The colour of the control's border. `None` when it painted no border. + pub(in crate::moon) border: Option, + /// The control's own shadows in reference pixels: the ones inside it first, then the ones + /// outside it, each group in the order it was painted. This is the order a + /// [`MoonFacet`](crate::moon::MoonFacet) lists its shadow stack in. + pub(in crate::moon) shadows: Vec, +} + +impl Painted { + /// What a control paints for a faceted surface: its fill, its edge as the border and its + /// whole shadow stack. + pub(in crate::moon) fn facet(surface: MoonFacet) -> Self { + Self { + fill: Some(surface.fill), + border: Some(surface.edge), + shadows: surface.shadows, + } + } + + /// What a control paints for a flat fill and border with no shadow. A fully transparent + /// colour is read back as nothing painted. + pub(in crate::moon) fn flat(fill: Hsla, border: Hsla) -> Self { + Self { + fill: (!fill.is_transparent()).then(|| Background::from(fill)), + border: (!border.is_transparent()).then_some(border), + shadows: Vec::new(), + } + } +} + +/// Draw the control `build` returns under the theme `config` and report what it painted. +/// +/// The control is the only child of a window-sized row and keeps its own size. Each call opens +/// its own headless application, so calls do not share a theme or a pointer. +/// +/// Args: +/// config: The theme to install before the control is drawn. +/// pointer: Where the pointer is while the control is drawn. +/// build: Builds the control; called once per frame. +/// +/// Returns: +/// The fill, border colour and shadows of the control's own box. +/// +/// Panics: +/// When the headless window cannot be opened or drawn, or the control was never laid out. +pub(in crate::moon) fn painted( + config: MoonThemeConfig, + pointer: Pointer, + build: impl Fn() -> E + 'static, +) -> Painted { + let scene = Rc::new(RefCell::new(RecordedScene::default())); + let recorded = scene.clone(); + let mut cx = HeadlessAppContext::with_platform( + Arc::new(NoopTextSystem::new()), + Arc::new(()), + move || Some(Box::new(SceneRecorder(recorded.clone())) as Box), + ); + cx.update(crate::init); + cx.update(|cx| MoonTheme::install_config(config, cx)); + + let bounds = Rc::new(Cell::new(Bounds::default())); + let window: AnyWindowHandle = cx + .open_window(size(px(480.0), px(240.0)), { + let bounds = bounds.clone(); + move |_, cx| { + cx.new(|_| ProbeHost { + build: Box::new(move || build().into_any_element()), + bounds, + }) + } + }) + .expect("headless window must open") + .into(); + settle(&mut cx, window); + + let control = bounds.get(); + assert!( + control.size.width > px(0.0) && control.size.height > px(0.0), + "the probed control was never laid out: {control:?}" + ); + let middle = control.center(); + if pointer != Pointer::Away { + send( + &mut cx, + window, + PlatformInput::MouseMove(MouseMoveEvent { + position: middle, + pressed_button: None, + modifiers: Modifiers::default(), + }), + ); + } + if pointer == Pointer::Pressed { + send( + &mut cx, + window, + PlatformInput::MouseDown(MouseDownEvent { + button: MouseButton::Left, + position: middle, + modifiers: Modifiers::default(), + click_count: 1, + first_mouse: false, + }), + ); + } + + let scale = cx + .update_window(window, |_, window, _| window.scale_factor()) + .expect("headless window must report its scale"); + // The recorder only fires on a render pass, and its "image" is an error by design. + let _ = cx.capture_screenshot(window); + scene.borrow().painted_for(control, scale) +} + +/// Dispatch `event` to the window and let the frames that follow it settle. +fn send(cx: &mut HeadlessAppContext, window: AnyWindowHandle, event: PlatformInput) { + cx.update_window(window, |_, window, cx| { + window.dispatch_event(event, cx); + }) + .expect("headless window must take input"); + settle(cx, window); +} + +/// Draw [`SETTLE_FRAMES`] frames. +fn settle(cx: &mut HeadlessAppContext, window: AnyWindowHandle) { + for _ in 0..SETTLE_FRAMES { + cx.update_window(window, |_, window, cx| { + window.refresh(); + window.draw(cx).clear(); + }) + .expect("headless frame must draw"); + cx.run_until_parked(); + } +} + +/// The root view of a probe window: one control, start-aligned so the row does not stretch it, +/// and the bounds it was laid out at. +struct ProbeHost { + build: Box AnyElement>, + bounds: Rc>>, +} + +impl Render for ProbeHost { + /// Render the control [`MARGIN`] in from the window's top-left corner and record its bounds. + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + let sink = self.bounds.clone(); + div() + .size_full() + .flex() + .flex_row() + .items_start() + .justify_start() + .p(px(MARGIN)) + .on_children_prepainted(move |bounds, _, _| { + if let Some(control) = bounds.first() { + sink.set(*control); + } + }) + .child((self.build)()) + } +} + +/// The quads and shadows of the last rendered scene. +#[derive(Default)] +struct RecordedScene { + quads: Vec, + shadows: Vec, +} + +impl RecordedScene { + /// Pick out what was painted for the box laid out at `control`. + /// + /// A fill and a border are separate quads over the same box. A child that fills its parent + /// exactly would be read as the parent; none of the probed controls has one. + /// + /// Args: + /// control: The control's laid-out bounds. + /// scale: The window's scale factor, which the recorded primitives are multiplied by. + /// + /// Returns: + /// The fill, border colour and shadows painted over exactly that box. + fn painted_for(&self, control: Bounds, scale: f32) -> Painted { + let target = control.scale(scale); + let own_quads = || { + self.quads + .iter() + .filter(|quad| same_box(quad.bounds, target)) + }; + let fill = own_quads() + .map(|quad| quad.background) + .find(|background| !background.is_transparent()); + let border = own_quads() + .find(|quad| { + let widths = quad.border_widths; + widths.top.0 + widths.right.0 + widths.bottom.0 + widths.left.0 > 0.0 + && !quad.border_color.is_transparent() + }) + .map(|quad| quad.border_color); + + let own_shadows = |inset: bool| { + self.shadows + .iter() + .filter(move |shadow| { + (shadow.inset != 0) == inset && same_box(shadow.element_bounds, target) + }) + .map(move |shadow| box_shadow_of(shadow, scale)) + }; + Painted { + fill, + border, + shadows: own_shadows(true).chain(own_shadows(false)).collect(), + } + } +} + +/// Return whether two recorded boxes are the same box, to within [`SNAP_TOLERANCE`]. +fn same_box(a: Bounds, b: Bounds) -> bool { + let near = |x: ScaledPixels, y: ScaledPixels| (x.0 - y.0).abs() <= SNAP_TOLERANCE; + near(a.origin.x, b.origin.x) + && near(a.origin.y, b.origin.y) + && near(a.size.width, b.size.width) + && near(a.size.height, b.size.height) +} + +/// Turn a recorded shadow back into the `BoxShadow` it was painted from, in reference pixels. +/// +/// A recorded shadow keeps its own rectangle and its element's: the offset is how far the one +/// was moved from the other, and the spread is how far it was grown, or for a shadow inside +/// its element how far its hole was shrunk. +fn box_shadow_of(shadow: &Shadow, scale: f32) -> BoxShadow { + let grown = (shadow.bounds.size.width.0 - shadow.element_bounds.size.width.0) / 2.0; + let spread = if shadow.inset != 0 { -grown } else { grown }; + let middle = |bounds: Bounds| bounds.center(); + let moved = middle(shadow.bounds) - middle(shadow.element_bounds); + BoxShadow { + color: shadow.color, + offset: point(px(moved.x.0 / scale), px(moved.y.0 / scale)), + blur_radius: px(shadow.blur_radius.0 / scale), + spread_radius: px(spread / scale), + inset: shadow.inset != 0, + } +} + +/// Records the primitives a headless window submits, without rasterizing them. +struct SceneRecorder(Rc>); + +impl PlatformHeadlessRenderer for SceneRecorder { + /// Record the scene; image output is intentionally unsupported. + fn render_scene_to_image( + &mut self, + scene: &Scene, + size: Size, + ) -> anyhow::Result { + self.render_scene(scene, size)?; + anyhow::bail!("the scene recorder does not rasterize images") + } + + /// Replace the preceding frame, so a probe reads the final submission only. + fn render_scene(&mut self, scene: &Scene, _size: Size) -> anyhow::Result<()> { + self.0.replace(RecordedScene { + quads: scene.quads.clone(), + shadows: scene.shadows.clone(), + }); + Ok(()) + } + + /// Fills and shadows do not depend on glyph or icon textures. + fn sprite_atlas(&self) -> Arc { + Arc::new(NoSpriteAtlas) + } +} + +/// Omits sprites from the scene: the probe reads quads and shadows only. +struct NoSpriteAtlas; + +impl PlatformAtlas for NoSpriteAtlas { + /// Decline every sprite. + fn get_or_insert_with<'a>( + &self, + _key: &AtlasKey, + _build: &mut dyn FnMut() -> anyhow::Result, Cow<'a, [u8]>)>>, + ) -> anyhow::Result> { + Ok(None) + } + + /// Nothing was stored, so there is nothing to remove. + fn remove(&self, _key: &AtlasKey) {} +} diff --git a/crates/moon-ui-components/src/moon/button/tests.rs b/crates/moon-ui-components/src/moon/button/tests.rs index eb9fa43..20250c9 100644 --- a/crates/moon-ui-components/src/moon/button/tests.rs +++ b/crates/moon-ui-components/src/moon/button/tests.rs @@ -1,12 +1,16 @@ //! Regression coverage for Moon button sizing and icon-slot rendering. -use super::super::{MoonDisclosureDirection, MoonPalette, MoonSize}; +use super::super::{ + MoonDisclosureDirection, MoonFacetKind, MoonFacetTint, MoonPalette, MoonSize, MoonSkin, + MoonThemeConfig, facet, facet_tinted, rgba_from, +}; +use super::scene_probe::{Painted, Pointer, painted}; use super::{ MoonButton, MoonButtonIconSlot, MoonButtonSize, MoonButtonVariant, MoonTheme, MoonThemeTokens, - SOLID_LABEL_CONTRAST_FLOOR, button_text_metrics, contrast_ratio, solid_colors, - tier_button_metrics, + SOLID_LABEL_CONTRAST_FLOOR, button_text_metrics, contrast_ratio, native_variant, raised_ink, + solid_colors, tier_button_metrics, }; -use crate::button::ButtonVariant; +use crate::button::{ButtonVariant, ButtonVisualState}; /// Catches ignoring `button.rs:MoonButton::width` or `full_width` during rendering, which would /// make fixed controls use content width or prevent full-row actions from filling their parent. @@ -546,3 +550,363 @@ fn existing_variants_keep_their_native_mapping() { ButtonVariant::Primary ); } + +// --- Facet skin ----------------------------------------------------------------------------- + +/// The Obsidian theme with its dark side drawn in `skin`: the same palette and scale on both +/// skins, so two renders differ by the skin alone. +fn obsidian_with(skin: MoonSkin) -> MoonThemeConfig { + let mut config = MoonThemeConfig::moon_obsidian(); + config.dark.skin = skin; + config +} + +/// Catches `button.rs:solid_variant` dropping its facet tone, which would leave the primary +/// button a flat accent slab among the lit controls of the faceted theme, and catches that +/// tone being honoured on a flat theme, where the solid button must not change. +#[gpui::test] +fn solid_button_is_a_solid_accent_facet_on_the_faceted_theme_only(cx: &mut gpui::TestAppContext) { + cx.update(crate::init); + + cx.update(|cx| MoonTheme::install_config(MoonThemeConfig::moon_obsidian(), cx)); + cx.update(|cx| { + let tokens = MoonTheme::active_tokens(cx); + let variant = native_variant(MoonButtonVariant::Solid, cx); + assert_eq!( + variant.resolve_facet(&tokens, false, ButtonVisualState::Normal), + Some(facet_tinted( + tokens.palette.accent, + MoonFacetTint::Solid, + &tokens + )) + ); + }); + + for flat in [ + MoonThemeConfig::moon_terminal(), + MoonThemeConfig::moon_light(), + obsidian_with(MoonSkin::Flat), + ] { + cx.update(|cx| MoonTheme::install_config(flat, cx)); + cx.update(|cx| { + let tokens = MoonTheme::active_tokens(cx); + let variant = native_variant(MoonButtonVariant::Solid, cx); + assert_eq!( + variant.resolve_facet(&tokens, false, ButtonVisualState::Normal), + None + ); + }); + } +} + +/// Catches the solid label, which is the same ink under both skins, dropping under the AA +/// floor on the faceted fill. That fill runs from the accent lifted a quarter of the way to +/// white down to the accent sunk by 6%; both ends are recomposited here. +#[test] +fn solid_label_clears_the_contrast_floor_on_both_ends_of_the_faceted_fill() { + let palette = MoonPalette::OBSIDIAN; + let solid = solid_colors(palette); + let top = blend(0xFFFFFF, palette.accent, 0.25); + let bottom = blend(0x000000, palette.accent, 0.06); + + for fill in [top, bottom] { + let ratio = contrast_ratio(solid.label, fill); + assert!( + ratio >= SOLID_LABEL_CONTRAST_FLOOR, + "solid label {:06X} on faceted {fill:06X}: {ratio:.2}", + solid.label + ); + } +} + +/// Catches `button.rs:raised_ink` keeping muted ink on a raised facet, where it falls under +/// the AA floor on the lit top of the control, and catches it rewriting any other colour or +/// touching a flat theme, where callers must get exactly the colour they passed. +#[test] +fn muted_ink_on_a_raised_facet_is_lifted_to_the_soft_ink() { + let palette = MoonPalette::OBSIDIAN; + let tokens = |skin: MoonSkin| MoonThemeTokens { + skin, + palette, + ..MoonThemeTokens::default() + }; + let faceted = tokens(MoonSkin::Facet); + + let lifted = raised_ink(palette.text_muted, &faceted); + assert_eq!(lifted, palette.text_soft); + // The reason for the rule, measured on the lit top of a raised facet. + assert!(contrast_ratio(palette.text_muted, palette.panel_high) < 4.5); + assert!(contrast_ratio(lifted, palette.panel_high) >= 4.5); + + for color in [palette.text, palette.text_soft, palette.accent, palette.red] { + assert_eq!(raised_ink(color, &faceted), color); + } + assert_eq!( + raised_ink(palette.text_muted, &tokens(MoonSkin::Flat)), + palette.text_muted + ); +} + +/// Builds one button for a probe or a layout harness. +type Build = fn() -> MoonButton; + +/// The neutral button every skin test starts from. +fn neutral() -> MoonButton { + MoonButton::new("b").label("Order") +} + +/// Catches `button.rs:Button::render` painting anything new on a flat theme: a facet fill, a +/// facet edge or a shadow where the flat button has none. The Obsidian palette is drawn here +/// with the Flat skin, and every colour is the flat design's own: the dark neutral fill, the +/// palette roles and the alphas, written out rather than read back from the button. +#[test] +fn a_flat_theme_paints_every_button_state_as_it_did_before_the_facet_skin() { + /// The neutral button's flat fill on a dark palette, which is not a palette entry. + const NEUTRAL_DARK_FILL: u32 = 0x1F2126; + let p = MoonPalette::OBSIDIAN; + let solid = solid_colors(p); + let flat = |fill: (u32, f32), border: (u32, f32)| { + Painted::flat(rgba_from(fill.0, fill.1), rgba_from(border.0, border.1)) + }; + let cases: [(&str, Pointer, Build, Painted); 9] = [ + ( + "resting neutral", + Pointer::Away, + neutral, + flat((NEUTRAL_DARK_FILL, 1.0), (p.border, 1.0)), + ), + ( + "hovered neutral", + Pointer::Over, + neutral, + flat((p.overlay, 0.10), (p.border_hover, 1.0)), + ), + ( + "pressed neutral", + Pointer::Pressed, + neutral, + flat((p.overlay, 0.06), (p.border_hover, 1.0)), + ), + ( + "selected neutral", + Pointer::Away, + || neutral().selected(true), + flat((p.accent, p.accent_tint_a), (p.accent, 1.0)), + ), + ( + "disabled neutral", + Pointer::Away, + || neutral().disabled(true), + flat((p.panel, 0.32), (p.border, 0.42)), + ), + ( + "resting blue", + Pointer::Away, + || neutral().primary(), + flat((p.blue, 0.10), (p.blue, 0.22)), + ), + ( + "hovered blue", + Pointer::Over, + || neutral().primary(), + flat((p.blue, 0.18), (p.blue, 0.42)), + ), + ( + "resting solid", + Pointer::Away, + || neutral().variant(MoonButtonVariant::Solid), + flat((solid.fill, 1.0), (solid.fill, 1.0)), + ), + ( + "hovered solid", + Pointer::Over, + || neutral().variant(MoonButtonVariant::Solid), + flat((solid.fill, solid.hover_alpha), (solid.fill, 1.0)), + ), + ]; + + let mismatches = paint_mismatches(MoonSkin::Flat, cases); + assert!( + mismatches.is_empty(), + "under the Flat skin:\n{}", + mismatches.join("\n") + ); +} + +/// Draw each case's button under the Obsidian palette with `skin` and describe every one that +/// did not paint what was expected, so one run names all the states that drifted. +fn paint_mismatches( + skin: MoonSkin, + cases: [(&str, Pointer, Build, Painted); N], +) -> Vec { + cases + .into_iter() + .filter_map(|(name, pointer, build, expected)| { + let got = painted(obsidian_with(skin), pointer, build); + (got != expected) + .then(|| format!("the {name} button painted {got:?}, not {expected:?}")) + }) + .collect() +} + +/// Catches `button.rs:Button::render` resolving a state's facet and then not painting it, or +/// painting the facet of another state: a faceted button that stays flat at rest, does not +/// relight under the pointer, or keeps its drop shadow while pressed. Also catches a disabled +/// or ghost button picking up a surface, and a loading one losing its fade. +#[test] +fn the_faceted_theme_paints_each_button_state_with_its_own_facet() { + let p = MoonPalette::OBSIDIAN; + let tokens = MoonThemeTokens { + skin: MoonSkin::Facet, + palette: p, + ..MoonThemeTokens::default() + }; + let raised = facet(MoonFacetKind::Raised, &tokens); + let top_to_bottom = |top: u32, bottom: u32| { + gpui::linear_gradient( + 180.0, + gpui::linear_color_stop(rgba_from(top, 1.0), 0.0), + gpui::linear_color_stop(rgba_from(bottom, 1.0), 1.0), + ) + }; + assert_eq!( + raised.fill, + top_to_bottom(p.panel_high, p.panel), + "the resting surface the cases below start from is a gradient" + ); + let soft_blue = facet_tinted(p.blue, MoonFacetTint::Soft, &tokens); + + let cases: [(&str, Pointer, Build, Painted); 11] = [ + ( + "resting neutral", + Pointer::Away, + neutral, + Painted::facet(raised.clone()), + ), + ( + "hovered neutral", + Pointer::Over, + neutral, + Painted::facet(facet(MoonFacetKind::RaisedHover, &tokens)), + ), + ( + "pressed neutral", + Pointer::Pressed, + neutral, + Painted { + fill: Some(top_to_bottom(p.panel, p.panel_high)), + border: Some(raised.edge), + shadows: raised + .shadows + .iter() + .filter(|shadow| shadow.inset) + .cloned() + .collect(), + }, + ), + ( + "selected neutral", + Pointer::Away, + || neutral().selected(true), + Painted::facet(facet_tinted(p.accent, MoonFacetTint::Selected, &tokens)), + ), + ( + "disabled neutral", + Pointer::Away, + || neutral().disabled(true), + Painted::flat(rgba_from(p.panel, 0.32), rgba_from(p.border, 0.42)), + ), + ( + "resting blue", + Pointer::Away, + || neutral().primary(), + Painted::facet(soft_blue.clone()), + ), + ( + "hovered blue", + Pointer::Over, + || neutral().primary(), + Painted::facet(facet_tinted(p.blue, MoonFacetTint::Selected, &tokens)), + ), + ( + "loading blue", + Pointer::Away, + || neutral().primary().loading(true), + Painted { + fill: Some(soft_blue.fill.opacity(0.8)), + border: Some(soft_blue.edge.opacity(0.8)), + shadows: soft_blue.shadows.clone(), + }, + ), + ( + "resting danger", + Pointer::Away, + || neutral().danger(), + Painted::facet(facet_tinted(p.red, MoonFacetTint::Soft, &tokens)), + ), + ( + "resting solid", + Pointer::Away, + || neutral().variant(MoonButtonVariant::Solid), + Painted::facet(facet_tinted(p.accent, MoonFacetTint::Solid, &tokens)), + ), + ( + "hovered ghost", + Pointer::Over, + || neutral().ghost(), + Painted::flat(rgba_from(p.overlay, 0.10), rgba_from(p.border_hover, 1.0)), + ), + ]; + + let mismatches = paint_mismatches(MoonSkin::Facet, cases); + assert!( + mismatches.is_empty(), + "under the Facet skin:\n{}", + mismatches.join("\n") + ); +} + +/// Catches the Facet skin changing a button's box, by adding a border, padding or a second +/// element, and catches a render path that only the faceted theme takes panicking: the same +/// button must occupy the same pixels on a flat theme and on the faceted one. +#[gpui::test] +fn the_facet_skin_leaves_every_button_box_unchanged(cx: &mut gpui::TestAppContext) { + let builds: [(&str, Build); 9] = [ + ("neutral", || MoonButton::new("b").label("Order")), + ("panel", || { + MoonButton::new("b") + .label("Order") + .variant(MoonButtonVariant::Panel) + }), + ("blue", || MoonButton::new("b").label("Order").primary()), + ("danger", || MoonButton::new("b").label("Order").danger()), + ("solid", || { + MoonButton::new("b") + .label("Order") + .variant(MoonButtonVariant::Solid) + }), + ("ghost", || MoonButton::new("b").label("Order").ghost()), + ("selected neutral", || { + MoonButton::new("b").label("Order").selected(true) + }), + ("disabled neutral", || { + MoonButton::new("b").label("Order").disabled(true) + }), + ("loading blue", || { + MoonButton::new("b").label("Order").primary().loading(true) + }), + ]; + + cx.update(crate::init); + for (name, build) in builds { + cx.update(|cx| MoonTheme::install_config(obsidian_with(MoonSkin::Flat), cx)); + let flat = laid_out_bounds(cx, build); + cx.update(|cx| MoonTheme::install_config(obsidian_with(MoonSkin::Facet), cx)); + let faceted = laid_out_bounds(cx, build); + + assert_eq!( + faceted, flat, + "the {name} button moved under the Facet skin" + ); + } +} diff --git a/crates/moon-ui-components/src/moon/checkbox/tests.rs b/crates/moon-ui-components/src/moon/checkbox/tests.rs index 8774597..07f1aba 100644 --- a/crates/moon-ui-components/src/moon/checkbox/tests.rs +++ b/crates/moon-ui-components/src/moon/checkbox/tests.rs @@ -99,3 +99,151 @@ fn compact_checkbox_metrics_are_strictly_below_standard() { assert!(xs.mark_size < sm.mark_size); assert!(xs.mark_stroke < sm.mark_stroke); } + +/// An opaque gradient from `top` at the top edge to `bottom` at the bottom edge, written out +/// rather than taken from the skin. +fn top_to_bottom(top: u32, bottom: u32) -> gpui::Background { + use crate::moon::rgba_from; + use gpui::{linear_color_stop, linear_gradient}; + + linear_gradient( + 180.0, + linear_color_stop(rgba_from(top, 1.0), 0.0), + linear_color_stop(rgba_from(bottom, 1.0), 1.0), + ) +} + +/// Catches `checkbox.rs:ChoiceColors::box_facet` ignoring the skin or the disabled flag: every +/// flat theme's checkboxes and radios would be repainted as wells and lit tones, or a disabled +/// box on the faceted theme would lose the flat fill it dims to half. With no facet resolved, +/// a box paints `ChoiceColors::resolve` alone, exactly as it did before skins existed. +#[test] +fn flat_themes_and_disabled_boxes_resolve_no_facet() { + use crate::checkbox::ChoiceColors; + use crate::moon::{MoonThemeConfig, MoonThemeTokens, MoonTone}; + + let flat_sides = [ + MoonThemeConfig::moon_terminal().dark, + MoonThemeConfig::moon_terminal().light, + MoonThemeConfig::moon_graphite().dark, + MoonThemeConfig::moon_obsidian().light, + MoonThemeTokens::default(), + ]; + for tokens in &flat_sides { + for tone in [None, Some(MoonTone::Warning)] { + for checked in [false, true] { + for disabled in [false, true] { + assert_eq!( + ChoiceColors::box_facet(tokens, tone, checked, disabled), + None, + "a flat theme never resolves a facet" + ); + } + } + } + } + + let faceted = MoonThemeConfig::moon_obsidian().dark; + for checked in [false, true] { + assert_eq!( + ChoiceColors::box_facet(&faceted, None, checked, true), + None, + "a disabled box keeps the flat colours" + ); + } +} + +/// Catches a faceted box drifting from its spec: unchecked, it is the skin's well, darkest at +/// its top and edged in the highlight ink at 6%; checked, it is the accent lit from above — +/// lifted 25% at the top, sunk 6% at the bottom, edged in the accent sunk 20% — unless a tone is +/// set, which then replaces the accent. Reading a soft tint for the checked box, or the accent +/// for a toned one, would leave the mark's ink on a fill it was not chosen for. +#[test] +fn a_faceted_box_is_a_well_until_checked_and_then_its_tone_lit_from_above() { + use crate::checkbox::ChoiceColors; + use crate::moon::{ + MoonFacetKind, MoonFacetTint, MoonThemeConfig, MoonTone, facet, facet_tinted, rgba_from, + }; + + let tokens = MoonThemeConfig::moon_obsidian().dark; + let p = tokens.palette; + // `gutter` 0x08090B sunk by 15%. + const WELL_TOP: u32 = 0x070809; + // The accent 0xFFB347 lifted by 25%, sunk by 6% and sunk by 20%. + const ACCENT_TOP: u32 = 0xFFC675; + const ACCENT_BOTTOM: u32 = 0xF0A843; + const ACCENT_EDGE: u32 = 0xCC8F39; + // The Info tone, `blue` 0x7FC9FF, sunk by 20%. + const BLUE_EDGE: u32 = 0x66A1CC; + + let unchecked = ChoiceColors::box_facet(&tokens, None, false, false).expect("faceted"); + assert_eq!(unchecked, facet(MoonFacetKind::Socket, &tokens)); + assert_eq!(unchecked.fill, top_to_bottom(WELL_TOP, p.gutter)); + assert_eq!(unchecked.edge, rgba_from(p.overlay, 0.17)); + assert_eq!( + ChoiceColors::box_facet(&tokens, Some(MoonTone::Info), false, false), + Some(unchecked), + "a tone colours only a checked box" + ); + + let checked = ChoiceColors::box_facet(&tokens, None, true, false).expect("faceted"); + assert_eq!( + checked, + facet_tinted(p.accent, MoonFacetTint::Solid, &tokens) + ); + assert_eq!(checked.fill, top_to_bottom(ACCENT_TOP, ACCENT_BOTTOM)); + assert_eq!(checked.edge, rgba_from(ACCENT_EDGE, 1.0)); + + let toned = + ChoiceColors::box_facet(&tokens, Some(MoonTone::Info), true, false).expect("faceted"); + assert_eq!(toned, facet_tinted(p.blue, MoonFacetTint::Solid, &tokens)); + assert_eq!(toned.edge, rgba_from(BLUE_EDGE, 1.0)); +} + +/// A checked small checkbox, shrunk to its content so the probes measure the control. +struct FacetedCheckboxHarness; + +impl gpui::Render for FacetedCheckboxHarness { + fn render( + &mut self, + _: &mut gpui::Window, + _: &mut gpui::Context, + ) -> impl gpui::IntoElement { + use gpui::ParentElement as _; + + crate::h_flex().child( + super::MoonCheckbox::new("faceted") + .size(MoonSize::Sm) + .checked(true), + ) + } +} + +/// Catches the faceted box changing the checkbox's geometry — a facet applied with a border of +/// another width, or the box resized to the design mock-up's: the mark is placed from the box's +/// 1px border, so under the faceted theme the box must keep its 16px and the 12px mark must stay +/// centred in it, exactly as under a flat theme. +#[gpui::test] +fn a_faceted_checkbox_keeps_its_box_and_centred_mark(cx: &mut gpui::TestAppContext) { + use crate::moon::{MoonTheme, MoonThemeConfig}; + use gpui::{px, size}; + + cx.update(crate::init); + for (config, faceted) in [ + (MoonThemeConfig::moon_terminal(), false), + (MoonThemeConfig::moon_obsidian(), true), + ] { + cx.update(|cx| MoonTheme::install_config(config, cx)); + cx.update(|cx| assert_eq!(MoonTheme::active_tokens(cx).skin.is_facet(), faceted)); + + let window = cx.add_window(|_, _| FacetedCheckboxHarness); + let mut cx = gpui::VisualTestContext::from_window(window.into(), cx); + cx.run_until_parked(); + + let box_bounds = cx.debug_bounds("faceted:box").expect("box must render"); + let mark = cx.debug_bounds("faceted:mark").expect("mark must render"); + assert_eq!(box_bounds.size, size(px(16.), px(16.)), "faceted={faceted}"); + assert_eq!(mark.size, size(px(12.), px(12.)), "faceted={faceted}"); + assert_eq!(mark.center(), box_bounds.center(), "faceted={faceted}"); + } +} diff --git a/crates/moon-ui-components/src/moon/collapsible.rs b/crates/moon-ui-components/src/moon/collapsible.rs index ed495b3..9db752c 100644 --- a/crates/moon-ui-components/src/moon/collapsible.rs +++ b/crates/moon-ui-components/src/moon/collapsible.rs @@ -3,8 +3,9 @@ use gpui::*; use super::{ disclosure::MoonDisclosure, + skin::{MoonFacet, MoonFacetKind, facet}, text::MoonText, - theme::MoonTheme, + theme::{MoonTheme, MoonThemeTokens}, tokens::{MoonRect, rgba_from}, }; @@ -25,6 +26,26 @@ fn moon_collapsible_header_click_next( } } +/// Resolve the surfaces a collapsible header paints under the Facet skin. +/// +/// A faceted header is a raised control whether the section is closed or open: the caret alone +/// says which. Under the pointer it relights as any raised control does. +/// +/// Args: +/// tokens: Active theme tokens; supply the skin, the palette and the UI scale. +/// +/// Returns: +/// The header's surface at rest and under the pointer, or `None` under the Flat skin, where +/// the header keeps its flat fill and its open-dependent border. +fn collapsible_header_facet(tokens: &MoonThemeTokens) -> Option<(MoonFacet, MoonFacet)> { + tokens.skin.is_facet().then(|| { + ( + facet(MoonFacetKind::Raised, tokens), + facet(MoonFacetKind::RaisedHover, tokens), + ) + }) +} + #[derive(IntoElement)] pub struct MoonCollapsible { id: SharedString, @@ -156,16 +177,28 @@ impl RenderOnce for MoonCollapsible { .h(px(tokens.ui(self.header_height))) .rounded(px(tokens.ui(4.0))) .border(px(tokens.ui(1.0))) - .border_color(rgba_from(p.border, if open { 1.0 } else { 0.74 })) - .bg(rgba_from(p.shell_high, if open { 0.98 } else { 0.72 })) .flex() .items_center() .gap(px(tokens.ui(8.0))) .px(px(tokens.ui(8.0))) .cursor_default() - .when(!disabled, |this| { - this.hover(|this| this.bg(rgba_from(p.overlay, 0.055))) - .active(|this| this.bg(rgba_from(p.overlay, 0.035))) + // `None` is the Flat skin. A faceted header is a raised control in both states. + .map(|this| match collapsible_header_facet(&tokens) { + Some((raised, hover)) => this + .border_color(raised.edge) + .bg(raised.fill) + .shadow(raised.shadows) + .when(!disabled, |this| { + this.hover(move |this| this.bg(hover.fill).border_color(hover.edge)) + .active(move |this| this.bg(raised.fill).border_color(raised.edge)) + }), + None => this + .border_color(rgba_from(p.border, if open { 1.0 } else { 0.74 })) + .bg(rgba_from(p.shell_high, if open { 0.98 } else { 0.72 })) + .when(!disabled, |this| { + this.hover(|this| this.bg(rgba_from(p.overlay, 0.055))) + .active(|this| this.bg(rgba_from(p.overlay, 0.035))) + }), }) .on_mouse_down(MouseButton::Left, { let state = state.clone(); diff --git a/crates/moon-ui-components/src/moon/collapsible/tests.rs b/crates/moon-ui-components/src/moon/collapsible/tests.rs index 3017fb3..09a3e6c 100644 --- a/crates/moon-ui-components/src/moon/collapsible/tests.rs +++ b/crates/moon-ui-components/src/moon/collapsible/tests.rs @@ -73,3 +73,50 @@ fn the_header_caret_is_wired_to_the_live_open_value() { "the header caret must never be built from a literal pose" ); } + +// ---- Theme skin: the surface of the header ---- + +use super::collapsible_header_facet; +use crate::moon::{MoonFacetKind, MoonPalette, MoonSkin, MoonThemeTokens, facet}; + +/// Theme tokens for `palette` drawn with `skin`, at the default scale. +fn skin_tokens(palette: MoonPalette, skin: MoonSkin) -> MoonThemeTokens { + MoonThemeTokens { + palette, + skin, + ..MoonThemeTokens::default() + } +} + +/// Catches `collapsible.rs:collapsible_header_facet` ignoring the skin: every flat theme's +/// section header would trade its `shell_high` fill and open-dependent border for a raised +/// gradient control. +#[test] +fn a_flat_collapsible_header_keeps_its_flat_fill() { + for palette in [ + MoonPalette::TERMINAL, + MoonPalette::LIGHT, + MoonPalette::OBSIDIAN, + ] { + assert_eq!( + collapsible_header_facet(&skin_tokens(palette, MoonSkin::Flat)), + None + ); + } +} + +/// Catches `collapsible.rs:collapsible_header_facet` swapping the two surfaces, or resolving a +/// sunken one: the header would be lit at rest and go dark under the pointer, or read as an +/// input field instead of something to click. +#[test] +fn a_faceted_collapsible_header_is_raised_and_relights_under_the_pointer() { + let tokens = skin_tokens(MoonPalette::OBSIDIAN, MoonSkin::Facet); + + assert_eq!( + collapsible_header_facet(&tokens), + Some(( + facet(MoonFacetKind::Raised, &tokens), + facet(MoonFacetKind::RaisedHover, &tokens), + )) + ); +} diff --git a/crates/moon-ui-components/src/moon/color_picker.rs b/crates/moon-ui-components/src/moon/color_picker.rs index 964651d..bc17b20 100644 --- a/crates/moon-ui-components/src/moon/color_picker.rs +++ b/crates/moon-ui-components/src/moon/color_picker.rs @@ -10,6 +10,7 @@ use super::{ MOON_SCROLLBAR_TRACK, MoonScrollAxis, MoonScrollbarVisibility, moon_scrollbar_overlay_with_palette, }, + skin::{MoonFacet, MoonFacetKind, facet}, text::MoonText, theme::{MoonTheme, MoonThemeTokens}, tokens::{MoonPalette, MoonTone, rgba_from}, @@ -345,6 +346,29 @@ impl MoonColorPicker { } } +/// Resolves the faceted frame of the [`MoonColorPickerTrigger::Field`] trigger. +/// +/// Under the Facet skin an enabled field is the skin's well, as a text input is, and its edge +/// takes the skin's hover edge under the pointer. A disabled field keeps the flat frame, dimmed +/// as before, so it resolves nothing. +/// +/// Args: +/// tokens: The active theme side; supplies the skin, the palette and the UI scale. +/// disabled: Whether the picker is disabled. +/// +/// Returns: +/// The well to paint and the colour of its edge under the pointer, or `None` where the flat +/// frame stands: under the Flat skin and on a disabled picker. +fn field_facet(tokens: &MoonThemeTokens, disabled: bool) -> Option<(MoonFacet, Hsla)> { + if !tokens.skin.is_facet() || disabled { + return None; + } + Some(( + facet(MoonFacetKind::Well, tokens), + facet(MoonFacetKind::RaisedHover, tokens).edge, + )) +} + /// Render a colour as the `#RRGGBB` text the hex field shows and [`parse_hex_rgb`] accepts back. fn hex_label(color: Hsla) -> SharedString { let [r, g, b] = rgb8_of(color); @@ -408,6 +432,7 @@ impl RenderOnce for MoonColorPicker { .uppercase(false) .render(); let trigger_id = ElementId::from(SharedString::from(format!("{}:trigger", self.id))); + let field_well = field_facet(&tokens, self.disabled); let trigger = match chrome.field_height { Some(height) => div() .id(trigger_id) @@ -425,9 +450,17 @@ impl RenderOnce for MoonColorPicker { .gap(px(tokens.ui(8.0))) .px(px(tokens.ui(7.0))) .cursor_default() - .when(!self.disabled, |this| { + .when(!self.disabled && field_well.is_none(), |this| { this.hover(move |this| this.border_color(rgba_from(p.border_hover, 1.0))) }) + // Under the Facet skin an enabled field is a well in place of the flat frame + // above; the swatch inside it is left as it is. + .when_some(field_well, |this, (well, hover_edge)| { + this.bg(well.fill) + .border_color(well.edge) + .shadow(well.shadows) + .hover(move |this| this.border_color(hover_edge)) + }) .child( div() .w(px(chrome.swatch_w)) diff --git a/crates/moon-ui-components/src/moon/color_picker/tests.rs b/crates/moon-ui-components/src/moon/color_picker/tests.rs index be9a983..885f856 100644 --- a/crates/moon-ui-components/src/moon/color_picker/tests.rs +++ b/crates/moon-ui-components/src/moon/color_picker/tests.rs @@ -1,10 +1,10 @@ //! Regression coverage for the Moon colour-picker hex field and reuse palette. use super::{ - MAX_CUSTOM_COLORS, MoonColorPickerTrigger, TriggerChrome, hex_label, parse_hex_rgb, - push_all_custom, push_custom, rgb8_of, trigger_chrome, + MAX_CUSTOM_COLORS, MoonColorPickerTrigger, TriggerChrome, field_facet, hex_label, + parse_hex_rgb, push_all_custom, push_custom, rgb8_of, trigger_chrome, }; -use crate::moon::{MoonScale, MoonThemeTokens}; +use crate::moon::{MoonFacetKind, MoonScale, MoonThemeConfig, MoonThemeTokens, facet, rgba_from}; use gpui::{Hsla, hsla, rgb}; /// Builds an opaque colour from its independently chosen six-digit RGB value. @@ -177,3 +177,40 @@ fn swatch_trigger_follows_the_ui_scale() { let swatch = trigger_chrome(&tokens, MoonColorPickerTrigger::Swatch, None); assert_eq!((swatch.swatch_w, swatch.swatch_h), (42.0, 30.0)); } + +/// Catches `color_picker.rs:field_facet` ignoring the skin or the disabled flag: the field of +/// every flat theme's picker would be repainted as a well, or a disabled field on the faceted +/// theme would lose its dimmed flat frame. With nothing resolved, the field paints the flat +/// border and fill it always had. +#[test] +fn flat_themes_and_disabled_fields_resolve_no_well() { + for tokens in [ + MoonThemeConfig::moon_terminal().dark, + MoonThemeConfig::moon_terminal().light, + MoonThemeConfig::moon_obsidian().light, + MoonThemeTokens::default(), + ] { + for disabled in [false, true] { + assert_eq!(field_facet(&tokens, disabled), None); + } + } + assert_eq!( + field_facet(&MoonThemeConfig::moon_obsidian().dark, true), + None, + "a disabled field keeps the flat frame" + ); +} + +/// Catches the faceted field drifting from the text input it sits beside: enabled, it is the +/// skin's well — edged in the highlight ink at 6%, not the 9% of a raised control — and its edge +/// rises to that ink at 20% under the pointer. +#[test] +fn a_faceted_field_is_a_well_that_lights_its_edge_under_the_pointer() { + let tokens = MoonThemeConfig::moon_obsidian().dark; + let p = tokens.palette; + + let (well, hover_edge) = field_facet(&tokens, false).expect("faceted"); + assert_eq!(well, facet(MoonFacetKind::Well, &tokens)); + assert_eq!(well.edge, rgba_from(p.overlay, 0.06)); + assert_eq!(hover_edge, rgba_from(p.overlay, 0.2)); +} diff --git a/crates/moon-ui-components/src/moon/data_table.rs b/crates/moon-ui-components/src/moon/data_table.rs index 3fb60bc..352f9c7 100644 --- a/crates/moon-ui-components/src/moon/data_table.rs +++ b/crates/moon-ui-components/src/moon/data_table.rs @@ -15,7 +15,7 @@ use super::{ moon_horizontal_track_is_drawn, moon_scrollbar_overlay_with_palette, }, table::{MoonTableAlign, MoonTableCell, MoonTableColumn, MoonTableRow, MoonTableStyle}, - theme::MoonTheme, + theme::{MoonTheme, MoonThemeTokens}, tokens::{MoonPalette, MoonRect, MoonTone, rgba_from}, virtual_list::{MoonVirtualList, MoonVirtualListScrollHandle}, }; @@ -61,6 +61,29 @@ type MoonDataContextMenuBuilder = /// Minimum logical column width used by drag resizing and render-time fitting. const MIN_COLUMN_WIDTH: f32 = 40.0; +/// Alpha of the `overlay` wash a faceted table row shows under the pointer. +const FACET_ROW_HOVER_ALPHA: f32 = 0.04; + +/// Resolve the fill a table row shows under the pointer under the Facet skin. +/// +/// A faceted table lets the surface under it show through, so its hover is a translucent wash +/// over that surface rather than an opaque row colour. +/// +/// # Arguments +/// +/// * `tokens` - Active theme tokens; supply the skin and the palette. +/// +/// # Returns +/// +/// The hover fill, or `None` under the Flat skin, where the row keeps the theme's `table_hover` +/// role. +fn facet_row_hover(tokens: &MoonThemeTokens) -> Option { + tokens + .skin + .is_facet() + .then(|| rgba_from(tokens.palette.overlay, FACET_ROW_HOVER_ALPHA)) +} + /// Return whether one keystroke is the platform's exact Select All shortcut. /// /// # Arguments @@ -1356,7 +1379,11 @@ impl RenderOnce for MoonDataTable { .w_full() .h(px(row_height)) .cursor_pointer() - .hover(|this| this.bg(cx.theme().table_hover)) + // `None` is the Flat skin, which keeps the theme's opaque hover role. + .map(|this| match facet_row_hover(&tokens) { + Some(hover) => this.hover(move |this| this.bg(hover)), + None => this.hover(|this| this.bg(cx.theme().table_hover)), + }) .on_click({ let on_select_row = on_select_row.clone(); move |event, window, cx| { @@ -1432,6 +1459,10 @@ impl RenderOnce for MoonDataTable { style.header_separator, style.header_separator_alpha, )) + // The gutter's edge continues the header's rule under the Facet skin. + .when_some(style.header_facet(p, &tokens), |this, facet| { + this.border_color(facet.separator) + }) .bg(row_header_bg) .child((ix + 1).to_string()) .on_click(move |_, window, cx| { @@ -1502,13 +1533,22 @@ impl RenderOnce for MoonDataTable { .top(px(0.0)) .w(px(row_header_width)) .h(px(header_height)) - .bg(rgba_from(style.header_bg, 1.0)) - .border_b(px(1.0)) - .border_r(px(1.0)) - .border_color(rgba_from( - style.header_separator, - style.header_separator_alpha, - )), + // The corner is a piece of the header and follows it under the Facet skin. + .map(|this| match style.header_facet(p, &tokens) { + Some(facet) => this + .when_some(facet.fill, |this, fill| this.bg(fill)) + .border_b(px(1.0)) + .border_r(px(1.0)) + .border_color(facet.separator), + None => this + .bg(rgba_from(style.header_bg, 1.0)) + .border_b(px(1.0)) + .border_r(px(1.0)) + .border_color(rgba_from( + style.header_separator, + style.header_separator_alpha, + )), + }), ); } diff --git a/crates/moon-ui-components/src/moon/data_table/header.rs b/crates/moon-ui-components/src/moon/data_table/header.rs index a471af1..f76e344 100644 --- a/crates/moon-ui-components/src/moon/data_table/header.rs +++ b/crates/moon-ui-components/src/moon/data_table/header.rs @@ -67,12 +67,20 @@ pub(super) fn render_header( .h(px(height)) .flex() .items_center() - .bg(rgba_from(style.header_bg, 1.0)) - .border_b(px(1.0)) - .border_color(rgba_from( - style.header_separator, - style.header_separator_alpha, - )); + // `None` is the Flat skin; see `MoonTableStyle::header_facet` for the faceted header. + .map(|this| match style.header_facet(p, &tokens) { + Some(facet) => this + .when_some(facet.fill, |this, fill| this.bg(fill)) + .border_b(px(1.0)) + .border_color(facet.separator), + None => this + .bg(rgba_from(style.header_bg, 1.0)) + .border_b(px(1.0)) + .border_color(rgba_from( + style.header_separator, + style.header_separator_alpha, + )), + }); // Which column's resize strip the pointer is over. GPUI applies hover styles at paint time // only, so the mark's hover form (taller and wider) cannot come from `hover`/`group_hover`; diff --git a/crates/moon-ui-components/src/moon/data_table/tests.rs b/crates/moon-ui-components/src/moon/data_table/tests.rs index 481e716..39cab14 100644 --- a/crates/moon-ui-components/src/moon/data_table/tests.rs +++ b/crates/moon-ui-components/src/moon/data_table/tests.rs @@ -624,3 +624,87 @@ fn resize_mark_alpha_is_denser_only_on_the_near_black_header() { assert_eq!(resize_mark_alpha(MoonPalette::GRAPHITE.table_head), 0.55); assert_eq!(resize_mark_alpha(MoonPalette::LIGHT.table_head), 0.55); } + +// ---- Theme skin: what the header and a hovered row paint ---- + +use super::facet_row_hover; +use crate::moon::{MoonSkin, MoonTableStyle, MoonThemeTokens, rgba_from}; + +/// Theme tokens for `palette` drawn with `skin`, at the default scale. +fn skin_tokens(palette: MoonPalette, skin: MoonSkin) -> MoonThemeTokens { + MoonThemeTokens { + palette, + skin, + ..MoonThemeTokens::default() + } +} + +/// Catches `table.rs:MoonTableStyle::header_facet` or `data_table.rs:facet_row_hover` ignoring +/// the skin: every flat theme's table would lose its header fill and its opaque `table_hover` +/// row, which the render keeps only while these answer `None`. +#[test] +fn a_flat_table_keeps_its_header_fill_and_theme_row_hover() { + for palette in [ + MoonPalette::TERMINAL, + MoonPalette::LIGHT, + MoonPalette::GRAPHITE, + MoonPalette::OBSIDIAN, + ] { + let tokens = skin_tokens(palette, MoonSkin::Flat); + assert_eq!( + MoonTableStyle::for_palette(palette).header_facet(palette, &tokens), + None + ); + assert_eq!(facet_row_hover(&tokens), None); + } +} + +/// Catches `table.rs:MoonTableStyle::header_facet` keeping the palette's header fill or its +/// solid separator under the Facet skin, and `data_table.rs:facet_row_hover` keeping an opaque +/// hover: the table would sit on its panel as a flat block that hides the slab under it. +#[test] +fn a_faceted_table_drops_the_header_fill_and_hovers_with_a_wash() { + let p = MoonPalette::OBSIDIAN; + let tokens = skin_tokens(p, MoonSkin::Facet); + + // The route `MoonDataTable::render` takes: a style built for the terminal palette, themed. + let style = MoonTableStyle::default().themed(p); + let header = style + .header_facet(p, &tokens) + .expect("the Facet skin resolves a header facet"); + assert_eq!(header.fill, None, "the header paints no fill of its own"); + assert_eq!(header.separator, rgba_from(p.overlay, 0.07)); + + assert_eq!(facet_row_hover(&tokens), Some(rgba_from(p.overlay, 0.04))); +} + +/// Catches `table.rs:MoonTableStyle::header_facet` discarding a colour the caller chose: a +/// table styled with its own header fill or separator would silently lose it under one theme. +#[test] +fn a_faceted_table_keeps_a_header_colour_the_caller_chose() { + let p = MoonPalette::OBSIDIAN; + let tokens = skin_tokens(p, MoonSkin::Facet); + const OWN_FILL: u32 = 0x123456; + const OWN_SEPARATOR: u32 = 0x654321; + + let own_fill = MoonTableStyle { + header_bg: OWN_FILL, + ..MoonTableStyle::for_palette(p) + }; + let header = own_fill + .header_facet(p, &tokens) + .expect("the Facet skin resolves a header facet"); + assert_eq!(header.fill, Some(rgba_from(OWN_FILL, 1.0))); + assert_eq!(header.separator, rgba_from(p.overlay, 0.07)); + + let own_separator = MoonTableStyle { + header_separator: OWN_SEPARATOR, + header_separator_alpha: 0.5, + ..MoonTableStyle::for_palette(p) + }; + let header = own_separator + .header_facet(p, &tokens) + .expect("the Facet skin resolves a header facet"); + assert_eq!(header.fill, None); + assert_eq!(header.separator, rgba_from(OWN_SEPARATOR, 0.5)); +} diff --git a/crates/moon-ui-components/src/moon/dock.rs b/crates/moon-ui-components/src/moon/dock.rs index c94b704..18a77da 100644 --- a/crates/moon-ui-components/src/moon/dock.rs +++ b/crates/moon-ui-components/src/moon/dock.rs @@ -15,6 +15,7 @@ use serde::{Deserialize, Serialize}; use super::{ background::MoonBackgroundPolicy, + skin::{MoonFacetKind, MoonSkin, facet}, text::MoonText, theme::{MoonTheme, MoonThemeTokens}, tokens::{MoonPalette, rgba_from}, @@ -39,7 +40,56 @@ pub use state::{ }; use tab_panel::tab_header_height; +/// What a floating tile paints under the Facet skin: the surface of the tab group inside it. +/// +/// A tile whose panel is opaque is a popup: gradient fill, edge and the shadow that lifts it off +/// the dock. A tile whose panel is see-through keeps the edge and the highlight along its top +/// only — GPUI paints a shadow that falls outside an element under the whole of it, so on an +/// unfilled tile it would darken the canvas the panel exists to show. +#[derive(Clone, Debug, PartialEq)] +struct TileFacet { + /// Corner radius of the tile, in rendered pixels. + radius: f32, + /// Fill of the tile; `None` leaves it unpainted. + fill: Option, + /// Colour of the tile's one-pixel edge. + edge: Hsla, + /// Shadows of the tile. + shadows: Vec, +} + +impl TileFacet { + /// Resolve the faceted look of a floating tile. + /// + /// Args: + /// tokens: Active theme tokens; supply the skin, the palette and the UI scale. + /// policy: Background policy of the panel the tile hosts. + /// + /// Returns: + /// The faceted look, or `None` under the Flat skin, where the tile renders as it always + /// has. + fn resolve(tokens: &MoonThemeTokens, policy: MoonBackgroundPolicy) -> Option { + if !tokens.skin.is_facet() { + return None; + } + let popup = facet(MoonFacetKind::Popup, tokens); + let opaque = matches!(policy, MoonBackgroundPolicy::Opaque); + Some(Self { + radius: tokens.ui(tokens.metrics.container_radius), + fill: opaque.then_some(popup.fill), + edge: popup.edge, + shadows: popup + .shadows + .into_iter() + .filter(|shadow| opaque || shadow.inset) + .collect(), + }) + } +} + const DOCK_RESIZE_HIT_SIZE: f32 = 6.0; +/// Alpha of the `overlay` wash a faceted resize strip shows under the pointer. +const FACET_RESIZE_HOVER_ALPHA: f32 = 0.04; /// Smallest gap between two repaints while a dock handle is being dragged. /// @@ -183,6 +233,9 @@ pub struct TabPanel { close_allowed: bool, pinned_leading_panels: Vec, panel_control_tooltips: DockPanelControlTooltips, + /// Whether a floating tile hosts this tab group and draws its surface. Read only under the + /// Facet skin, where the tile carries the fill, the edge and the shadow of the group. + floating: bool, } impl TabPanel { @@ -205,6 +258,7 @@ impl TabPanel { close_allowed: true, pinned_leading_panels: Vec::new(), panel_control_tooltips: DockPanelControlTooltips::default(), + floating: false, } } @@ -268,6 +322,12 @@ impl TabPanel { self } + /// Mark this tab group as the content of a floating tile, which draws the group's surface. + fn floating(mut self, floating: bool) -> Self { + self.floating = floating; + self + } + fn dock_context( mut self, dock_area: WeakEntity, @@ -1629,6 +1689,9 @@ impl DockArea { } /// Render a pointer resize handle, or no element while structural editing is locked. + /// + /// Under the Flat skin the handle is a filled strip with a line along its middle. Under the + /// Facet skin it is the unpainted gap between two slabs, of the same size and grab area. fn resize_handle( &self, id: SharedString, @@ -1640,6 +1703,10 @@ impl DockArea { return Empty.into_any_element(); } let p = MoonPalette::active(cx); + // Faceted panels are slabs with a gap between them: the strip keeps its size and its + // grab area, and paints neither a fill nor a line, so the gap shows what lies behind + // the dock. Only a faint wash under the pointer says the gap can be dragged. + let faceted = MoonSkin::active(cx).is_facet(); let grab_cursor = if horizontal_split { CursorStyle::ResizeColumn } else { @@ -1650,7 +1717,7 @@ impl DockArea { .id(ElementId::from(id)) .flex_none() .relative() - .bg(rgba_from(p.shell, 1.0)) + .when(!faceted, |this| this.bg(rgba_from(p.shell, 1.0))) .occlude() .cursor(if horizontal_split { CursorStyle::ResizeColumn @@ -1662,16 +1729,25 @@ impl DockArea { .h_full() .items_center() .justify_center() - .child(div().w(px(1.0)).h_full().bg(rgba_from(p.border, 1.0))) + .when(!faceted, |this| { + this.child(div().w(px(1.0)).h_full().bg(rgba_from(p.border, 1.0))) + }) }) .when(!horizontal_split, |this| { this.h(px(DOCK_RESIZE_HIT_SIZE)) .w_full() .items_center() .justify_center() - .child(div().h(px(1.0)).w_full().bg(rgba_from(p.border, 1.0))) + .when(!faceted, |this| { + this.child(div().h(px(1.0)).w_full().bg(rgba_from(p.border, 1.0))) + }) + }) + .when(!faceted, |this| { + this.hover(|style| style.bg(rgba_from(p.shell_high, 1.0))) + }) + .when(faceted, |this| { + this.hover(|style| style.bg(rgba_from(p.overlay, FACET_RESIZE_HOVER_ALPHA))) }) - .hover(|style| style.bg(rgba_from(p.shell_high, 1.0))) // A press ARMS the gesture; the pointer is then followed by window-level // listeners (`resize_pointer_hook`). Deliberately NOT `on_drag`: GPUI's drag // machinery exists to float a preview under the cursor, and pays for it with a @@ -3167,6 +3243,17 @@ impl DockArea { .border(px(1.0)) .border_color(rgba_from(p.border, 1.0)); tile = panel.background_policy(cx).apply(tile, p.shell_high, 1.0); + if let Some(tile_facet) = + TileFacet::resolve(&tokens, panel.background_policy(cx)) + { + tile = tile + .rounded(px(tile_facet.radius)) + .border_color(tile_facet.edge) + .shadow(tile_facet.shadows); + if let Some(fill) = tile_facet.fill { + tile = tile.bg(fill); + } + } tiles = tiles.child( tile.child( TabPanel::new( @@ -3183,6 +3270,7 @@ impl DockArea { .close_allowed(self.close_allowed) .pinned_leading_panels(self.pinned_leading_panels.clone()) .panel_control_tooltips(self.panel_control_tooltips.clone()) + .floating(true) .background_policy(MoonBackgroundPolicy::NoFill) .content_background_policy(panel.background_policy(cx)), ) @@ -3331,6 +3419,7 @@ impl DockArea { sizes, } => { let p = MoonPalette::active(cx); + let faceted = MoonSkin::active(cx).is_facet(); let id_text = id.to_string(); let split_key = Self::split_key(root, &path); let dock = cx.entity(); @@ -3387,7 +3476,11 @@ impl DockArea { .min_h(px(0.0)) .when(*horizontal, |this| this.h_full()) .when(!*horizontal, |this| this.w_full()); - slot = child.background_policy(cx).apply(slot, p.shell, 1.0); + // A faceted slab has rounded corners and a gap beside it; both show what + // lies behind the dock, so the slot does not paint `shell` under them. + if !faceted { + slot = child.background_policy(cx).apply(slot, p.shell, 1.0); + } let slot_size = if first_child_flexes && ix == 0 { None } else { diff --git a/crates/moon-ui-components/src/moon/dock/tab_panel.rs b/crates/moon-ui-components/src/moon/dock/tab_panel.rs index d6cc60a..f7edb3a 100644 --- a/crates/moon-ui-components/src/moon/dock/tab_panel.rs +++ b/crates/moon-ui-components/src/moon/dock/tab_panel.rs @@ -2,9 +2,10 @@ use gpui::prelude::FluentBuilder as _; use gpui::{ - App, AppContext as _, ElementId, InteractiveElement as _, IntoElement, MouseButton, - MouseDownEvent, ParentElement as _, RenderOnce, ScrollHandle, SharedString, - StatefulInteractiveElement as _, Styled as _, WeakEntity, Window, div, px, + App, AppContext as _, Background, BoxShadow, ElementId, Hsla, InteractiveElement as _, + IntoElement, MouseButton, MouseDownEvent, ParentElement as _, RenderOnce, ScrollHandle, + SharedString, StatefulInteractiveElement as _, Styled as _, WeakEntity, Window, div, + linear_color_stop, linear_gradient, px, transparent_black, }; use super::{ @@ -18,12 +19,19 @@ use crate::{ background::MoonBackgroundPolicy, button::{MoonButton, MoonButtonSize, MoonButtonVariant}, h_flex, + skin::{MoonFacetKind, facet, fading_rule_h}, text::MoonText, theme::{MoonTheme, MoonThemeTokens}, tokens::{MoonPalette, rgba_from}, }, }; +/// Alpha of the `overlay` ink at the top of a faceted header's sheen; it fades to nothing at the +/// header's bottom edge. +const FACET_HEADER_SHEEN_ALPHA: f32 = 0.05; +/// Alpha of the `overlay` ink at the middle of the fading rule under a faceted header. +const FACET_HEADER_RULE_ALPHA: f32 = 0.11; + /// Height of a dock tab header at the active token scale. /// /// Args: @@ -35,6 +43,135 @@ pub(super) fn tab_header_height(tokens: &MoonThemeTokens) -> f32 { tokens.fit_band(tokens.tier_band_base(29.0, |m| m.line_height), 13.0) } +/// Where a tab panel sits, which decides how much of a surface it paints under the Facet skin. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum PanelPlacement { + /// On its own, outside any dock: the whole slab, the shadows it casts included. + Standalone, + /// In a slot of a dock, beside other panels: the slab without the shadows that fall outside + /// it. A neighbouring slot may hold a see-through panel, and a shadow cast over that slot + /// would darken the canvas it shows. + Docked, + /// Inside a floating tile, which draws the surface around the panel. + Floating, +} + +/// What a tab panel paints under the Facet skin, in place of its flat fills and solid borders. +/// +/// A panel whose body is opaque is one slab: the root takes the slab fill and edge, the header +/// adds a sheen and the body paints nothing, so the slab shows through it. +/// +/// A panel whose body is see-through — neither the root nor the content paints a fill, which is +/// how a chart host lets the GPU canvas under the scene show — keeps that body untouched. Only +/// its header takes the slab fill, and the root gets no shadow that falls outside itself: GPUI +/// paints such a shadow under the whole element, so it would darken the canvas through the +/// unfilled body. +/// +/// A floating tile draws the surface itself, around the panel, so the panel inside paints +/// neither a fill nor an edge of its own. +#[derive(Clone, Debug, PartialEq)] +pub(super) struct PanelFacet { + /// Corner radius of the panel's edge, in rendered pixels. + pub(super) radius: f32, + /// Fill of the panel root; `None` leaves the root unpainted. + pub(super) root_fill: Option, + /// Colour of the root's one-pixel edge; transparent when the host tile draws the edge. + pub(super) edge: Hsla, + /// Shadows of the panel root. + pub(super) root_shadows: Vec, + /// Background of the header; `None` leaves the header unpainted. + pub(super) header_fill: Option, + /// A layer painted over `header_fill`: the sheen of a header that spends its own background + /// on the surface fill. + pub(super) header_overlay: Option, + /// Radius of the header's top corners, which keeps its paint inside the rounded edge. + pub(super) header_radius: f32, + /// Colour at the middle of the fading rule under the header. + pub(super) header_rule: Hsla, + /// Whether the body keeps the fill its background policy asks for. + pub(super) body_keeps_policy_fill: bool, +} + +impl PanelFacet { + /// Resolve the faceted look of a tab panel. + /// + /// Args: + /// tokens: Active theme tokens; supply the skin, the palette and the UI scale. + /// placement: Where the panel sits: on its own, in a dock slot or in a floating tile. + /// root: Background policy of the panel root. + /// header: Background policy of the header. + /// content: Background policy of the body, as resolved for the active panel. + /// + /// Returns: + /// The faceted look, or `None` under the Flat skin, where the panel renders as it + /// always has. + pub(super) fn resolve( + tokens: &MoonThemeTokens, + placement: PanelPlacement, + root: MoonBackgroundPolicy, + header: MoonBackgroundPolicy, + content: MoonBackgroundPolicy, + ) -> Option { + if !tokens.skin.is_facet() { + return None; + } + let p = tokens.palette; + let floating = placement == PanelPlacement::Floating; + let surface = facet( + if floating { + MoonFacetKind::Popup + } else { + MoonFacetKind::Slab + }, + tokens, + ); + let opaque = |policy| matches!(policy, MoonBackgroundPolicy::Opaque); + // `Transparent` paints a fill of zero alpha, so it shows what lies behind exactly as + // `NoFill` does. + let see_through = !opaque(root) && !opaque(content); + let sheen = linear_gradient( + 180.0, + linear_color_stop(rgba_from(p.overlay, FACET_HEADER_SHEEN_ALPHA), 0.0), + linear_color_stop(rgba_from(p.overlay, 0.0), 1.0), + ); + let radius = tokens.ui(tokens.metrics.container_radius); + + let (header_fill, header_overlay) = if !see_through { + (Some(sheen), None) + } else if opaque(header) { + (Some(surface.fill), Some(sheen)) + } else { + (None, None) + }; + let (root_fill, edge, root_shadows) = if floating { + (None, transparent_black(), Vec::new()) + } else { + // Only a panel on its own, with an opaque body, keeps the shadows it casts. + let casts = placement == PanelPlacement::Standalone && !see_through; + let shadows = surface.shadows.into_iter(); + ( + (!see_through).then_some(surface.fill), + surface.edge, + shadows.filter(|shadow| casts || shadow.inset).collect(), + ) + }; + + Some(Self { + radius, + root_fill, + edge, + root_shadows, + header_fill, + header_overlay, + // The header starts inside the root's one-pixel border, and a floating tile adds + // its own border outside that. + header_radius: (radius - if floating { 2.0 } else { 1.0 }).max(0.0), + header_rule: rgba_from(p.overlay, FACET_HEADER_RULE_ALPHA), + body_keeps_policy_fill: see_through, + }) + } +} + /// Glyph and matching host tooltip for the dock zoom control. /// /// One function owns both so a restore label cannot sit on the maximize glyph. @@ -59,6 +196,9 @@ pub(super) fn zoom_control( impl RenderOnce for TabPanel { /// Render the tab group and synchronize edge-triggered panel activation notifications. + /// + /// Under the Flat skin the group is a bordered box with a filled header and a filled body. + /// Under the Facet skin it is drawn as [`PanelFacet`] resolves it. fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let p = MoonPalette::active(cx); let tokens = MoonTheme::active_tokens(cx); @@ -128,6 +268,30 @@ impl RenderOnce for TabPanel { .border(px(1.0)) .border_color(rgba_from(p.border, 1.0)); root = self.background_policy.apply(root, p.shell_high, 0.98); + // `None` under the Flat skin: every faceted override below is skipped and the panel + // renders exactly what the lines around them describe. + let panel_facet = PanelFacet::resolve( + &tokens, + if self.floating { + PanelPlacement::Floating + } else if self.dock_area.is_some() { + PanelPlacement::Docked + } else { + PanelPlacement::Standalone + }, + self.background_policy, + self.header_background_policy, + content_policy, + ); + if let Some(panel_facet) = &panel_facet { + root = root + .rounded(px(panel_facet.radius)) + .border_color(panel_facet.edge) + .shadow(panel_facet.root_shadows.clone()); + if let Some(fill) = panel_facet.root_fill { + root = root.bg(fill); + } + } if self.show_header { let mut header = div() @@ -144,6 +308,42 @@ impl RenderOnce for TabPanel { .border_b(px(1.0)) .border_color(rgba_from(p.border, 1.0)); header = self.header_background_policy.apply(header, p.panel, 1.0); + if let Some(panel_facet) = &panel_facet { + // The bottom border keeps its pixel of height, so the tabs sit where they do + // under the Flat skin; it only stops painting, and the fading rule takes the row + // it occupied. An absolute child is placed inside its parent's border, hence the + // negative inset. + let header_radius = px(panel_facet.header_radius); + header = header + .border_color(transparent_black()) + .rounded_t(header_radius); + if let Some(fill) = panel_facet.header_fill { + header = header.bg(fill); + } + // Both layers go in ahead of the tabs, so a tab's hover fill and the active + // underline paint over them. + if let Some(overlay) = panel_facet.header_overlay { + header = header.child( + div() + .absolute() + .top_0() + .right_0() + .bottom_0() + .left_0() + .rounded_t(header_radius) + .bg(overlay), + ); + } + header = header.child( + div() + .absolute() + .left_0() + .right_0() + .bottom(px(-1.0)) + .h(px(1.0)) + .child(fading_rule_h(panel_facet.header_rule)), + ); + } let tabs_scroll_handle = window .use_keyed_state( @@ -549,11 +749,20 @@ impl RenderOnce for TabPanel { .w_full() .min_h(px(0.0)) .overflow_hidden(); - content = content_policy.apply(content, p.shell, 1.0); + // A faceted slab shows through its body, so the body drops its own fill there. A + // see-through body is left to its policy, which paints nothing over the canvas behind. + let body_keeps_policy_fill = panel_facet + .as_ref() + .is_none_or(|panel_facet| panel_facet.body_keeps_policy_fill); + if body_keeps_policy_fill { + content = content_policy.apply(content, p.shell, 1.0); + } if let Some(panel) = active_panel { let mut panel_host = div().absolute().top_0().right_0().bottom_0().left_0(); - panel_host = content_policy.apply(panel_host, p.shell, 1.0); + if body_keeps_policy_fill { + panel_host = content_policy.apply(panel_host, p.shell, 1.0); + } content = content.child(panel_host.child(panel.render_panel(window, cx))); } diff --git a/crates/moon-ui-components/src/moon/dock/tests.rs b/crates/moon-ui-components/src/moon/dock/tests.rs index bf964da..3e6e223 100644 --- a/crates/moon-ui-components/src/moon/dock/tests.rs +++ b/crates/moon-ui-components/src/moon/dock/tests.rs @@ -1559,3 +1559,576 @@ fn split_drop_header_inset_clears_the_tab_header() { ); assert_eq!(DockArea::split_drop_header_inset(false, &tokens), 0.0); } + +// ---- Theme skin: what a panel, a tile and the gap between panels paint ---- + +use super::{ + TileFacet, + tab_panel::{PanelFacet, PanelPlacement}, +}; +use crate::{ + ElementExt as _, Root, + moon::{MoonFacetKind, MoonPalette, MoonSkin, MoonTheme, MoonThemeConfig, facet, rgba_from}, +}; +use gpui::{ + AnyWindowHandle, AtlasKey, AtlasTile, Background, BoxShadow, DevicePixels, HeadlessAppContext, + NoopTextSystem, Pixels, PlatformAtlas, PlatformHeadlessRenderer, Point, Quad, ScaledPixels, + Scene, Shadow, Size, linear_color_stop, linear_gradient, transparent_black, +}; +use std::{borrow::Cow, sync::Arc}; + +const ALL_POLICIES: [MoonBackgroundPolicy; 3] = [ + MoonBackgroundPolicy::Opaque, + MoonBackgroundPolicy::Transparent, + MoonBackgroundPolicy::NoFill, +]; + +const ALL_PLACEMENTS: [PanelPlacement; 3] = [ + PanelPlacement::Standalone, + PanelPlacement::Docked, + PanelPlacement::Floating, +]; + +/// The highlight a slab carries along its top edge on a dark palette, written out rather than +/// taken from `skin.rs`: one pixel down, no blur, inside the slab. +fn slab_top_highlight(p: MoonPalette) -> BoxShadow { + BoxShadow { + color: rgba_from(p.overlay, 0.12), + offset: point(px(0.0), px(1.0)), + blur_radius: px(0.0), + spread_radius: px(0.0), + inset: true, + } +} + +/// Theme tokens for `palette` drawn with `skin`, at the default scale. +fn skin_tokens(palette: MoonPalette, skin: MoonSkin) -> MoonThemeTokens { + MoonThemeTokens { + palette, + skin, + ..MoonThemeTokens::default() + } +} + +/// The sheen a faceted header lays over its surface, written out rather than taken from +/// `tab_panel.rs`. +fn header_sheen(p: MoonPalette) -> Background { + linear_gradient( + 180.0, + linear_color_stop(rgba_from(p.overlay, 0.05), 0.0), + linear_color_stop(rgba_from(p.overlay, 0.0), 1.0), + ) +} + +/// Catches `tab_panel.rs:PanelFacet::resolve` or `dock.rs:TileFacet::resolve` ignoring the skin: +/// every flat theme would get gradient panels, rounded corners and shadows it never asked for. +#[test] +fn the_flat_skin_resolves_no_panel_or_tile_facet() { + for palette in [ + MoonPalette::TERMINAL, + MoonPalette::LIGHT, + MoonPalette::GRAPHITE, + MoonPalette::OBSIDIAN, + ] { + let tokens = skin_tokens(palette, MoonSkin::Flat); + for root in ALL_POLICIES { + for content in ALL_POLICIES { + for placement in ALL_PLACEMENTS { + assert_eq!( + PanelFacet::resolve( + &tokens, + placement, + root, + MoonBackgroundPolicy::Opaque, + content + ), + None + ); + } + } + assert_eq!(TileFacet::resolve(&tokens, root), None); + } + } +} + +/// Catches `tab_panel.rs:PanelFacet::resolve` keying the slab on the root policy alone. A host +/// that leaves the dock unfilled so a chart can show — every panel root is `NoFill` there — +/// would get no slab on its ordinary, opaque panels, which is the whole look of the skin. +/// +/// Also catches a docked slab keeping the shadows it casts: the slot beside it may hold a +/// see-through panel, and a shadow cast over that slot would darken the chart it shows. +#[test] +fn a_panel_with_an_opaque_body_is_one_slab_and_casts_a_shadow_only_on_its_own() { + let p = MoonPalette::OBSIDIAN; + let tokens = skin_tokens(p, MoonSkin::Facet); + let slab = facet(MoonFacetKind::Slab, &tokens); + + for (root, content) in [ + (MoonBackgroundPolicy::NoFill, MoonBackgroundPolicy::Opaque), + (MoonBackgroundPolicy::Opaque, MoonBackgroundPolicy::Opaque), + (MoonBackgroundPolicy::Opaque, MoonBackgroundPolicy::NoFill), + ] { + let alone = PanelFacet::resolve( + &tokens, + PanelPlacement::Standalone, + root, + MoonBackgroundPolicy::Opaque, + content, + ) + .expect("the Facet skin resolves a panel facet"); + assert_eq!(alone.root_fill, Some(slab.fill), "{root:?}/{content:?}"); + assert_eq!( + alone.root_shadows, slab.shadows, + "a panel on its own keeps the whole slab" + ); + + let panel = PanelFacet::resolve( + &tokens, + PanelPlacement::Docked, + root, + MoonBackgroundPolicy::Opaque, + content, + ) + .expect("the Facet skin resolves a panel facet"); + + assert_eq!(panel.root_fill, Some(slab.fill), "{root:?}/{content:?}"); + assert_eq!(panel.edge, slab.edge); + assert_eq!( + panel.root_shadows, + vec![slab_top_highlight(p)], + "a docked slab keeps its top highlight and casts nothing outside itself" + ); + assert_eq!(panel.radius, 8.0, "the container radius of the theme"); + assert_eq!( + panel.header_fill, + Some(header_sheen(p)), + "the header is a sheen over the slab, not the flat `panel` fill" + ); + assert_eq!(panel.header_overlay, None); + assert_eq!( + panel.header_radius, 7.0, + "the header starts inside the one-pixel edge" + ); + assert_eq!(panel.header_rule, rgba_from(p.overlay, 0.11)); + assert!( + !panel.body_keeps_policy_fill, + "the body paints nothing, so the slab shows through it" + ); + } +} + +/// Catches `tab_panel.rs:PanelFacet::resolve` giving a see-through panel the slab fill or the +/// slab's outer shadows. GPUI paints a shadow under the whole of its element, so either one +/// would cover the GPU chart canvas that such a panel exists to show. +#[test] +fn a_see_through_panel_takes_the_slab_on_its_header_only() { + let p = MoonPalette::OBSIDIAN; + let tokens = skin_tokens(p, MoonSkin::Facet); + let slab = facet(MoonFacetKind::Slab, &tokens); + + for policy in [ + MoonBackgroundPolicy::NoFill, + MoonBackgroundPolicy::Transparent, + ] { + for placement in [PanelPlacement::Docked, PanelPlacement::Standalone] { + let panel = PanelFacet::resolve( + &tokens, + placement, + policy, + MoonBackgroundPolicy::Opaque, + policy, + ) + .expect("the Facet skin resolves a panel facet"); + + assert_eq!( + panel.root_fill, None, + "{placement:?} {policy:?} body must stay unpainted" + ); + assert!( + panel.body_keeps_policy_fill, + "the body is left to its policy, which paints nothing" + ); + assert_eq!( + panel.root_shadows, + vec![slab_top_highlight(p)], + "only the inset highlight: no shadow that falls outside the panel" + ); + assert_eq!(panel.edge, slab.edge); + assert_eq!( + panel.header_fill, + Some(slab.fill), + "the header alone carries the slab" + ); + assert_eq!(panel.header_overlay, Some(header_sheen(p))); + } + + let bare_header = PanelFacet::resolve( + &tokens, + PanelPlacement::Docked, + policy, + MoonBackgroundPolicy::NoFill, + policy, + ) + .expect("the Facet skin resolves a panel facet"); + assert_eq!( + (bare_header.header_fill, bare_header.header_overlay), + (None, None), + "a header that asked for no fill gets none" + ); + } +} + +/// Catches `dock.rs:TileFacet::resolve` and `tab_panel.rs:PanelFacet::resolve` both painting a +/// surface for a floating tile — a slab inside a popup — or neither, and catches a see-through +/// tile taking the popup's fill or its outer shadows over the canvas behind it. +#[test] +fn a_floating_tile_is_a_popup_and_the_panel_inside_paints_no_surface() { + let p = MoonPalette::OBSIDIAN; + let tokens = skin_tokens(p, MoonSkin::Facet); + let popup = facet(MoonFacetKind::Popup, &tokens); + + let tile = TileFacet::resolve(&tokens, MoonBackgroundPolicy::Opaque) + .expect("the Facet skin resolves a tile facet"); + assert_eq!(tile.fill, Some(popup.fill)); + assert_eq!(tile.edge, popup.edge); + assert_eq!(tile.shadows, popup.shadows); + assert_eq!(tile.radius, 8.0); + + let inside = PanelFacet::resolve( + &tokens, + PanelPlacement::Floating, + MoonBackgroundPolicy::NoFill, + MoonBackgroundPolicy::Opaque, + MoonBackgroundPolicy::Opaque, + ) + .expect("the Facet skin resolves a panel facet"); + assert_eq!(inside.root_fill, None, "the tile carries the fill"); + assert_eq!( + inside.edge, + transparent_black(), + "the tile carries the edge" + ); + assert_eq!(inside.root_shadows, Vec::new()); + assert_eq!(inside.header_fill, Some(header_sheen(p))); + assert_eq!( + inside.header_radius, 6.0, + "inside the tile's edge and the panel's own unpainted one" + ); + assert!(!inside.body_keeps_policy_fill); + + for policy in [ + MoonBackgroundPolicy::NoFill, + MoonBackgroundPolicy::Transparent, + ] { + let tile = TileFacet::resolve(&tokens, policy).expect("the Facet skin resolves a tile"); + assert_eq!(tile.fill, None, "{policy:?} tile must stay unpainted"); + assert_eq!(tile.edge, popup.edge); + assert!( + !tile.shadows.is_empty() && tile.shadows.iter().all(|shadow| shadow.inset), + "only the inset highlight: {:?}", + tile.shadows + ); + + let inside = PanelFacet::resolve( + &tokens, + PanelPlacement::Floating, + MoonBackgroundPolicy::NoFill, + MoonBackgroundPolicy::Opaque, + policy, + ) + .expect("the Facet skin resolves a panel facet"); + assert_eq!(inside.root_fill, None); + assert_eq!( + inside.header_fill, + Some(popup.fill), + "the header of a see-through tile carries the popup surface" + ); + assert!(inside.body_keeps_policy_fill); + } +} + +/// The quads and shadows of the last rendered scene. +#[derive(Default)] +struct RecordedScene { + quads: Vec, + shadows: Vec, +} + +impl RecordedScene { + /// Return the fills painted at `probe`, bottom to top: every quad that covers the point + /// with a background that is not fully transparent. + fn fills_at(&self, probe: Point) -> Vec { + self.quads + .iter() + .filter(|quad| { + !quad.background.is_transparent() + && quad.bounds.contains(&probe) + && quad.content_mask.bounds.contains(&probe) + }) + .map(|quad| quad.background) + .collect() + } + + /// Return the shadows that can reach `probe`. + /// + /// A shadow outside its element reaches three blur radii past its own rectangle and is + /// painted under the whole of it. A shadow inside its element covers the element except the + /// hole, which is that rectangle shrunk by the same margin. + fn shadows_at(&self, probe: Point) -> Vec<&Shadow> { + self.shadows + .iter() + .filter(|shadow| { + let reach = 3.0 * shadow.blur_radius.0; + shadow.content_mask.bounds.contains(&probe) + && if shadow.inset == 0 { + shadow.bounds.dilate(ScaledPixels(reach)).contains(&probe) + } else { + shadow.element_bounds.contains(&probe) + && !shadow.bounds.dilate(ScaledPixels(-reach)).contains(&probe) + } + }) + .collect() + } +} + +/// Records the primitives a headless window submits, without rasterizing them. +struct SkinSceneRecorder(Rc>); + +impl PlatformHeadlessRenderer for SkinSceneRecorder { + /// Record the scene; image output is intentionally unsupported. + fn render_scene_to_image( + &mut self, + scene: &Scene, + size: Size, + ) -> anyhow::Result { + self.render_scene(scene, size)?; + anyhow::bail!("scene recorder does not rasterize images") + } + + /// Replace the preceding frame, so assertions inspect the final submission only. + fn render_scene(&mut self, scene: &Scene, _size: Size) -> anyhow::Result<()> { + self.0.replace(RecordedScene { + quads: scene.quads.clone(), + shadows: scene.shadows.clone(), + }); + Ok(()) + } + + /// Fills and shadows do not depend on glyph or icon textures. + fn sprite_atlas(&self) -> Arc { + Arc::new(NoSpriteAtlas) + } +} + +/// Omits sprites from the fill-and-shadow scene probe. +struct NoSpriteAtlas; + +impl PlatformAtlas for NoSpriteAtlas { + /// Decline every sprite: the probe reads quads and shadows only. + fn get_or_insert_with<'a>( + &self, + _key: &AtlasKey, + _build: &mut dyn FnMut() -> anyhow::Result, Cow<'a, [u8]>)>>, + ) -> anyhow::Result> { + Ok(None) + } + + /// Nothing was stored, so there is nothing to remove. + fn remove(&self, _key: &AtlasKey) {} +} + +/// Build a headed panel that reports where its body was painted. +fn probed_panel( + name: &'static str, + policy: MoonBackgroundPolicy, + body: Rc>>, +) -> Rc { + Rc::new( + MoonDockPanel::new(name, name, move |_, _| { + let body = body.clone(); + div() + .size_full() + .on_prepaint(move |bounds, _, _| body.set(bounds)) + .into_any_element() + }) + .background_policy(policy) + .show_dock_header(true), + ) +} + +/// Draw `frames` frames, so prepaint captures settle before the scene is read. +fn pump_frames(cx: &mut HeadlessAppContext, window: AnyWindowHandle, frames: usize) { + for _ in 0..frames { + cx.update_window(window, |_, window, cx| { + window.refresh(); + window.draw(cx).clear(); + }) + .expect("headless frame must draw"); + cx.run_until_parked(); + } +} + +/// Catches `tab_panel.rs:TabPanel::render` or `dock.rs:DockArea::render_item` painting over the +/// body of a see-through panel under the Facet skin — a slab fill, a body fill or a shadow — +/// which would hide the GPU chart canvas a chart host shows through it. Also catches the Flat +/// skin losing a fill it has always painted: the panel body, the header or the strip between +/// two panels. +/// +/// The dock is set up as a chart host sets it up: the window root, the dock and every panel +/// root are `NoFill`, and only a panel's own policy decides whether its body is painted. +#[test] +fn a_see_through_panel_body_stays_unpainted_and_the_gap_opens_only_under_the_facet_skin() { + for (name, config) in [ + ("flat", MoonThemeConfig::moon_terminal()), + ("facet", MoonThemeConfig::moon_obsidian()), + ] { + let scene = Rc::new(RefCell::new(RecordedScene::default())); + let recorded = scene.clone(); + let mut cx = HeadlessAppContext::with_platform( + Arc::new(NoopTextSystem::new()), + Arc::new(()), + move || Some(Box::new(SkinSceneRecorder(recorded.clone()))), + ); + cx.update(crate::init); + cx.update(|cx| MoonTheme::install_config(config.clone(), cx)); + let tokens = cx.update(|cx| MoonTheme::active_tokens(cx)); + let p = tokens.palette; + let faceted = tokens.skin.is_facet(); + assert_eq!(faceted, name == "facet", "{name}: the theme under test"); + + let opaque_body = Rc::new(Cell::new(Bounds::default())); + let chart_body = Rc::new(Cell::new(Bounds::default())); + let window = cx + .open_window(size(px(800.0), px(600.0)), { + let opaque_body = opaque_body.clone(); + let chart_body = chart_body.clone(); + move |window, cx| { + let dock = cx.new(|cx| { + DockArea::new("skin-dock", None, window, cx) + .background_policy(MoonBackgroundPolicy::NoFill) + .tab_background_policy(MoonBackgroundPolicy::NoFill) + }); + dock.update(cx, |dock, cx| { + dock.set_center( + DockItem::Split { + horizontal: true, + items: vec![ + DockItem::Panel(probed_panel( + "orders", + MoonBackgroundPolicy::Opaque, + opaque_body, + )), + DockItem::Panel(probed_panel( + "chart", + MoonBackgroundPolicy::NoFill, + chart_body, + )), + ], + sizes: vec![Some(300.0), None], + }, + window, + cx, + ); + }); + let view = cx.new(|_| DockEntityHarness { dock }); + cx.new(|cx| { + Root::new(view, window, cx) + .bordered(false) + .background_policy(MoonBackgroundPolicy::NoFill) + }) + } + }) + .expect("headless window must open"); + + pump_frames(&mut cx, window.into(), 4); + let scale = cx + .update_window(window.into(), |_, window, _| window.scale_factor()) + .expect("headless window must report its scale"); + // The recorder only fires on a render pass; a plain draw never reaches it. + let _ = cx.capture_screenshot(window.into()); + let scene = scene.borrow(); + + let opaque = opaque_body.get(); + let chart = chart_body.get(); + assert!( + opaque.size.width > px(100.0) && chart.size.width > px(100.0), + "{name}: both panel bodies must have been painted: {opaque:?} {chart:?}" + ); + assert!( + chart.left() > opaque.right(), + "{name}: the chart sits right of the gap" + ); + let header_height = px(tab_header_height(&tokens)); + let chart_center = chart.center().scale(scale); + let chart_header = point(chart.center().x, chart.top() - header_height / 2.0).scale(scale); + let opaque_center = opaque.center().scale(scale); + let gap = point((opaque.right() + chart.left()) / 2.0, opaque.center().y).scale(scale); + + assert_eq!( + scene.fills_at(chart_center), + Vec::new(), + "{name}: nothing may be filled over the body of a see-through panel" + ); + let over_chart = scene.shadows_at(chart_center); + assert!( + over_chart.is_empty(), + "{name}: no shadow may fall over the body of a see-through panel: {over_chart:?}" + ); + + let slab = facet(MoonFacetKind::Slab, &tokens); + let flat = |color: u32, alpha: f32| Background::from(rgba_from(color, alpha)); + // Shadows cast by something the size of a panel, as (inside, outside) counts. The glow + // under an active tab is a shadow too, under either skin, and is not one of these. + let panel_side = 100.0 * scale; + let panel_shadows = scene.shadows.iter().filter(|shadow| { + shadow.element_bounds.size.width.0 > panel_side + && shadow.element_bounds.size.height.0 > panel_side + }); + let (inside, outside): (Vec<&Shadow>, Vec<&Shadow>) = + panel_shadows.partition(|shadow| shadow.inset != 0); + if faceted { + assert_eq!( + (inside.len(), outside.len()), + (2, 0), + "{name}: each panel is lit along its top, and a docked panel casts no shadow" + ); + assert_eq!( + scene.fills_at(opaque_center), + vec![slab.fill], + "{name}: an opaque panel is one slab, and its body adds no fill over it" + ); + assert_eq!( + scene.fills_at(chart_header).first(), + Some(&slab.fill), + "{name}: the header of a see-through panel carries the slab" + ); + assert_eq!( + scene.fills_at(gap), + Vec::new(), + "{name}: the gap between two slabs shows what lies behind the dock" + ); + } else { + let over_opaque = scene.fills_at(opaque_center); + assert_eq!( + over_opaque.last(), + Some(&flat(p.shell, 1.0)), + "{name}: the body paints `shell`: {over_opaque:?}" + ); + assert_eq!( + scene.fills_at(chart_header), + vec![flat(p.panel, 1.0)], + "{name}: the header paints `panel`" + ); + assert_eq!( + scene.fills_at(gap).first(), + Some(&flat(p.shell, 1.0)), + "{name}: the strip between two panels paints `shell`" + ); + assert_eq!( + (inside.len(), outside.len()), + (0, 0), + "{name}: a flat panel casts no shadow" + ); + } + } +} diff --git a/crates/moon-ui-components/src/moon/dropdown/popup.rs b/crates/moon-ui-components/src/moon/dropdown/popup.rs index 77ab92a..682d1f7 100644 --- a/crates/moon-ui-components/src/moon/dropdown/popup.rs +++ b/crates/moon-ui-components/src/moon/dropdown/popup.rs @@ -1,5 +1,6 @@ //! Popup-menu rendering, virtualization state, and nested-menu composition. +use super::super::skin::{MoonFacet, MoonFacetKind, facet}; use super::super::theme::MoonTextMetrics; use super::*; @@ -104,6 +105,24 @@ fn menu_trailing_label( .into_any_element() } +/// Design-reference corner radius of a menu under the Facet skin. +const FACET_MENU_RADIUS: f32 = 8.0; + +/// Resolve the surface a menu level paints under the Facet skin. +/// +/// Args: +/// tokens: Tokens the menu renders with; supply the skin, the palette and the UI scale. +/// +/// Returns: +/// The popup surface, or `None` under the Flat skin, where the menu keeps its solid fill, +/// its palette border and its single soft shadow. +fn menu_facet(tokens: &MoonThemeTokens) -> Option { + tokens + .skin + .is_facet() + .then(|| facet(MoonFacetKind::Popup, tokens)) +} + /// Reset and return the last palette shell observed by a probe submenu row. /// /// Returns: @@ -544,11 +563,22 @@ impl MoonPopupMenu { .relative() .w(px(width)) .p(px(tokens.ui(MENU_PADDING))) - .rounded(px(tokens.ui(5.0))) - .border(px(1.0)) - .border_color(rgba_from(p.border, 1.0)) - .bg(rgba_from(p.shell_high, 0.98)) - .shadow(vec![shadow]) + // `None` is the Flat skin. A faceted menu is a popup surface: gradient fill, lit + // edge and the deeper shadow of something floating above the window. + .map(|this| match menu_facet(&tokens) { + Some(surface) => this + .rounded(px(tokens.ui(FACET_MENU_RADIUS))) + .border(px(1.0)) + .border_color(surface.edge) + .bg(surface.fill) + .shadow(surface.shadows), + None => this + .rounded(px(tokens.ui(5.0))) + .border(px(1.0)) + .border_color(rgba_from(p.border, 1.0)) + .bg(rgba_from(p.shell_high, 0.98)) + .shadow(vec![shadow]), + }) .occlude() .flex() .flex_col() diff --git a/crates/moon-ui-components/src/moon/dropdown/popup/tests.rs b/crates/moon-ui-components/src/moon/dropdown/popup/tests.rs index 92537d2..b84d9c9 100644 --- a/crates/moon-ui-components/src/moon/dropdown/popup/tests.rs +++ b/crates/moon-ui-components/src/moon/dropdown/popup/tests.rs @@ -199,3 +199,50 @@ fn submenu_flips_left_and_caps_height_at_viewport_edges(cx: &mut gpui::TestAppCo } } } + +// ---- Theme skin: the surface a menu level paints ---- + +use super::menu_facet; +use crate::moon::{MoonFacetKind, MoonPalette, MoonSkin, MoonThemeTokens, facet}; + +/// Theme tokens for `palette` drawn with `skin`, at the default scale. +fn skin_tokens(palette: MoonPalette, skin: MoonSkin) -> MoonThemeTokens { + MoonThemeTokens { + palette, + skin, + ..MoonThemeTokens::default() + } +} + +/// Catches `popup.rs:menu_facet` ignoring the skin: every flat theme's menus, dropdowns and +/// context menus would trade their solid fill and palette border for a gradient popup, which +/// `render_with_metrics` keeps only while this answers `None`. +#[test] +fn a_flat_menu_keeps_its_solid_surface() { + for palette in [ + MoonPalette::TERMINAL, + MoonPalette::LIGHT, + MoonPalette::GRAPHITE, + MoonPalette::OBSIDIAN, + ] { + assert_eq!(menu_facet(&skin_tokens(palette, MoonSkin::Flat)), None); + } +} + +/// Catches `popup.rs:menu_facet` resolving a surface that rests on a panel instead of one that +/// floats above the window: a faceted menu would lose the deep shadow that lifts it off the +/// panels it opens over. +#[test] +fn a_faceted_menu_is_a_popup_surface() { + let tokens = skin_tokens(MoonPalette::OBSIDIAN, MoonSkin::Facet); + + assert_eq!( + menu_facet(&tokens), + Some(facet(MoonFacetKind::Popup, &tokens)) + ); + assert_ne!( + menu_facet(&tokens), + Some(facet(MoonFacetKind::Card, &tokens)), + "a card is not a popup" + ); +} diff --git a/crates/moon-ui-components/src/moon/group_box.rs b/crates/moon-ui-components/src/moon/group_box.rs index f35be02..785fe46 100644 --- a/crates/moon-ui-components/src/moon/group_box.rs +++ b/crates/moon-ui-components/src/moon/group_box.rs @@ -2,11 +2,57 @@ use gpui::*; use super::{ background::MoonBackgroundPolicy, + skin::{MoonFacetKind, facet}, text::MoonText, - theme::MoonTheme, + theme::{MoonTheme, MoonThemeTokens}, tokens::{MoonRect, rgba_from}, }; +/// What a group box paints under the Facet skin. +#[derive(Clone, Debug, PartialEq)] +struct GroupBoxFacet { + /// Fill of the box; `None` leaves it as its background policy left it. + fill: Option, + /// Colour of the box's one-pixel edge. + edge: Hsla, + /// Shadows of the box. + shadows: Vec, +} + +/// Resolve the look of a group box under the Facet skin. +/// +/// A box that paints its own fill is a card resting on its panel. A box whose policy leaves it +/// see-through keeps the card's edge and the highlight along its top only — GPUI paints a +/// shadow that falls outside an element under the whole of it, so the card's shadow would show +/// through the box. +/// +/// Args: +/// tokens: Active theme tokens; supply the skin, the palette and the UI scale. +/// policy: Background policy of the box. +/// +/// Returns: +/// The faceted look, or `None` under the Flat skin, where the box keeps its flat fill and +/// its palette border. +fn group_box_facet( + tokens: &MoonThemeTokens, + policy: MoonBackgroundPolicy, +) -> Option { + if !tokens.skin.is_facet() { + return None; + } + let card = facet(MoonFacetKind::Card, tokens); + let opaque = matches!(policy, MoonBackgroundPolicy::Opaque); + Some(GroupBoxFacet { + fill: opaque.then_some(card.fill), + edge: card.edge, + shadows: card + .shadows + .into_iter() + .filter(|shadow| opaque || shadow.inset) + .collect(), + }) +} + #[derive(IntoElement)] pub struct MoonGroupBox { id: SharedString, @@ -69,6 +115,17 @@ impl ParentElement for MoonGroupBox { } impl RenderOnce for MoonGroupBox { + /// Render the bordered box, its optional title and its children. + /// + /// The box paints the flat Moon fill under the Flat skin and a card surface under the Facet + /// skin; see [`group_box_facet`]. + /// + /// Args: + /// _: Window the box renders in; unused. + /// cx: Application context used to resolve the active theme tokens. + /// + /// Returns: + /// The rendered group box. fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let tokens = MoonTheme::active_tokens(cx); let p = tokens.palette; @@ -83,6 +140,15 @@ impl RenderOnce for MoonGroupBox { .flex_col() .gap(px(tokens.ui(self.gap))); root = self.background_policy.apply(root, p.shell_high, 0.98); + // `None` under the Flat skin, which keeps the fill and the border set above. + if let Some(group_facet) = group_box_facet(&tokens, self.background_policy) { + root = root + .border_color(group_facet.edge) + .shadow(group_facet.shadows); + if let Some(fill) = group_facet.fill { + root = root.bg(fill); + } + } if let Some(bounds) = self.bounds { root = root @@ -109,3 +175,6 @@ impl RenderOnce for MoonGroupBox { root.children(self.children) } } + +#[cfg(test)] +mod tests; diff --git a/crates/moon-ui-components/src/moon/group_box/tests.rs b/crates/moon-ui-components/src/moon/group_box/tests.rs new file mode 100644 index 0000000..d82357d --- /dev/null +++ b/crates/moon-ui-components/src/moon/group_box/tests.rs @@ -0,0 +1,62 @@ +//! Guards what a group box paints under each theme skin. + +use super::group_box_facet; +use crate::moon::{ + MoonBackgroundPolicy, MoonFacetKind, MoonPalette, MoonSkin, MoonThemeTokens, facet, +}; + +/// Theme tokens for `palette` drawn with `skin`, at the default scale. +fn skin_tokens(palette: MoonPalette, skin: MoonSkin) -> MoonThemeTokens { + MoonThemeTokens { + palette, + skin, + ..MoonThemeTokens::default() + } +} + +/// Catches `group_box.rs:group_box_facet` ignoring the skin: every flat theme's group box would +/// trade its `shell_high` fill and palette border for a gradient card with a shadow. +#[test] +fn a_flat_group_box_keeps_its_fill_and_border() { + for palette in [MoonPalette::TERMINAL, MoonPalette::LIGHT] { + let tokens = skin_tokens(palette, MoonSkin::Flat); + for policy in [ + MoonBackgroundPolicy::Opaque, + MoonBackgroundPolicy::Transparent, + MoonBackgroundPolicy::NoFill, + ] { + assert_eq!(group_box_facet(&tokens, policy), None); + } + } +} + +/// Catches `group_box.rs:group_box_facet` filling a box that asked for no fill, or leaving the +/// card's outer shadow on it. GPUI paints a shadow under the whole of its element, so a frame +/// drawn inside a popup would smear a dark patch over the popup behind it. +#[test] +fn a_faceted_group_box_is_a_card_and_a_see_through_one_keeps_only_its_edge() { + let tokens = skin_tokens(MoonPalette::OBSIDIAN, MoonSkin::Facet); + let card = facet(MoonFacetKind::Card, &tokens); + + let filled = group_box_facet(&tokens, MoonBackgroundPolicy::Opaque) + .expect("the Facet skin resolves a group box facet"); + assert_eq!(filled.fill, Some(card.fill)); + assert_eq!(filled.edge, card.edge); + assert_eq!(filled.shadows, card.shadows); + + for policy in [ + MoonBackgroundPolicy::Transparent, + MoonBackgroundPolicy::NoFill, + ] { + let frame = + group_box_facet(&tokens, policy).expect("the Facet skin resolves a group box facet"); + assert_eq!(frame.fill, None, "{policy:?}"); + assert_eq!(frame.edge, card.edge); + assert_eq!( + frame.shadows, + vec![card.shadows[0].clone()], + "only the inset highlight along the top edge" + ); + assert!(frame.shadows[0].inset); + } +} diff --git a/crates/moon-ui-components/src/moon/input/tests.rs b/crates/moon-ui-components/src/moon/input/tests.rs index e01d3b0..48746cc 100644 --- a/crates/moon-ui-components/src/moon/input/tests.rs +++ b/crates/moon-ui-components/src/moon/input/tests.rs @@ -1,16 +1,246 @@ -//! Layout checks for custom input metrics, including the terminal's normal font delta. +//! Guards the frame a text input paints under each skin, and that the skin never changes the +//! input's size. use gpui::{ - AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _, - Render, Styled as _, TestAppContext, VisualTestContext, Window, div, px, + AppContext as _, BoxShadow, Context, Entity, InteractiveElement as _, IntoElement, + ParentElement as _, Render, Styled as _, TestAppContext, VisualTestContext, Window, div, point, + px, }; use super::{MoonInput, MoonInputSize, MoonInputState}; -use crate::{ - Root, - moon::{MoonTheme, MoonThemeConfig, ThemeMode}, - v_flex, +use crate::input::{InputFacet, input_style_for, trigger_facets}; +use crate::moon::{ + MoonFacetKind, MoonTheme, MoonThemeConfig, MoonThemeTokens, MoonTone, ThemeMode, facet, + rgba_from, }; +use crate::{Root, v_flex}; + +/// Catches `input.rs:InputFacet::resolve` ignoring the skin or the disabled flag: every flat +/// theme's inputs would be repainted as wells, or a disabled input on the faceted theme would +/// lose its flat dimmed frame. With no frame resolved, an input paints the flat fill, border and +/// hover it always had. +#[test] +fn flat_themes_and_disabled_inputs_resolve_no_frame() { + for tokens in [ + MoonThemeConfig::moon_terminal().dark, + MoonThemeConfig::moon_terminal().light, + MoonThemeConfig::moon_graphite().dark, + MoonThemeConfig::moon_obsidian().light, + MoonThemeTokens::default(), + ] { + let accent = MoonTone::Info.color(tokens.palette); + for selected in [false, true] { + for focused in [false, true] { + for disabled in [false, true] { + assert_eq!( + InputFacet::resolve(&tokens, accent, selected, focused, disabled), + None, + "a flat theme never resolves a frame" + ); + } + } + } + } + + let faceted = MoonThemeConfig::moon_obsidian().dark; + let accent = MoonTone::Info.color(faceted.palette); + for focused in [false, true] { + assert_eq!( + InputFacet::resolve(&faceted, accent, false, focused, true), + None, + "a disabled input keeps the flat frame" + ); + } +} + +/// Catches a faceted input drifting from its spec. At rest it is the skin's well, edged in the +/// highlight ink at 6%, and under the pointer the edge rises to that ink at 20% over the same +/// fill. A selected input keeps the flat input's tone edge, 78% at rest and 82% under the +/// pointer. In focus the edge is the tone at 92% — under the pointer too, and whether or not the +/// input is selected — and a glow in the tone at 28% joins the well's shadows: 8px of blur in the +/// design, so 4px at the renderer. A glow left on an unfocused input, or missing from a focused +/// one, misreports where typing will land. +#[test] +fn a_faceted_input_rests_in_a_well_and_glows_in_its_tone_in_focus() { + let tokens = MoonThemeConfig::moon_obsidian().dark; + let p = tokens.palette; + let well = facet(MoonFacetKind::Well, &tokens); + let tone = MoonTone::Danger.color(p); + assert_eq!(tone, p.red, "the Danger tone of a dark palette is its red"); + let edge = |alpha: f32| rgba_from(p.red, alpha); + + let resting = InputFacet::resolve(&tokens, tone, false, false, false).expect("faceted"); + assert_eq!(resting.fill, well.fill); + assert_eq!(resting.edge, rgba_from(p.overlay, 0.06)); + assert_eq!(resting.hover_edge, rgba_from(p.overlay, 0.2)); + assert_eq!(resting.shadows, well.shadows); + + let selected = InputFacet::resolve(&tokens, tone, true, false, false).expect("faceted"); + assert_eq!(selected.fill, well.fill); + assert_eq!( + (selected.edge, selected.hover_edge), + (edge(0.78), edge(0.82)) + ); + assert_eq!(selected.shadows, well.shadows); + + // The glow written out: centred on the input, no spread, the design's 8px blur halved into + // the renderer's blur radius. + let glow = BoxShadow { + color: edge(0.28), + offset: point(px(0.0), px(0.0)), + blur_radius: px(4.0), + spread_radius: px(0.0), + inset: false, + }; + let mut well_and_glow = well.shadows.clone(); + well_and_glow.push(glow); + for selected in [false, true] { + let focused = InputFacet::resolve(&tokens, tone, selected, true, false).expect("faceted"); + assert_eq!(focused.fill, well.fill); + assert_eq!((focused.edge, focused.hover_edge), (edge(0.92), edge(0.92))); + assert_eq!(focused.shadows, well_and_glow); + } +} + +/// Catches `input.rs:input_style_for` giving a faceted field the flat fill, or a flat theme the +/// sunk one. The date field is drawn by mirrored donor code that knows only this pair: with the +/// flat `shell_high` under the Facet skin it would stand out as a light box beside the wells +/// of the inputs around it, and with `gutter` under a flat theme every field would darken. +#[test] +fn an_input_like_field_takes_the_well_floor_only_under_the_facet_skin() { + let faceted = MoonThemeConfig::moon_obsidian().dark; + let p = faceted.palette; + assert_eq!( + input_style_for(false, &faceted), + (rgba_from(p.gutter, 1.0), rgba_from(p.text_soft, 1.0)) + ); + assert_eq!( + input_style_for(true, &faceted), + (rgba_from(p.panel, 0.55), rgba_from(p.text_muted, 0.45)) + ); + + for flat in [ + MoonThemeConfig::moon_terminal().dark, + MoonThemeConfig::moon_terminal().light, + MoonThemeConfig::moon_graphite().dark, + MoonThemeTokens::default(), + ] { + let p = flat.palette; + assert_eq!( + input_style_for(false, &flat), + (rgba_from(p.shell_high, 1.0), rgba_from(p.text_soft, 1.0)) + ); + assert_eq!( + input_style_for(true, &flat), + (rgba_from(p.panel, 0.55), rgba_from(p.text_muted, 0.45)) + ); + } +} + +/// Catches `input.rs:trigger_facets` ignoring the skin or the disabled flag, or handing a +/// button-styled trigger the input's well: the select beside a button would sink while the +/// button stands, and the one beside an input would stand while the input sinks. +#[test] +fn a_faceted_trigger_matches_the_control_it_is_styled_as() { + use crate::button::{ButtonVariant, ButtonVisualState, raised_pressed_facet}; + + for tokens in [ + MoonThemeConfig::moon_terminal().dark, + MoonThemeConfig::moon_terminal().light, + MoonThemeTokens::default(), + ] { + for variant in [None, Some(ButtonVariant::Panel), Some(ButtonVariant::Blue)] { + assert_eq!(trigger_facets(&tokens, variant, false, false), None); + } + } + + let tokens = MoonThemeConfig::moon_obsidian().dark; + let p = tokens.palette; + for variant in [None, Some(ButtonVariant::Panel), Some(ButtonVariant::Blue)] { + assert_eq!(trigger_facets(&tokens, variant, false, true), None); + } + + assert_eq!( + trigger_facets(&tokens, Some(ButtonVariant::Panel), false, false), + Some([ + facet(MoonFacetKind::Raised, &tokens), + facet(MoonFacetKind::RaisedHover, &tokens), + raised_pressed_facet(&tokens), + ]) + ); + + let well = facet(MoonFacetKind::Well, &tokens); + for variant in [ + ButtonVariant::Blue, + ButtonVariant::Amber, + ButtonVariant::Danger, + ] { + let surfaces = trigger_facets(&tokens, Some(variant), false, false).expect("tone surface"); + for (surface, state) in surfaces.into_iter().zip([ + ButtonVisualState::Normal, + ButtonVisualState::Hovered, + ButtonVisualState::Active, + ]) { + assert_eq!(Some(surface), variant.resolve_facet(&tokens, false, state)); + } + } + for variant in [ButtonVariant::Ghost, ButtonVariant::Bare] { + assert_eq!(trigger_facets(&tokens, Some(variant), false, false), None); + } + let [rest, hovered, pressed] = trigger_facets(&tokens, None, false, false).expect("faceted"); + assert_eq!(rest, well); + assert_eq!(hovered.fill, well.fill); + assert_eq!(hovered.edge, rgba_from(p.overlay, 0.2)); + assert_eq!(hovered.shadows, well.shadows); + assert_eq!(pressed, hovered); + + // In focus, or with its menu open, the field takes the input's focus frame in the Info tone. + let frame = + InputFacet::resolve(&tokens, MoonTone::Info.color(p), false, true, false).expect("faceted"); + let [rest, hovered, _] = trigger_facets(&tokens, None, true, false).expect("faceted"); + assert_eq!((rest.edge, hovered.edge), (frame.edge, frame.hover_edge)); + assert_eq!(rest.shadows, frame.shadows); + assert_ne!(rest.shadows, well.shadows); +} + +/// One normal-size input in a fixed-width column, inside a probe that measures it. +struct InputHarness; + +impl Render for InputHarness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div().w(px(180.)).child( + div() + .debug_selector(|| "input-probe".into()) + .child(MoonInput::new("field").normal().placeholder("Coin")), + ) + } +} + +/// Catches the faceted frame changing an input's size — a well that brings the design mock-up's +/// 22px height with it: under the faceted theme a normal input must measure the 180x28 it +/// measures under a flat one, so a form row never changes height with the theme. +#[gpui::test] +fn a_faceted_input_keeps_the_size_of_the_flat_one(cx: &mut TestAppContext) { + cx.update(crate::init); + for (config, faceted) in [ + (MoonThemeConfig::moon_terminal(), false), + (MoonThemeConfig::moon_obsidian(), true), + ] { + cx.update(|cx| MoonTheme::install_config(config, cx)); + cx.update(|cx| assert_eq!(MoonTheme::active_tokens(cx).skin.is_facet(), faceted)); + + let window = cx.add_window(|window, cx| { + let view = cx.new(|_| InputHarness); + crate::Root::new(view, window, cx).bordered(false) + }); + let mut cx = VisualTestContext::from_window(window.into(), cx); + cx.run_until_parked(); + + let probe = cx.debug_bounds("input-probe").expect("input must render"); + assert_eq!(probe.size.width, px(180.), "faceted={faceted}"); + assert_eq!(probe.size.height, px(28.), "faceted={faceted}"); + } +} /// The following sibling measures the input's real layout height rather than its builder fields. struct HeightHarness { diff --git a/crates/moon-ui-components/src/moon/kbd.rs b/crates/moon-ui-components/src/moon/kbd.rs index 64d2415..3b29d9a 100644 --- a/crates/moon-ui-components/src/moon/kbd.rs +++ b/crates/moon-ui-components/src/moon/kbd.rs @@ -1,9 +1,11 @@ //! Monospaced shortcut chips sized to the active density tier. +use gpui::prelude::FluentBuilder as _; use gpui::*; use super::{ foundation::MoonSize, + skin::{MoonFacet, MoonFacetKind, facet}, theme::{MoonTheme, MoonThemeTokens}, tokens::{MoonRect, rgba_from}, }; @@ -242,6 +244,19 @@ impl MoonKbd { }, } } + + /// Resolve the faceted surface the chip paints over its flat chrome. + /// + /// Args: + /// tokens: The theme side to resolve for; supplies the skin, the palette and the UI + /// scale. + /// + /// Returns: + /// The raised surface for a filled chip under the Facet skin. `None` under the Flat + /// skin, and for an outline chip, which has no fill to light and keeps its hairline. + fn raised_surface(&self, tokens: &MoonThemeTokens) -> Option { + (tokens.skin.is_facet() && !self.outline).then(|| facet(MoonFacetKind::Raised, tokens)) + } } impl From for MoonKbd { @@ -251,11 +266,13 @@ impl From for MoonKbd { } impl RenderOnce for MoonKbd { - /// Renders resolved text and chrome without double scaling. + /// Renders resolved text and chrome without double scaling, with the surface of + /// [`MoonKbd::raised_surface`] painted over the flat chrome where there is one. fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let tokens = MoonTheme::active_tokens(cx); let p = tokens.palette; let metrics = self.metrics(&tokens); + let raised = self.raised_surface(&tokens); let mut root = div() .relative() .h(px(metrics.height)) @@ -267,6 +284,11 @@ impl RenderOnce for MoonKbd { if self.outline { p.shell } else { p.panel }, if self.outline { 0.0 } else { 0.92 }, )) + .when_some(raised, |this, raised| { + this.bg(raised.fill) + .border_color(raised.edge) + .shadow(raised.shadows) + }) .flex() .items_center() .justify_center() diff --git a/crates/moon-ui-components/src/moon/kbd/tests.rs b/crates/moon-ui-components/src/moon/kbd/tests.rs index 743c4af..c3fa178 100644 --- a/crates/moon-ui-components/src/moon/kbd/tests.rs +++ b/crates/moon-ui-components/src/moon/kbd/tests.rs @@ -116,6 +116,95 @@ fn kbd_formats_keystrokes_like_longbridge() { } } +/// Catches `kbd.rs:MoonKbd::raised_surface` answering for the Flat skin, which would restyle +/// the shortcut chips of every existing theme, losing the raised surface of a filled chip on +/// the faceted theme, or lighting an outline chip, which has no fill. +#[test] +fn only_a_filled_chip_under_the_facet_skin_is_raised() { + use crate::moon::{MoonFacetKind, MoonPalette, MoonSkin, MoonThemeTokens, facet, rgba_from}; + use gpui::{linear_color_stop, linear_gradient}; + + for palette in [MoonPalette::OBSIDIAN, MoonPalette::LIGHT] { + let tokens = |skin: MoonSkin| MoonThemeTokens { + skin, + palette, + ..MoonThemeTokens::default() + }; + let faceted = tokens(MoonSkin::Facet); + + let raised = MoonKbd::new("Esc") + .raised_surface(&faceted) + .expect("a filled chip is raised under the Facet skin"); + assert_eq!( + raised.fill, + linear_gradient( + 180.0, + linear_color_stop(rgba_from(palette.panel_high, 1.0), 0.0), + linear_color_stop(rgba_from(palette.panel, 1.0), 1.0), + ), + "the chip is lit from above" + ); + assert!(!raised.shadows.is_empty()); + assert_eq!(raised, facet(MoonFacetKind::Raised, &faceted)); + + assert_eq!( + MoonKbd::new("Esc").outline(true).raised_surface(&faceted), + None, + "an outline chip keeps its hairline" + ); + for outline in [false, true] { + assert_eq!( + MoonKbd::new("Esc") + .outline(outline) + .raised_surface(&tokens(MoonSkin::Flat)), + None, + "a chip is raised under the Flat skin (outline={outline})" + ); + } + } +} + +/// Catches `kbd.rs:MoonKbd::render` painting anything new on a flat theme, not painting the +/// raised surface it resolves on the faceted one, or lighting an outline chip. The flat colours +/// are the flat design's own: the panel at 92% under a 72% border, and an unfilled chip with a +/// solid border for the outline. +#[test] +fn a_chip_paints_flat_on_a_flat_theme_and_raised_on_the_faceted_one() { + use super::super::button::scene_probe::{Painted, Pointer, painted}; + use crate::moon::{ + MoonFacetKind, MoonPalette, MoonSkin, MoonThemeConfig, MoonThemeTokens, facet, rgba_from, + }; + + let p = MoonPalette::OBSIDIAN; + let obsidian_with = |skin: MoonSkin| { + let mut config = MoonThemeConfig::moon_obsidian(); + config.dark.skin = skin; + config + }; + let chip = |skin: MoonSkin, outline: bool| { + painted(obsidian_with(skin), Pointer::Away, move || { + MoonKbd::new("Esc").outline(outline) + }) + }; + let flat_filled = Painted::flat(rgba_from(p.panel, 0.92), rgba_from(p.border, 0.72)); + let flat_outline = Painted::flat(rgba_from(p.shell, 0.0), rgba_from(p.border, 1.0)); + assert_eq!(flat_outline.fill, None, "an outline chip has no fill"); + + assert_eq!(chip(MoonSkin::Flat, false), flat_filled); + assert_eq!(chip(MoonSkin::Flat, true), flat_outline); + + let tokens = MoonThemeTokens { + skin: MoonSkin::Facet, + palette: p, + ..MoonThemeTokens::default() + }; + assert_eq!( + chip(MoonSkin::Facet, false), + Painted::facet(facet(MoonFacetKind::Raised, &tokens)) + ); + assert_eq!(chip(MoonSkin::Facet, true), flat_outline); +} + /// Catches dropping the modifier-only and Caps Lock labels from `kbd.rs:MoonKbd::format_keystroke`. /// /// A shortcut that is one modifier parses back under GPUI's own name for it, so without these the diff --git a/crates/moon-ui-components/src/moon/mod.rs b/crates/moon-ui-components/src/moon/mod.rs index 55b0e8c..d8ec646 100644 --- a/crates/moon-ui-components/src/moon/mod.rs +++ b/crates/moon-ui-components/src/moon/mod.rs @@ -58,6 +58,7 @@ mod setting; mod sheet; mod sidebar; mod skeleton; +mod skin; mod slider; mod spinner; mod stacked_bar; @@ -195,6 +196,10 @@ pub use sidebar::{ MoonSidebarItem, MoonSidebarMenu, MoonSidebarMenuItem, MoonSidebarToggleButton, }; pub use skeleton::MoonSkeleton; +pub use skin::{ + MoonFacet, MoonFacetKind, MoonFacetTint, MoonSkin, facet, facet_glow, facet_tinted, + fading_rule_h, fading_rule_v, moon_ambient_light, moon_backdrop, +}; pub use slider::{MoonSlider, MoonSliderEvent, MoonSliderState}; pub use spinner::{MoonSpinner, MoonSpinnerSize}; pub use stacked_bar::{MoonBarSegment, MoonStackedBar}; diff --git a/crates/moon-ui-components/src/moon/popover.rs b/crates/moon-ui-components/src/moon/popover.rs index 2a7d1b8..d1a9549 100644 --- a/crates/moon-ui-components/src/moon/popover.rs +++ b/crates/moon-ui-components/src/moon/popover.rs @@ -3,7 +3,8 @@ use gpui::{prelude::FluentBuilder as _, *}; use super::{ background::MoonBackgroundPolicy, - theme::MoonTheme, + skin::{MoonFacetKind, facet}, + theme::{MoonTheme, MoonThemeTokens}, tokens::{MoonPalette, MoonRect, rgba_from}, }; @@ -43,6 +44,54 @@ impl MoonPopoverWidth { } } +/// Design-reference corner radius of the Moon chrome under the Facet skin. +const FACET_POPOVER_RADIUS: f32 = 8.0; + +/// What the Moon chrome of a popup box paints under the Facet skin. +#[derive(Clone, Debug, PartialEq)] +struct PopoverFacet { + /// Corner radius of the box, in rendered pixels. + radius: f32, + /// Fill of the box; `None` leaves it as its background policy left it. + fill: Option, + /// Colour of the box's one-pixel edge. + edge: Hsla, + /// Shadows of the box. + shadows: Vec, +} + +/// Resolve the popup surface the Moon chrome paints under the Facet skin. +/// +/// A popup that paints its own fill is a popup surface: gradient fill, lit edge and the deep +/// shadow of something floating above the window. A popup whose policy leaves it see-through +/// keeps the edge and the highlight along its top only — GPUI paints a shadow that falls +/// outside an element under the whole of it, so the deep shadow would show through the box. +/// +/// Args: +/// tokens: Active theme tokens; supply the skin, the palette and the UI scale. +/// policy: Background policy of the popup box. +/// +/// Returns: +/// The faceted look, or `None` under the Flat skin, where the box keeps its flat fill, its +/// palette border and its single soft shadow. +fn popover_facet(tokens: &MoonThemeTokens, policy: MoonBackgroundPolicy) -> Option { + if !tokens.skin.is_facet() { + return None; + } + let popup = facet(MoonFacetKind::Popup, tokens); + let opaque = matches!(policy, MoonBackgroundPolicy::Opaque); + Some(PopoverFacet { + radius: tokens.ui(FACET_POPOVER_RADIUS), + fill: opaque.then_some(popup.fill), + edge: popup.edge, + shadows: popup + .shadows + .into_iter() + .filter(|shadow| opaque || shadow.inset) + .collect(), + }) +} + /// Which surface the popup box paints. #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub enum MoonPopoverChrome { @@ -361,6 +410,17 @@ impl RenderOnce for MoonPopover { if matches!(chrome, MoonPopoverChrome::Moon) { popup = self.background_policy.apply(popup, p.shell_high, 0.98); + // `None` is the Flat skin, and the Picker chrome is never faceted: it exists to + // match the popup a Longbridge picker draws next to it. + if let Some(surface) = popover_facet(&tokens, self.background_policy) { + popup = popup + .rounded(px(surface.radius)) + .border_color(surface.edge) + .shadow(surface.shadows); + if let Some(fill) = surface.fill { + popup = popup.bg(fill); + } + } } let mut popover = CorePopover::new(ElementId::from(self.id.clone())) diff --git a/crates/moon-ui-components/src/moon/popover/tests.rs b/crates/moon-ui-components/src/moon/popover/tests.rs index 1a1701d..9dd90b2 100644 --- a/crates/moon-ui-components/src/moon/popover/tests.rs +++ b/crates/moon-ui-components/src/moon/popover/tests.rs @@ -319,3 +319,68 @@ fn popover_hosted_tooltip_paints_above_its_popover() { ); } } + +// ---- Theme skin: the surface the Moon chrome paints ---- + +use super::popover_facet; +use crate::moon::{MoonBackgroundPolicy, MoonFacetKind, MoonSkin, facet}; + +/// Theme tokens for `palette` drawn with `skin`, with every length doubled. +fn doubled_skin_tokens(palette: MoonPalette, skin: MoonSkin) -> MoonThemeTokens { + let mut tokens = MoonThemeTokens { + palette, + skin, + ..MoonThemeTokens::default() + }; + tokens.scale.ui = 2.0; + tokens +} + +/// Catches `popover.rs:popover_facet` ignoring the skin: every flat theme's popover would trade +/// its `shell_high` fill, palette border and single soft shadow for a gradient popup. +#[test] +fn a_flat_popover_keeps_its_moon_chrome() { + for palette in [MoonPalette::TERMINAL, MoonPalette::LIGHT] { + let tokens = doubled_skin_tokens(palette, MoonSkin::Flat); + for policy in [ + MoonBackgroundPolicy::Opaque, + MoonBackgroundPolicy::Transparent, + MoonBackgroundPolicy::NoFill, + ] { + assert_eq!(popover_facet(&tokens, policy), None); + } + } +} + +/// Catches `popover.rs:popover_facet` leaving the radius unscaled, or filling a popup whose +/// policy asked for no fill and casting the popup's deep shadow through it. +#[test] +fn a_faceted_popover_is_a_popup_and_a_see_through_one_keeps_only_its_edge() { + let tokens = doubled_skin_tokens(MoonPalette::OBSIDIAN, MoonSkin::Facet); + let popup = facet(MoonFacetKind::Popup, &tokens); + + let filled = popover_facet(&tokens, MoonBackgroundPolicy::Opaque) + .expect("the Facet skin resolves a popover facet"); + assert_eq!(filled.fill, Some(popup.fill)); + assert_eq!(filled.edge, popup.edge); + assert_eq!(filled.shadows, popup.shadows); + assert_eq!( + filled.radius, 16.0, + "eight design pixels at a doubled scale" + ); + + for policy in [ + MoonBackgroundPolicy::Transparent, + MoonBackgroundPolicy::NoFill, + ] { + let bare = popover_facet(&tokens, policy).expect("the Facet skin resolves a popover facet"); + assert_eq!(bare.fill, None, "{policy:?}"); + assert_eq!(bare.edge, popup.edge); + assert_eq!( + bare.shadows, + vec![popup.shadows[0].clone()], + "only the inset highlight along the top edge" + ); + assert!(bare.shadows[0].inset); + } +} diff --git a/crates/moon-ui-components/src/moon/progress.rs b/crates/moon-ui-components/src/moon/progress.rs index 1c95cc0..15aba80 100644 --- a/crates/moon-ui-components/src/moon/progress.rs +++ b/crates/moon-ui-components/src/moon/progress.rs @@ -1,9 +1,11 @@ +use gpui::prelude::FluentBuilder as _; use gpui::*; use crate::progress::Progress; use super::{ - theme::MoonTheme, + skin::{MoonFacet, MoonFacetKind, facet, facet_glow}, + theme::{MoonTheme, MoonThemeTokens}, tokens::{MoonTone, rgba_from}, }; @@ -76,6 +78,8 @@ impl MoonProgress { } impl RenderOnce for MoonProgress { + /// Renders the bar: the base progress bar in the tone's colour under the Flat skin, and + /// [`ProgressFacet`]'s groove and lit fill under the Facet skin, at the same height. fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { let tokens = MoonTheme::active_tokens(cx); let p = tokens.palette; @@ -92,5 +96,76 @@ impl RenderOnce for MoonProgress { .color(rgba_from(color, 1.0)) .h(px(height)) .rounded(px(radius)) + .when_some(ProgressFacet::resolve(&tokens, color), |bar, facet| { + facet.apply(bar) + }) + .into_any_element() } } + +/// How far the top edge of a faceted bar's fill is mixed toward white. +const FACET_FILL_LIFT: f32 = 0.2; +/// Opacity of the glow a faceted bar's fill casts in its own colour. +const FACET_FILL_GLOW_ALPHA: f32 = 0.4; +/// Blur of that glow as the design gives it, in design-reference pixels. +const FACET_FILL_GLOW_BLUR: f32 = 8.0; + +/// What a progress bar paints under the Facet skin. +/// +/// The shared progress engine owns motion and keyed state; the skin supplies only paint. +#[derive(Clone, Debug, PartialEq)] +struct ProgressFacet { + /// The groove the bar runs in: the skin's well, painted without its edge. + track: MoonFacet, + /// The fill: the colour at its top edge, then the one at its bottom edge. + fill: [Hsla; 2], + /// The glow the fill casts in its own colour. + glow: BoxShadow, +} + +impl ProgressFacet { + /// Resolves a progress bar's faceted surfaces. + /// + /// Args: + /// tokens: The active theme side; supplies the skin, the palette and the UI scale. + /// color: The bar's colour as `0xRRGGBB`: its tone, or the colour set on it. + /// + /// Returns: + /// The surfaces to paint, or `None` under the Flat skin, where the base bar stands. + fn resolve(tokens: &MoonThemeTokens, color: u32) -> Option { + if !tokens.skin.is_facet() { + return None; + } + let tone = rgba_from(color, 1.0); + Some(Self { + track: facet(MoonFacetKind::Well, tokens), + fill: [tone.blend(white().opacity(FACET_FILL_LIFT)), tone], + glow: facet_glow(color, FACET_FILL_GLOW_ALPHA, FACET_FILL_GLOW_BLUR, tokens), + }) + } + + /// Apply the groove and lit fill without replacing the donor's motion or geometry. + fn apply(self, bar: Progress) -> Progress { + let track = StyleRefinement { + background: Some(Fill::Color(self.track.fill)), + box_shadow: Some(self.track.shadows), + ..Default::default() + }; + let fill = StyleRefinement { + background: Some( + linear_gradient( + 180.0, + linear_color_stop(self.fill[0], 0.0), + linear_color_stop(self.fill[1], 1.0), + ) + .into(), + ), + box_shadow: Some(vec![self.glow]), + ..Default::default() + }; + bar.surface_styles(track, fill) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/moon-ui-components/src/moon/progress/tests.rs b/crates/moon-ui-components/src/moon/progress/tests.rs new file mode 100644 index 0000000..42b8417 --- /dev/null +++ b/crates/moon-ui-components/src/moon/progress/tests.rs @@ -0,0 +1,118 @@ +//! Guards what a MoonProgress bar paints under each skin, and that the skin never changes its +//! height. + +use gpui::{ + BoxShadow, Context, InteractiveElement as _, IntoElement, ParentElement as _, Render, Rgba, + Styled as _, TestAppContext, VisualTestContext, Window, div, point, px, +}; + +use super::{MoonProgress, ProgressFacet}; +use crate::moon::{ + MoonFacetKind, MoonTheme, MoonThemeConfig, MoonThemeTokens, MoonTone, facet, rgba_from, +}; + +/// Catches `progress.rs:ProgressFacet::resolve` ignoring the skin: every flat theme's bars would +/// leave the base progress bar — a tint of the tone under a solid fill — for a groove and a +/// gradient. With nothing resolved, `MoonProgress` renders the base bar exactly as it always has. +#[test] +fn flat_themes_resolve_no_progress_facet() { + for tokens in [ + MoonThemeConfig::moon_terminal().dark, + MoonThemeConfig::moon_terminal().light, + MoonThemeConfig::moon_graphite().dark, + MoonThemeConfig::moon_obsidian().light, + MoonThemeTokens::default(), + ] { + let color = MoonTone::Positive.color(tokens.palette); + assert_eq!(ProgressFacet::resolve(&tokens, color), None); + } +} + +/// Catches a faceted bar drifting from its spec: the track is the skin's well, and the fill runs +/// from its colour lifted a fifth of the way to white down to that colour, under a glow in the +/// colour at 40% — 8px of blur in the design, so 4px at the renderer. A fill lit from below, or +/// one painted in the accent whatever its tone, would make a warning bar read like a healthy one. +#[test] +fn a_faceted_bar_runs_a_lit_fill_of_its_tone_in_a_well() { + let tokens = MoonThemeConfig::moon_obsidian().dark; + let p = tokens.palette; + + // `green` 0x34C07A is (52, 192, 122) and `amber` 0xFFB347 is (255, 179, 71); a fifth of the + // way to 255 adds 40.6, 12.6, 26.6 and 0, 15.2, 36.8. + for (color, lit_top) in [ + (p.green, [92.6, 204.6, 148.6]), + (p.amber, [255.0, 194.2, 107.8]), + ] { + let bar = ProgressFacet::resolve(&tokens, color).expect("faceted"); + assert_eq!(bar.track, facet(MoonFacetKind::Well, &tokens)); + assert_eq!(bar.track.edge, rgba_from(p.overlay, 0.06)); + + let top = Rgba::from(bar.fill[0]); + for (level, expected) in [top.r, top.g, top.b].into_iter().zip(lit_top) { + assert!( + (level * 255.0 - expected).abs() < 0.1, + "fill top {top:?} is not {lit_top:?}" + ); + } + assert_eq!(bar.fill[1], rgba_from(color, 1.0)); + // The glow written out: centred on the fill, no spread, the design's 8px blur halved + // into the renderer's blur radius. + assert_eq!( + bar.glow, + BoxShadow { + color: rgba_from(color, 0.4), + offset: point(px(0.0), px(0.0)), + blur_radius: px(4.0), + spread_radius: px(0.0), + inset: false, + } + ); + } +} + +/// Two bars in a column, each in a probe that measures it: one at the default height, one taller. +struct ProgressHarness; + +impl Render for ProgressHarness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + .w(px(200.)) + .child( + div() + .debug_selector(|| "default-bar".into()) + .child(MoonProgress::new("default").value(40.0)), + ) + .child( + div() + .debug_selector(|| "tall-bar".into()) + .child(MoonProgress::new("tall").value(100.0).height(7.0)), + ) + } +} + +/// Catches the faceted bar changing a progress bar's size — a groove grown past the height the +/// caller set, or a bar that stops filling its parent's width: under the faceted theme each bar +/// must measure exactly what it measures under a flat one, 4px by default and the height asked +/// for otherwise, so a table row holding one never changes height with the theme. +#[gpui::test] +fn a_faceted_bar_keeps_the_height_and_width_of_the_flat_one(cx: &mut TestAppContext) { + cx.update(crate::init); + for (config, faceted) in [ + (MoonThemeConfig::moon_terminal(), false), + (MoonThemeConfig::moon_obsidian(), true), + ] { + cx.update(|cx| MoonTheme::install_config(config, cx)); + cx.update(|cx| assert_eq!(MoonTheme::active_tokens(cx).skin.is_facet(), faceted)); + + let window = cx.add_window(|_, _| ProgressHarness); + let mut cx = VisualTestContext::from_window(window.into(), cx); + cx.run_until_parked(); + + let default_bar = cx.debug_bounds("default-bar").expect("bar must render"); + let tall_bar = cx.debug_bounds("tall-bar").expect("bar must render"); + assert_eq!(default_bar.size.height, px(4.), "faceted={faceted}"); + assert_eq!(tall_bar.size.height, px(7.), "faceted={faceted}"); + assert_eq!(default_bar.size.width, px(200.), "faceted={faceted}"); + assert_eq!(tall_bar.top(), default_bar.bottom(), "faceted={faceted}"); + } +} diff --git a/crates/moon-ui-components/src/moon/radio.rs b/crates/moon-ui-components/src/moon/radio.rs index bfda052..a448417 100644 --- a/crates/moon-ui-components/src/moon/radio.rs +++ b/crates/moon-ui-components/src/moon/radio.rs @@ -234,6 +234,16 @@ impl RenderOnce for MoonRadio { .border_color(colors.border) .bg(colors.fill) .opacity(colors.box_opacity) + // Under the Facet skin an enabled circle takes the checkbox box's faceted surface in + // place of the flat fill and border above; the dot is left as it is. + .when_some( + ChoiceColors::box_facet(&tokens, self.tone, checked, disabled), + |this, facet| { + this.bg(facet.fill) + .border_color(facet.edge) + .shadow(facet.shadows) + }, + ) .when(is_focused, |this| { this.child(choice_focus_ring( &self.id, diff --git a/crates/moon-ui-components/src/moon/radio/tests.rs b/crates/moon-ui-components/src/moon/radio/tests.rs index 0642904..8d3b7ff 100644 --- a/crates/moon-ui-components/src/moon/radio/tests.rs +++ b/crates/moon-ui-components/src/moon/radio/tests.rs @@ -282,6 +282,38 @@ fn radio_without_text_is_its_circle_with_a_centred_dot(cx: &mut TestAppContext) } } +/// Catches the faceted circle changing the radio's geometry — a circle resized when its facet is +/// applied, or a facet painted in place of the circle's content: under the faceted theme a +/// checked radio must stay exactly its 16 or 20px circle around a centred 6 or 8px dot, as under +/// a flat theme, so a radio lines up with the checkbox beside it in either. +#[gpui::test] +fn a_faceted_radio_keeps_its_circle_and_centred_dot(cx: &mut TestAppContext) { + use super::super::MoonTheme; + + cx.update(crate::init); + cx.update(|cx| MoonTheme::install_config(MoonThemeConfig::moon_obsidian(), cx)); + cx.update(|cx| assert!(MoonTheme::active_tokens(cx).skin.is_facet())); + + for (tier, box_px, dot_px) in [(MoonSize::Sm, 16., 6.), (MoonSize::Md, 20., 8.)] { + let window = cx.add_window(move |_, _| ProbedRadioHarness { + size: tier, + label: None, + }); + let mut cx = VisualTestContext::from_window(window.into(), cx); + cx.run_until_parked(); + + let control = cx + .debug_bounds("probed-control") + .expect("probe must render"); + let circle = cx.debug_bounds("probed:box").expect("circle must render"); + let dot = cx.debug_bounds("probed:dot").expect("dot must render"); + assert_eq!(control.size, size(px(box_px), px(box_px)), "{tier:?}"); + assert_eq!(circle.size, size(px(box_px), px(box_px)), "{tier:?}"); + assert_eq!(dot.size, size(px(dot_px), px(dot_px)), "{tier:?}"); + assert_eq!(dot.center(), circle.center(), "{tier:?}"); + } +} + /// Catches `MoonRadio::render` sizing the dot on its own again instead of through /// `snap_centered`. Layout rounds the circle and the dot to device pixels separately, so at 110 % /// a 16px circle and a 6px dot land as 18 and 7: an odd pixel is left over and the dot sits off diff --git a/crates/moon-ui-components/src/moon/segment.rs b/crates/moon-ui-components/src/moon/segment.rs index 8eb6a13..a4934d4 100644 --- a/crates/moon-ui-components/src/moon/segment.rs +++ b/crates/moon-ui-components/src/moon/segment.rs @@ -2,8 +2,11 @@ use gpui::prelude::FluentBuilder; use gpui::*; use std::rc::Rc; +use crate::button::raised_pressed_facet; + use super::{ foundation::{MoonIndexedClickHandler, MoonSize, accent_underline_colored}, + skin::{MoonFacet, MoonFacetKind, MoonFacetTint, facet, facet_tinted}, tag::tone_wash, text::{MoonText, fit_text_to_width, measure_text_width}, theme::{MoonTheme, MoonThemeTokens}, @@ -692,6 +695,9 @@ impl MoonSegmentedControl { let mut replacements = self.replacements; let density = self.density; let (label_size, label_line, hotkey_size, hotkey_line) = segment_text_metrics(&tokens); + let facets = SegmentFacets::resolve(&tokens, frame, fill); + // Cells that stand apart under the Facet skin carry their own hover and press ramp. + let raised_cells = facets.as_ref().is_some_and(|facets| facets.cell.is_some()); let mut root = div() .id(self.id) @@ -709,7 +715,11 @@ impl MoonSegmentedControl { .border_color(rgba_from(p.border, 1.0)) .rounded(fill_radius) .overflow_hidden() - }); + }) + .when_some( + facets.as_ref().and_then(|f| f.strip.clone()), + |this, strip| strip.paint(this), + ); if let Some(bounds) = self.bounds { root = root @@ -728,18 +738,22 @@ impl MoonSegmentedControl { .position(|(index, _)| *index == ix) .map(|position| replacements.swap_remove(position).1); let replaced = replacement.is_some(); - let selected_text = if filled { fill_label } else { accent.text }; - let key_color = if selected { - selected_text + // A faceted selected cell is a wash of the accent over the panel, not the accent + // itself, so its label keeps the ink measured against the panel. + let selected_text = if filled && facets.is_none() { + fill_label } else { - p.text_muted + accent.text }; - let key_alpha = if selected { 0.60 } else { 0.667 }; - let label_color = if selected { - selected_text - } else { - p.text_muted + // A cell that stands apart is a raised chip, lighter than the panel: the muted ink + // falls short on it, so it takes the soft one. + let rest_text = match &facets { + Some(facets) if facets.cell.is_some() && !disabled => p.text_soft, + _ => p.text_muted, }; + let key_color = if selected { selected_text } else { rest_text }; + let key_alpha = if selected { 0.60 } else { 0.667 }; + let label_color = if selected { selected_text } else { rest_text }; let item_click = on_click.clone(); let item_scroll = on_scroll.clone(); let interaction = segment_cell_interaction( @@ -768,10 +782,13 @@ impl MoonSegmentedControl { .when(!interaction.click && !interaction.scroll, |this| { this.cursor_default() }) - .when(!selected && !disabled && !replaced, |this| { - this.hover(move |this| this.bg(rgba_from(p.overlay, 0.025))) - .active(move |this| this.bg(rgba_from(p.overlay, 0.016))) - }) + .when( + !selected && !disabled && !replaced && !raised_cells, + |this| { + this.hover(move |this| this.bg(rgba_from(p.overlay, 0.025))) + .active(move |this| this.bg(rgba_from(p.overlay, 0.016))) + }, + ) .when(filled && selected, |this| this.bg(rgba_from(fill, 1.0))) .when(frame == SegmentFillFrame::JoinedStrip && ix > 0, |this| { this.border_l(hairline) @@ -781,6 +798,24 @@ impl MoonSegmentedControl { this.border(hairline) .border_color(rgba_from(if selected { fill } else { p.border }, 1.0)) .rounded(fill_radius) + }) + .when_some(facets.as_ref(), |this, facets| { + if filled && selected { + // The selected cell is a lit chip of its own, edged all round. + facets + .selected + .clone() + .paint(this.border(hairline).rounded(fill_radius)) + } else if let (Some([rest, hovered, pressed]), false) = + (facets.cell.clone(), disabled) + { + rest.paint(this).when(!replaced, |this| { + this.hover(move |this| hovered.paint(this)) + .active(move |this| pressed.paint(this)) + }) + } else { + this + } }); if let Some(replacement) = replacement { @@ -884,6 +919,52 @@ impl MoonSegmentedControl { } } +/// The surfaces a [`MoonSegmentSelection::Filled`] control paints under the Facet skin. +/// +/// Touching items sit in one well and the selected one stands in it as a lit chip; items that +/// stand apart are raised chips of their own. The underline style has no surfaces, so it +/// resolves none. +#[derive(Clone, Debug, PartialEq)] +struct SegmentFacets { + /// The well a strip of touching items is sunk into; `None` for items that stand apart. + strip: Option, + /// An unselected item that stands apart — at rest, under the pointer and pressed; `None` + /// inside a strip, where items are bare. + cell: Option<[MoonFacet; 3]>, + /// The selected item: a wash of the accent, edged and glowing in it. + selected: MoonFacet, +} + +impl SegmentFacets { + /// Resolve the faceted surfaces of a segmented control. + /// + /// Args: + /// tokens: The theme side; supplies the skin, the palette and the UI scale. + /// frame: The outline policy of the control, from [`segment_fill_frame`]. + /// accent: The accent hue of the selected item as `0xRRGGBB`. + /// + /// Returns: + /// The surfaces to paint, or `None` where the flat colours stand: under the Flat skin + /// and for the underline style. + fn resolve(tokens: &MoonThemeTokens, frame: SegmentFillFrame, accent: u32) -> Option { + if !tokens.skin.is_facet() || frame == SegmentFillFrame::None { + return None; + } + let apart = frame == SegmentFillFrame::SeparateCells; + Some(Self { + strip: (!apart).then(|| facet(MoonFacetKind::Well, tokens)), + cell: apart.then(|| { + [ + facet(MoonFacetKind::Raised, tokens), + facet(MoonFacetKind::RaisedHover, tokens), + raised_pressed_facet(tokens), + ] + }), + selected: facet_tinted(accent, MoonFacetTint::Selected, tokens), + }) + } +} + impl RenderOnce for MoonSegmentedControl { /// Render the control with the active palette and theme tokens. /// diff --git a/crates/moon-ui-components/src/moon/segment/tests.rs b/crates/moon-ui-components/src/moon/segment/tests.rs index 89cc52f..deac480 100644 --- a/crates/moon-ui-components/src/moon/segment/tests.rs +++ b/crates/moon-ui-components/src/moon/segment/tests.rs @@ -14,8 +14,8 @@ use super::{ LABEL_CONTRAST_FLOOR, MoonAccent, MoonPalette, MoonSegmentDensity, MoonSegmentItem, MoonSegmentSelection, MoonSegmentedControl, SEGMENT_COUNT_GAP, SEGMENT_COUNT_PAD_X, SEGMENT_GAP, SEGMENT_LABEL_SIZE, SEGMENT_PAD_X, SegmentCellChrome, SegmentCellInteraction, - SegmentFillFrame, contrast_ratio, count_pill_colors, fit_segment_item, segment_cell_chrome, - segment_cell_interaction, segment_fill_frame, + SegmentFacets, SegmentFillFrame, contrast_ratio, count_pill_colors, fit_segment_item, + segment_cell_chrome, segment_cell_interaction, segment_fill_frame, }; use crate::moon::{MoonScale, MoonTheme, MoonThemeTokens, MoonTone}; @@ -722,3 +722,65 @@ fn counted_item_reserves_its_pill_and_renders_it_inside_the_cell(cx: &mut gpui:: let slack = pill_room - SEGMENT_COUNT_GAP - f32::from(pill.size.width); assert!((0.0..1.0).contains(&slack), "pill reserve slack {slack}"); } + +/// Catches `segment.rs:SegmentFacets::resolve` ignoring the skin or the selection style: every +/// flat theme's filled strip would be repainted as a well, or an underline strip — which has no +/// surfaces at all — would grow a frame under the faceted theme. +#[test] +fn flat_themes_and_underline_strips_resolve_no_segment_facets() { + use crate::moon::MoonThemeConfig; + + let accent = MoonPalette::OBSIDIAN.blue; + for tokens in [ + MoonThemeConfig::moon_terminal().dark, + MoonThemeConfig::moon_terminal().light, + MoonThemeConfig::moon_graphite().dark, + MoonThemeTokens::default(), + ] { + for frame in [ + SegmentFillFrame::None, + SegmentFillFrame::JoinedStrip, + SegmentFillFrame::SeparateCells, + ] { + assert_eq!(SegmentFacets::resolve(&tokens, frame, accent), None); + } + } + let faceted = MoonThemeConfig::moon_obsidian().dark; + assert_eq!( + SegmentFacets::resolve(&faceted, SegmentFillFrame::None, accent), + None + ); +} + +/// Catches a faceted filled strip drifting from its spec. Touching items share one well and are +/// bare inside it; items that stand apart are raised chips with the button's hover and press +/// ramp and no well behind them. Either way the selected item is the accent washed over the +/// panel — not the accent itself, which would bury its label — edged and glowing in that accent. +#[test] +fn a_faceted_filled_strip_is_a_well_or_raised_chips_around_a_lit_selection() { + use crate::button::raised_pressed_facet; + use crate::moon::{MoonFacetKind, MoonFacetTint, MoonThemeConfig, facet, facet_tinted}; + + let tokens = MoonThemeConfig::moon_obsidian().dark; + let accent = tokens.palette.blue; + let selected = facet_tinted(accent, MoonFacetTint::Selected, &tokens); + + let joined = + SegmentFacets::resolve(&tokens, SegmentFillFrame::JoinedStrip, accent).expect("faceted"); + assert_eq!(joined.strip, Some(facet(MoonFacetKind::Well, &tokens))); + assert_eq!(joined.cell, None); + assert_eq!(joined.selected, selected); + + let apart = + SegmentFacets::resolve(&tokens, SegmentFillFrame::SeparateCells, accent).expect("faceted"); + assert_eq!(apart.strip, None); + assert_eq!( + apart.cell, + Some([ + facet(MoonFacetKind::Raised, &tokens), + facet(MoonFacetKind::RaisedHover, &tokens), + raised_pressed_facet(&tokens), + ]) + ); + assert_eq!(apart.selected, selected); +} diff --git a/crates/moon-ui-components/src/moon/selectable_card.rs b/crates/moon-ui-components/src/moon/selectable_card.rs index 50ea073..872e315 100644 --- a/crates/moon-ui-components/src/moon/selectable_card.rs +++ b/crates/moon-ui-components/src/moon/selectable_card.rs @@ -6,7 +6,8 @@ use std::rc::Rc; use super::{ foundation::box_shadow, - theme::MoonTheme, + skin::{MoonFacetKind, facet, facet_glow}, + theme::{MoonTheme, MoonThemeTokens}, tokens::{MoonPalette, MoonTone, rgba_from}, }; @@ -52,6 +53,83 @@ fn card_chrome(selected: bool, focused: bool, accent: u32, p: MoonPalette) -> Ca } } +/// Alpha of the accent on the edge of a selected faceted card. +const FACET_SELECTED_EDGE_ALPHA: f32 = 0.75; +/// Alpha of the glow around a selected faceted card. +const FACET_SELECTED_GLOW_ALPHA: f32 = 0.2; +/// Blur of that glow as the design gives it, in design-reference pixels. +const FACET_SELECTED_GLOW_BLUR: f32 = 22.0; +/// Alpha of the `overlay` ink on the edge of a faceted card under the pointer. +const FACET_HOVER_EDGE_ALPHA: f32 = 0.22; + +/// What a card paints under the Facet skin. +#[derive(Clone, Debug, PartialEq)] +struct CardFacet { + /// Fill of the card. + fill: Background, + /// Colour of the card's one-pixel edge. + edge: Hsla, + /// Colour the edge takes under the pointer; applied to an unselected card only. + hover_edge: Hsla, + /// Shadows of the card: the card's own, then the selected glow or the focus ring. + shadows: Vec, +} + +/// Resolve the look of a card under the Facet skin. +/// +/// A faceted card is a card surface resting on its panel. The selected one is told apart by an +/// accent edge and a soft accent glow, not by the hard one-pixel ring of the Flat skin. Keyboard +/// focus on an unselected card keeps its ring, drawn outside the card's own shadows. +/// +/// Args: +/// selected: Whether the card is the chosen one. +/// focused: Whether the card has keyboard focus. +/// accent: Accent colour of the selected state. +/// tokens: Active theme tokens; supply the skin, the palette and the UI scale. +/// +/// Returns: +/// The faceted look, or `None` under the Flat skin, where [`card_chrome`] alone decides the +/// outline. +fn card_facet( + selected: bool, + focused: bool, + accent: u32, + tokens: &MoonThemeTokens, +) -> Option { + if !tokens.skin.is_facet() { + return None; + } + let p = tokens.palette; + let card = facet(MoonFacetKind::Card, tokens); + let mut shadows = card.shadows; + let edge = if selected { + shadows.push(facet_glow( + accent, + FACET_SELECTED_GLOW_ALPHA, + FACET_SELECTED_GLOW_BLUR, + tokens, + )); + rgba_from(accent, FACET_SELECTED_EDGE_ALPHA) + } else { + if let Some((color, alpha, spread)) = card_chrome(selected, focused, accent, p).ring { + shadows.push(box_shadow( + px(0.0), + px(0.0), + px(0.0), + px(tokens.ui(spread)), + rgba_from(color, alpha), + )); + } + card.edge + }; + Some(CardFacet { + fill: card.fill, + edge, + hover_edge: rgba_from(p.overlay, FACET_HOVER_EDGE_ALPHA), + shadows, + }) +} + /// Moon selectable card: a bordered container for arbitrary content, such as a theme preview, /// that the user picks by click, or by Tab and Space/Enter. The selected card takes an accent /// border. @@ -167,6 +245,9 @@ impl RenderOnce for MoonSelectableCard { .clone(); let focused = interactive && focus_handle.is_focused(window); let chrome = card_chrome(self.selected, focused, accent, p); + // `None` under the Flat skin, where `chrome` alone paints the card. + let card_facet = card_facet(self.selected, focused, accent, &tokens); + let facet_hover_edge = card_facet.as_ref().map(|card_facet| card_facet.hover_edge); let id = self.id.clone(); let mut card = div() @@ -178,23 +259,32 @@ impl RenderOnce for MoonSelectableCard { .p(px(tokens.ui(CARD_PADDING))) .rounded(px(tokens.ui(CARD_RADIUS))) .border(px(tokens.ui(1.0))) - .border_color(rgba_from(chrome.border, 1.0)) - .bg(rgba_from(p.panel_high, 1.0)) - .when_some(chrome.ring, |this, (color, alpha, spread)| { - this.shadow(vec![box_shadow( - px(0.0), - px(0.0), - px(0.0), - px(tokens.ui(spread)), - rgba_from(color, alpha), - )]) + .map(|this| match card_facet { + Some(card_facet) => this + .border_color(card_facet.edge) + .bg(card_facet.fill) + .shadow(card_facet.shadows), + None => this + .border_color(rgba_from(chrome.border, 1.0)) + .bg(rgba_from(p.panel_high, 1.0)) + .when_some(chrome.ring, |this, (color, alpha, spread)| { + this.shadow(vec![box_shadow( + px(0.0), + px(0.0), + px(0.0), + px(tokens.ui(spread)), + rgba_from(color, alpha), + )]) + }), }) .when(self.disabled, |this| this.opacity(0.5)) .when(interactive, |this| { this.track_focus(&focus_handle.clone().tab_stop(true)) .cursor_pointer() - .when(!self.selected, |this| { - this.hover(move |this| this.border_color(rgba_from(p.border_hover, 1.0))) + .when(!self.selected, |this| match facet_hover_edge { + Some(edge) => this.hover(move |this| this.border_color(edge)), + None => this + .hover(move |this| this.border_color(rgba_from(p.border_hover, 1.0))), }) }) .children(self.children); diff --git a/crates/moon-ui-components/src/moon/selectable_card/tests.rs b/crates/moon-ui-components/src/moon/selectable_card/tests.rs index c21d915..3d5c274 100644 --- a/crates/moon-ui-components/src/moon/selectable_card/tests.rs +++ b/crates/moon-ui-components/src/moon/selectable_card/tests.rs @@ -107,3 +107,96 @@ fn cards_select_by_click_and_keyboard_but_not_when_disabled(cx: &mut gpui::TestA "the clicked card keeps focus and Space selects it again" ); } + +// ---- Theme skin: the card surface and how the selected card is told apart ---- + +use super::card_facet; +use crate::moon::{MoonFacetKind, MoonSkin, MoonThemeTokens, facet, rgba_from}; +use gpui::{BoxShadow, point, px}; + +/// Theme tokens for `palette` drawn with `skin`, with every length doubled. +fn doubled_skin_tokens(palette: MoonPalette, skin: MoonSkin) -> MoonThemeTokens { + let mut tokens = MoonThemeTokens { + palette, + skin, + ..MoonThemeTokens::default() + }; + tokens.scale.ui = 2.0; + tokens +} + +/// Catches `selectable_card.rs:card_facet` ignoring the skin: every flat theme's cards would +/// trade the `panel_high` fill and the hard selected ring `card_chrome` gives them for a +/// gradient card with a glow. +#[test] +fn a_flat_card_is_left_to_its_chrome() { + for palette in [MoonPalette::TERMINAL, MoonPalette::LIGHT] { + let tokens = doubled_skin_tokens(palette, MoonSkin::Flat); + for (selected, focused) in [(false, false), (true, false), (false, true), (true, true)] { + assert_eq!(card_facet(selected, focused, palette.accent, &tokens), None); + } + } +} + +/// Catches `selectable_card.rs:card_facet` keeping the Flat skin's hard one-pixel ring on the +/// selected card, dropping its glow, or leaving its edge on the neutral colour: the chosen +/// theme card would either look ringed like a flat one or exactly like the cards beside it. +#[test] +fn a_faceted_card_marks_selection_with_an_accent_edge_and_a_glow() { + let p = MoonPalette::OBSIDIAN; + let tokens = doubled_skin_tokens(p, MoonSkin::Facet); + let accent = p.blue; + let card = facet(MoonFacetKind::Card, &tokens); + + let idle = card_facet(false, false, accent, &tokens).expect("the Facet skin resolves a card"); + assert_eq!(idle.fill, card.fill); + assert_eq!(idle.edge, card.edge); + assert_eq!(idle.shadows, card.shadows); + assert_eq!(idle.hover_edge, rgba_from(p.overlay, 0.22)); + + // The glow written out: centred on the card, no spread, 22 design pixels of blur doubled + // with the UI scale and halved into the renderer's blur radius. + let glow = BoxShadow { + color: rgba_from(accent, 0.2), + offset: point(px(0.0), px(0.0)), + blur_radius: px(22.0), + spread_radius: px(0.0), + inset: false, + }; + let mut card_and_glow = card.shadows.clone(); + card_and_glow.push(glow); + for focused in [false, true] { + let selected = + card_facet(true, focused, accent, &tokens).expect("the Facet skin resolves a card"); + assert_eq!(selected.fill, card.fill); + assert_eq!(selected.edge, rgba_from(accent, 0.75)); + assert_eq!(selected.shadows, card_and_glow, "focused: {focused}"); + assert!( + selected + .shadows + .iter() + .all(|shadow| shadow.spread_radius == px(0.0)), + "no hard spread ring on a selected faceted card" + ); + } + + // The focus ring written out: two design pixels of spread, doubled, and no blur. + let ring = BoxShadow { + color: rgba_from(accent, 0.55), + offset: point(px(0.0), px(0.0)), + blur_radius: px(0.0), + spread_radius: px(4.0), + inset: false, + }; + let mut card_and_ring = card.shadows.clone(); + card_and_ring.push(ring); + let focused = card_facet(false, true, accent, &tokens).expect("the Facet skin resolves a card"); + assert_eq!( + focused.edge, card.edge, + "focus alone must not look selected" + ); + assert_eq!( + focused.shadows, card_and_ring, + "a focused card keeps its focus ring" + ); +} diff --git a/crates/moon-ui-components/src/moon/selector.rs b/crates/moon-ui-components/src/moon/selector.rs index 4e90f8c..6fa5e29 100644 --- a/crates/moon-ui-components/src/moon/selector.rs +++ b/crates/moon-ui-components/src/moon/selector.rs @@ -1,11 +1,15 @@ use gpui::prelude::FluentBuilder; use gpui::*; +use crate::button::raised_pressed_facet; + use super::{ + button::raised_ink, disclosure::{MoonDisclosure, MoonDisclosureDirection}, foundation::box_shadow, + skin::{MoonFacet, MoonFacetKind, MoonSkin, facet}, text::MoonText, - theme::MoonTheme, + theme::{MoonTheme, MoonThemeTokens}, tokens::{MoonRect, rgba_from}, }; @@ -184,6 +188,11 @@ impl MoonSelectorPill { impl RenderOnce for MoonSelectorPill { /// Render the selector pill and its optional decorative caret. /// + /// Under the Facet skin an enabled pill is a raised control: lit from above at rest, relit + /// under the pointer and lit from below while pressed, with muted segment ink lifted to + /// `text_soft`. A disabled pill keeps the flat disabled look on every skin, and the shape + /// and sizes are the same on both. + /// /// Args: /// _window: Window context unused by this renderer. /// cx: Application context used to resolve active theme tokens. @@ -195,6 +204,7 @@ impl RenderOnce for MoonSelectorPill { let p = tokens.palette; let disabled = self.disabled; let text_alpha = if disabled { 0.45 } else { 1.0 }; + let raised = pill_is_raised(tokens.skin, disabled); let mut root = div() .id(self.id) @@ -210,12 +220,18 @@ impl RenderOnce for MoonSelectorPill { .pr(px(tokens.ui(self.pad_right))) .gap(px(tokens.ui(self.gap))) .cursor_default() - .when(!disabled, |this| { + .when(!disabled && !raised, |this| { this.hover(|this| { this.bg(rgba_from(p.panel_high, 1.0)) .border_color(rgba_from(p.border_hover, 1.0)) }) .active(|this| this.bg(rgba_from(p.shell_high, 1.0))) + }) + .when(raised, |this| { + let [rest, hover, pressed] = pill_facets(&tokens); + with_facet(this, rest) + .hover(|this| with_facet(this, hover)) + .active(|this| with_facet(this, pressed)) }); if let Some(bounds) = self.bounds { @@ -246,6 +262,11 @@ impl RenderOnce for MoonSelectorPill { for segment in self.segments { let color = segment.color.unwrap_or(p.text_soft); + let color = if raised { + raised_ink(color, &tokens) + } else { + color + }; root = root.child( div().mt(px(tokens.ui(segment.margin_top))).child( MoonText::new(segment.text) @@ -265,20 +286,56 @@ impl RenderOnce for MoonSelectorPill { // The pill's caret is decorative and permanently collapsed, so `DownUp` selects the // unrotated down-pointing pose. The absolute offsets belong to the pill's geometry, // not the caret's, and therefore stay on this wrapper. + let caret = MoonDisclosure::glyph(false) + .direction(MoonDisclosureDirection::DownUp) + .size(8.0) + .disabled(disabled); + // The caret's own default ink is `text_muted`; on a raised pill it takes the quiet + // ink of a raised control instead, like the muted segments above. + let caret = if raised { + caret.color(raised_ink(p.text_muted, &tokens)) + } else { + caret + }; root = root.child( div() .absolute() .right(px(tokens.ui(self.caret_right))) .top(px(tokens.ui(self.caret_top))) - .child( - MoonDisclosure::glyph(false) - .direction(MoonDisclosureDirection::DownUp) - .size(8.0) - .disabled(disabled), - ), + .child(caret), ); } root } } + +/// Return whether a pill is painted as a raised facet: under the Facet skin, unless it is +/// disabled. +fn pill_is_raised(skin: MoonSkin, disabled: bool) -> bool { + skin.is_facet() && !disabled +} + +/// Resolve the faceted surfaces of a raised pill. +/// +/// Args: +/// tokens: The theme side to resolve them for; supplies the palette and the UI scale. +/// +/// Returns: +/// The surface at rest, under the pointer and while pressed, in that order. +fn pill_facets(tokens: &MoonThemeTokens) -> [MoonFacet; 3] { + [ + facet(MoonFacetKind::Raised, tokens), + facet(MoonFacetKind::RaisedHover, tokens), + raised_pressed_facet(tokens), + ] +} + +/// Paint a faceted surface over the style already set on `this`: its fill as the background, +/// its edge as the border colour and its shadow stack as the shadows. +fn with_facet(this: S, surface: MoonFacet) -> S { + surface.paint(this) +} + +#[cfg(test)] +mod tests; diff --git a/crates/moon-ui-components/src/moon/selector/tests.rs b/crates/moon-ui-components/src/moon/selector/tests.rs new file mode 100644 index 0000000..d4aac71 --- /dev/null +++ b/crates/moon-ui-components/src/moon/selector/tests.rs @@ -0,0 +1,268 @@ +//! Regression coverage for the selector pill under the Flat and Facet skins. + +// Do not use `super::*`: the glob would import the `gpui::test` macro, causing `#[test]` to +// recursively expand into itself. +use std::{cell::RefCell, rc::Rc}; + +use gpui::{ + AppContext as _, Bounds, BoxShadow, Context, IntoElement, ParentElement as _, Pixels, Render, + Styled as _, TestAppContext, Window, div, linear_color_stop, linear_gradient, point, px, +}; + +use super::super::button::scene_probe::{Painted, Pointer, painted}; +use super::{MoonSelectorPill, pill_facets, pill_is_raised}; +use crate::moon::{ + MoonFacetKind, MoonPalette, MoonSkin, MoonTheme, MoonThemeConfig, MoonThemeTokens, facet, + rgba_from, +}; + +/// Theme tokens painting `palette` with the Facet skin at the reference scale. +fn faceted_tokens(palette: MoonPalette) -> MoonThemeTokens { + MoonThemeTokens { + skin: MoonSkin::Facet, + palette, + ..MoonThemeTokens::default() + } +} + +/// An opaque gradient from `top` at the top edge to `bottom` at the bottom edge, written out +/// here rather than taken from the code under test. +fn top_to_bottom(top: u32, bottom: u32) -> gpui::Background { + linear_gradient( + 180.0, + linear_color_stop(rgba_from(top, 1.0), 0.0), + linear_color_stop(rgba_from(bottom, 1.0), 1.0), + ) +} + +/// Catches `selector.rs:pill_is_raised` answering for the Flat skin, which would restyle the +/// pill of every existing theme, or for a disabled pill, which keeps its flat disabled look. +#[test] +fn only_an_enabled_pill_under_the_facet_skin_is_raised() { + assert!(pill_is_raised(MoonSkin::Facet, false)); + assert!(!pill_is_raised(MoonSkin::Facet, true)); + assert!(!pill_is_raised(MoonSkin::Flat, false)); + assert!(!pill_is_raised(MoonSkin::Flat, true)); +} + +/// Catches `selector.rs:pill_facets` handing the hover surface to the resting pill, or a +/// pressed surface that still casts its drop shadow: the pill would look hovered at rest, or +/// would not sink when pressed. +#[test] +fn a_raised_pill_rests_raised_relights_under_the_pointer_and_sinks_when_pressed() { + for palette in [MoonPalette::OBSIDIAN, MoonPalette::LIGHT] { + let tokens = faceted_tokens(palette); + let [rest, hover, pressed] = pill_facets(&tokens); + + assert_eq!( + rest.fill, + top_to_bottom(palette.panel_high, palette.panel), + "the resting pill is lit from above" + ); + assert!(!rest.shadows.is_empty()); + assert_eq!(rest, facet(MoonFacetKind::Raised, &tokens)); + assert_eq!(hover, facet(MoonFacetKind::RaisedHover, &tokens)); + assert_ne!(hover, rest); + + assert_eq!( + pressed.fill, + top_to_bottom(palette.panel, palette.panel_high), + "the pressed pill is lit from below" + ); + assert_eq!(pressed.edge, rest.edge); + let inner: Vec = rest + .shadows + .iter() + .filter(|shadow| shadow.inset) + .cloned() + .collect(); + assert_eq!(pressed.shadows, inner, "the pressed pill casts no shadow"); + assert!(pressed.shadows.len() < rest.shadows.len()); + } +} + +/// The Obsidian theme with its dark side drawn in `skin`: the same palette and scale on both +/// skins, so two renders differ by the skin alone. +fn obsidian_with(skin: MoonSkin) -> MoonThemeConfig { + let mut config = MoonThemeConfig::moon_obsidian(); + config.dark.skin = skin; + config +} + +/// The pill every probe below draws, enabled or disabled. +fn probed_pill(disabled: bool) -> MoonSelectorPill { + MoonSelectorPill::new("pill") + .label("BTCUSDT") + .leading_dot(MoonPalette::OBSIDIAN.green) + .disabled(disabled) +} + +/// Catches `selector.rs:MoonSelectorPill::render` painting anything new on a flat theme: the +/// pill keeps its solid panel fill, its hairline and its hover and pressed fills, and casts no +/// shadow. The colours are the flat design's own, written out rather than read back. +#[test] +fn a_flat_theme_paints_the_pill_as_it_did_before_the_facet_skin() { + let p = MoonPalette::OBSIDIAN; + let flat = |fill: (u32, f32), border: (u32, f32)| { + Painted::flat(rgba_from(fill.0, fill.1), rgba_from(border.0, border.1)) + }; + let cases = [ + ( + "resting", + Pointer::Away, + false, + flat((p.panel, 1.0), (p.border, 1.0)), + ), + ( + "hovered", + Pointer::Over, + false, + flat((p.panel_high, 1.0), (p.border_hover, 1.0)), + ), + ( + "pressed", + Pointer::Pressed, + false, + flat((p.shell_high, 1.0), (p.border_hover, 1.0)), + ), + ( + "disabled", + Pointer::Over, + true, + flat((p.panel, 0.48), (p.border, 0.55)), + ), + ]; + + let mismatches = paint_mismatches(MoonSkin::Flat, cases); + assert!( + mismatches.is_empty(), + "under the Flat skin:\n{}", + mismatches.join("\n") + ); +} + +/// Draw each case's pill under the Obsidian palette with `skin` and describe every one that +/// did not paint what was expected, so one run names all the states that drifted. A case is +/// `(name, pointer, disabled, expected)`. +fn paint_mismatches(skin: MoonSkin, cases: [(&str, Pointer, bool, Painted); 4]) -> Vec { + cases + .into_iter() + .filter_map(|(name, pointer, disabled, expected)| { + let got = painted(obsidian_with(skin), pointer, move || probed_pill(disabled)); + (got != expected).then(|| format!("the {name} pill painted {got:?}, not {expected:?}")) + }) + .collect() +} + +/// Catches `selector.rs:MoonSelectorPill::render` not painting the raised surfaces it resolves: +/// a pill that stays a flat slab on the faceted theme, does not relight under the pointer or +/// keeps its drop shadow while pressed. Also catches a disabled pill picking a surface up. +#[test] +fn the_faceted_theme_paints_the_pill_raised_relit_and_pressed() { + let p = MoonPalette::OBSIDIAN; + let tokens = faceted_tokens(p); + let raised = facet(MoonFacetKind::Raised, &tokens); + let cases = [ + ( + "resting", + Pointer::Away, + false, + Painted::facet(raised.clone()), + ), + ( + "hovered", + Pointer::Over, + false, + Painted::facet(facet(MoonFacetKind::RaisedHover, &tokens)), + ), + ( + "pressed", + Pointer::Pressed, + false, + Painted { + fill: Some(top_to_bottom(p.panel, p.panel_high)), + border: Some(raised.edge), + shadows: raised + .shadows + .iter() + .filter(|shadow| shadow.inset) + .cloned() + .collect(), + }, + ), + ( + "disabled", + Pointer::Over, + true, + Painted::flat(rgba_from(p.panel, 0.48), rgba_from(p.border, 0.55)), + ), + ]; + + let mismatches = paint_mismatches(MoonSkin::Facet, cases); + assert!( + mismatches.is_empty(), + "under the Facet skin:\n{}", + mismatches.join("\n") + ); +} + +/// A window rendering one pill at the origin and recording its laid-out bounds. +struct PillHarness { + disabled: bool, + bounds: Rc>>>, +} + +impl Render for PillHarness { + /// Render the pill start-aligned in a wide row, so the row does not stretch it. + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + let sink = self.bounds.clone(); + div() + .w(px(400.0)) + .flex() + .flex_row() + .items_start() + .justify_start() + .on_children_prepainted(move |bounds, _, _| *sink.borrow_mut() = bounds) + .child(probed_pill(self.disabled)) + } +} + +/// Draw a pill in a real window and return its box. +fn laid_out_pill(cx: &mut TestAppContext, disabled: bool) -> Bounds { + let bounds = Rc::new(RefCell::new(Vec::new())); + let sink = bounds.clone(); + let window = cx.add_window(move |_, _| PillHarness { + disabled, + bounds: sink, + }); + cx.update_window(window.into(), |_, window, cx| { + window.draw(cx).clear(); + }) + .unwrap(); + + let out = bounds.borrow(); + assert_eq!(out.len(), 1, "expected exactly the pill as a child"); + out[0] +} + +/// Catches the Facet skin changing the pill's box, by adding a border width or padding, and +/// catches the faceted render path panicking: the pill keeps its shape and size on both skins, +/// enabled or disabled. The 24-pixel height is the pill's own design-reference default. +#[gpui::test] +fn the_facet_skin_leaves_the_pill_box_unchanged(cx: &mut TestAppContext) { + cx.update(crate::init); + + for disabled in [false, true] { + cx.update(|cx| MoonTheme::install_config(obsidian_with(MoonSkin::Flat), cx)); + let flat = laid_out_pill(cx, disabled); + cx.update(|cx| MoonTheme::install_config(obsidian_with(MoonSkin::Facet), cx)); + let faceted = laid_out_pill(cx, disabled); + + assert_eq!(flat.origin, point(px(0.0), px(0.0))); + assert_eq!(flat.size.height, px(24.0)); + assert_eq!( + faceted, flat, + "the pill moved under the Facet skin (disabled={disabled})" + ); + } +} diff --git a/crates/moon-ui-components/src/moon/skin.rs b/crates/moon-ui-components/src/moon/skin.rs new file mode 100644 index 0000000..60904dd --- /dev/null +++ b/crates/moon-ui-components/src/moon/skin.rs @@ -0,0 +1,539 @@ +//! The theme skin: the manner in which a theme draws depth. +//! +//! A palette says which colours a theme has; the skin says what a surface made of them looks +//! like. [`MoonSkin::Flat`] is every theme that existed before skins did: one solid fill and a +//! hairline. [`MoonSkin::Facet`] lights each surface from above: a two-stop vertical gradient, +//! a one-pixel edge, a highlight along the top and a shadow underneath. +//! +//! Components ask [`MoonSkin::active`] and, only when it answers `Facet`, paint the surface +//! [`facet`] or [`facet_tinted`] resolves for them. Nothing here is consulted under `Flat`, so +//! a theme without a skin renders exactly as it did before this module existed. +//! +//! Every colour is derived from the palette by rule rather than stored, so the same code +//! serves a dark and a light palette: `overlay` is the highlight ink, `shadow` is the shadow +//! ink, and [`MoonPalette::is_light`] decides strengths only. Every length goes through +//! [`MoonThemeTokens::ui`], so a facet grows with the control it is painted on. + +use gpui::{ + AnyElement, App, Background, BoxShadow, Div, Hsla, InteractiveElement as _, IntoElement, + ParentElement as _, Styled, div, linear_color_stop, linear_gradient, px, relative, +}; +use serde::{Deserialize, Serialize}; + +use super::{ + foundation::box_shadow, + theme::{MoonTheme, MoonThemeTokens}, + tokens::{MoonPalette, rgba_from}, +}; + +/// The white end of [`lift`], and the highlight ink a light palette uses in place of its +/// (black) `overlay`. +const WHITE: u32 = 0xFFFFFF; +/// The black end of [`sink`]. +const BLACK: u32 = 0x000000; + +/// What a blur of the design becomes as a blur radius of the renderer. +/// +/// The skin's blurs are written as the design gives them: CSS `box-shadow` blurs, where the +/// gaussian's standard deviation is half the blur. The renderer takes the standard deviation +/// itself as its blur radius, so a design blur is halved on the way in. Passed through as it is, +/// every shadow and glow would spread twice as wide and arrive at a fraction of its strength. +const DESIGN_BLUR_TO_RENDERER: f32 = 0.5; + +/// How far down the window the backdrop's top light reaches, as a fraction of its height. +const BACKDROP_TOP_LIGHT_REACH: f32 = 0.5; +/// Blur of a backdrop ambient light as the design gives it, in design-reference pixels. +const BACKDROP_LIGHT_BLUR: f32 = 260.0; +/// Spread radius of a backdrop ambient light, in design-reference pixels. +const BACKDROP_LIGHT_SPREAD: f32 = 140.0; + +/// The manner in which a theme draws depth. +/// +/// A theme side carries one in [`MoonThemeTokens::skin`]. A theme file that does not name a +/// skin gets [`Self::Flat`], which is what every theme was before skins existed. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum MoonSkin { + /// Solid fills and hairlines; no component consults this module. + #[default] + Flat, + /// Surfaces lit from above: gradient fills, one-pixel edges, highlights and shadows. + Facet, +} + +impl MoonSkin { + /// Return whether this is the [`Self::Facet`] skin. + pub fn is_facet(self) -> bool { + matches!(self, Self::Facet) + } + + /// Return the skin of the active theme side. + /// + /// Reads the [`MoonTheme`] global directly, so it is cheap enough to ask on every render. + /// + /// Args: + /// cx: The application context holding the theme global. + /// + /// Returns: + /// The active skin, or [`Self::Flat`] when no theme is installed. + pub fn active(cx: &App) -> Self { + MoonTheme::global(cx) + .map(|theme| theme.skin) + .unwrap_or_default() + } +} + +/// A resolved faceted surface, ready to paint. +/// +/// A component applies `fill` as its background, `edge` as the colour of a one-pixel border +/// and `shadows` through `Styled::shadow`. +#[derive(Clone, Debug, PartialEq)] +pub struct MoonFacet { + /// The surface fill: a two-stop gradient running from the top edge to the bottom edge. + pub fill: Background, + /// The colour of the one-pixel edge around the surface. + pub edge: Hsla, + /// The shadow stack: the inset shadow first, then the shadows outside the surface. + pub shadows: Vec, +} + +impl MoonFacet { + /// Paint this surface over the style already set on `this`. + /// + /// Sets the fill as the background, the edge as the border colour and the shadow stack. + /// The border width and the corner radius stay with the caller, so the same surface serves + /// a hairline frame at any scale. + /// + /// Args: + /// this: The element or style refinement to paint on. + /// + /// Returns: + /// `this`, painted. + pub fn paint(self, this: S) -> S { + this.bg(self.fill) + .border_color(self.edge) + .shadow(self.shadows) + } +} + +/// The neutral surfaces a faceted theme is built from. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MoonFacetKind { + /// A dock panel: the large slab the rest of the interface sits on. + Slab, + /// A control that stands off its surface, such as a button or a pill. + Raised, + /// [`Self::Raised`] under the pointer. + RaisedHover, + /// A surface sunk into its parent: an input, a toggle track, a slider groove. + Well, + /// A small well that holds a mark: a checkbox box, a radio circle, a toggle track. It is + /// [`Self::Well`] under an edge strong enough to read at a dozen pixels, where the well's + /// own edge disappears into the surface around it. + Socket, + /// A surface floating above the window: a menu, popover, tooltip or dialog. + Popup, + /// A card resting on a panel. + Card, +} + +/// How strongly a tone colours a faceted surface. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MoonFacetTint { + /// A light wash of the tone over the panel. + Soft, + /// A stronger wash with a glow, for the selected one of several. + Selected, + /// The tone itself as the fill, for a primary action. + Solid, +} + +/// Resolve a neutral faceted surface from the palette. +/// +/// Args: +/// kind: Which surface to resolve. +/// tokens: The theme side to resolve it for; supplies the palette and the UI scale. +/// +/// Returns: +/// The fill, edge and shadow stack for that surface. +pub fn facet(kind: MoonFacetKind, tokens: &MoonThemeTokens) -> MoonFacet { + let p = tokens.palette; + let top_highlight = + |dark: f32, light: f32| inset_shadow(1.0, 0.0, highlight(p, dark, light), tokens); + let cast = |y: f32, blur: f32, dark: f32, light: f32| { + drop_shadow(y, blur, shade(p, dark, light), tokens) + }; + let raised_shadows = || vec![top_highlight(0.16, 0.9), cast(1.0, 2.0, 0.55, 0.12)]; + + match kind { + MoonFacetKind::Slab => MoonFacet { + fill: vertical(lift(p.shell_high, 0.045), p.shell_high), + edge: edge(p, 0.07, p.border), + shadows: vec![ + top_highlight(0.12, 0.9), + cast(18.0, 40.0, 0.65, 0.10), + cast(2.0, 6.0, 0.5, 0.08), + ], + }, + MoonFacetKind::Raised => MoonFacet { + fill: vertical(p.panel_high, p.panel), + edge: edge(p, 0.09, p.border), + shadows: raised_shadows(), + }, + MoonFacetKind::RaisedHover => MoonFacet { + fill: vertical(lift(p.panel_high, 0.05), lift(p.panel, 0.04)), + edge: edge(p, 0.2, p.border_hover), + shadows: raised_shadows(), + }, + // A well inverts the stack: the inset shadow is the dark one, pooling under the top + // edge, and the light sits outside, along the lip below the well. + MoonFacetKind::Well => MoonFacet { + fill: vertical(sink(p.gutter, well_sink(p)), p.gutter), + edge: edge(p, 0.06, p.border), + shadows: vec![ + inset_shadow(1.0, 3.0, shade(p, 0.7, 0.12), tokens), + drop_shadow(1.0, 0.0, highlight(p, 0.07, 0.8), tokens), + ], + }, + MoonFacetKind::Socket => MoonFacet { + edge: edge(p, 0.17, p.border_hover), + ..facet(MoonFacetKind::Well, tokens) + }, + MoonFacetKind::Popup => MoonFacet { + fill: vertical(lift(p.panel_high, 0.02), p.card), + edge: edge(p, 0.13, p.border), + shadows: vec![ + top_highlight(0.2, 0.9), + cast(28.0, 60.0, 0.8, 0.16), + cast(6.0, 16.0, 0.55, 0.10), + ], + }, + MoonFacetKind::Card => MoonFacet { + fill: vertical(lift(p.card, 0.04), p.card), + edge: edge(p, 0.08, p.border), + shadows: vec![top_highlight(0.12, 0.9), cast(2.0, 6.0, 0.5, 0.08)], + }, + } +} + +/// Resolve a faceted surface coloured by a tone. +/// +/// Args: +/// tone: The colouring tone as `0xRRGGBB`, such as the palette's `blue` or `accent`. +/// tint: How strongly the tone colours the surface. +/// tokens: The theme side to resolve it for; supplies the palette and the UI scale. +/// +/// Returns: +/// The fill, edge and shadow stack for that surface. +pub fn facet_tinted(tone: u32, tint: MoonFacetTint, tokens: &MoonThemeTokens) -> MoonFacet { + let p = tokens.palette; + let washed = |share: f32| mix(p.panel, tone, share); + // The `overlay` ink in either palette: a highlight on a dark one, and on a light one, where + // `overlay` is black, a faint line of shade under the top edge instead. + let soft_highlight = || inset_shadow(1.0, 0.0, rgba_from(p.overlay, 0.10), tokens); + let contact = || drop_shadow(1.0, 2.0, shade(p, 0.55, 0.12), tokens); + + match tint { + MoonFacetTint::Soft => MoonFacet { + fill: vertical(washed(0.14), washed(0.06)), + edge: rgba_from(tone, 0.42), + shadows: vec![soft_highlight(), contact()], + }, + MoonFacetTint::Selected => MoonFacet { + fill: vertical(washed(0.20), washed(0.08)), + edge: rgba_from(tone, 0.7), + shadows: vec![ + soft_highlight(), + contact(), + facet_glow(tone, 0.26, 18.0, tokens), + ], + }, + // The highlight on a solid tone is white in either palette: the fill is the tone itself, + // which the palette's side does not change. + MoonFacetTint::Solid => MoonFacet { + fill: vertical(lift(tone, 0.25), sink(tone, 0.06)), + edge: rgba_from(sink(tone, 0.2), 1.0), + shadows: vec![ + inset_shadow(1.0, 0.0, rgba_from(WHITE, 0.45), tokens), + contact(), + facet_glow(tone, if p.is_light() { 0.2 } else { 0.32 }, 18.0, tokens), + ], + }, + } +} + +/// Build a glow: a blurred shadow centred on its element, with no offset and no spread. +/// +/// Args: +/// color: The glow colour as `0xRRGGBB`. +/// alpha: The glow's opacity at full strength, from 0 to 1. +/// blur: The blur as the design gives it, a CSS `box-shadow` blur in design-reference +/// pixels; scaled with the UI and converted for the renderer. +/// tokens: The theme side, for the UI scale. +/// +/// Returns: +/// The shadow, to append to a surface's shadow stack. +pub fn facet_glow(color: u32, alpha: f32, blur: f32, tokens: &MoonThemeTokens) -> BoxShadow { + box_shadow( + px(0.0), + px(0.0), + renderer_blur(blur, tokens), + px(0.0), + rgba_from(color, alpha), + ) +} + +/// Convert a blur of the design, in design-reference pixels, to the renderer's blur radius at +/// the UI scale of `tokens`; see [`DESIGN_BLUR_TO_RENDERER`]. +fn renderer_blur(design_blur: f32, tokens: &MoonThemeTokens) -> gpui::Pixels { + px(tokens.ui(design_blur * DESIGN_BLUR_TO_RENDERER)) +} + +/// Build a horizontal one-pixel rule that fades out toward both ends. +/// +/// The rule fills its parent's width. A gradient has exactly two stops, so the rule is two +/// halves: the first fades in from transparent, the second fades back out. +/// +/// Args: +/// color: The colour at the middle of the rule; its own alpha is kept there. +/// +/// Returns: +/// The rule element. +pub fn fading_rule_h(color: Hsla) -> impl IntoElement { + let [rising, falling] = fading_halves(color, 90.0); + div() + .flex() + .flex_row() + .flex_none() + .w_full() + .h(px(1.0)) + .child(div().flex_1().h_full().bg(rising)) + .child(div().flex_1().h_full().bg(falling)) +} + +/// Build a vertical one-pixel rule that fades out toward both ends. +/// +/// The rule fills its parent's height; see [`fading_rule_h`] for the construction. +/// +/// Args: +/// color: The colour at the middle of the rule; its own alpha is kept there. +/// +/// Returns: +/// The rule element. +pub fn fading_rule_v(color: Hsla) -> impl IntoElement { + let [rising, falling] = fading_halves(color, 180.0); + div() + .flex() + .flex_col() + .flex_none() + .h_full() + .w(px(1.0)) + .child(div().flex_1().w_full().bg(rising)) + .child(div().flex_1().w_full().bg(falling)) +} + +/// Build the window backdrop of a faceted theme. +/// +/// The backdrop is the window fill with a top light — `shell` lifted along the top edge and +/// settling to plain `shell` part of the way down — and two very soft ambient lights: the +/// accent around the upper-left quarter and blue around the lower-right one. Each light is a +/// one-pixel element casting a large blurred shadow, clipped to the backdrop. +/// +/// The element is absolutely positioned and fills its nearest positioned ancestor. It is +/// opaque, so it belongs behind everything else in that ancestor; it takes no pointer input. +/// `MoonRoot` paints it on its own when it owns an opaque window fill. A host whose root +/// paints no fill, to show what lies behind the scene, places it where it wants one. +/// +/// Args: +/// tokens: The theme side to build it for; supplies the skin, the palette and the UI scale. +/// +/// Returns: +/// The backdrop under [`MoonSkin::Facet`], `None` under [`MoonSkin::Flat`]. +pub fn moon_backdrop(tokens: &MoonThemeTokens) -> Option { + if !tokens.skin.is_facet() { + return None; + } + let p = tokens.palette; + let light = |color: u32, alpha: f32| moon_ambient_light(color, alpha, tokens); + + Some( + div() + .debug_selector(|| "moon-backdrop".into()) + .absolute() + .top_0() + .left_0() + .size_full() + .overflow_hidden() + .bg(backdrop_fill(p)) + .child( + light(p.accent, 0.10) + .left(relative(0.25)) + .top(relative(0.25)), + ) + .child( + light(p.blue, 0.08) + .right(relative(0.25)) + .bottom(relative(0.25)), + ) + .into_any_element(), + ) +} + +/// Build one ambient light of a faceted theme: a wide, soft pool of `color`. +/// +/// The light is a one-pixel element casting a large blurred shadow — the lights of +/// [`moon_backdrop`] are two of these. It is absolutely positioned and the caller places it; +/// the pool is centred on that point. A host whose window paints no backdrop, because something +/// shows through its root, lays one inside an opaque band of its own instead, in a parent that +/// clips it to that band. +/// +/// The skin is the caller's question: this builds the light under either one. +/// +/// Args: +/// color: The light's colour as `0xRRGGBB`. +/// alpha: The light's opacity at its centre, from 0 to 1. +/// tokens: The theme side, for the UI scale. +/// +/// Returns: +/// The light, absolutely positioned and not yet placed. +pub fn moon_ambient_light(color: u32, alpha: f32, tokens: &MoonThemeTokens) -> Div { + div() + .absolute() + .size(px(1.0)) + .shadow(vec![ambient_light(color, alpha, tokens)]) +} + +/// Build the backdrop fill: `shell` lifted along the top edge, plain `shell` from +/// [`BACKDROP_TOP_LIGHT_REACH`] of the way down. +fn backdrop_fill(p: MoonPalette) -> Background { + linear_gradient( + 180.0, + linear_color_stop(rgba_from(lift(p.shell, 0.035), 1.0), 0.0), + linear_color_stop(rgba_from(p.shell, 1.0), BACKDROP_TOP_LIGHT_REACH), + ) +} + +/// Build the shadow that makes one backdrop ambient light of `color` at `alpha`. +fn ambient_light(color: u32, alpha: f32, tokens: &MoonThemeTokens) -> BoxShadow { + box_shadow( + px(0.0), + px(0.0), + renderer_blur(BACKDROP_LIGHT_BLUR, tokens), + px(tokens.ui(BACKDROP_LIGHT_SPREAD)), + rgba_from(color, alpha), + ) +} + +/// Build the two halves of a fading rule along the gradient `angle`: transparent rising to +/// `color`, then `color` falling back to transparent. +/// +/// The transparent ends keep the hue of `color`, so the fade does not pass through grey. +fn fading_halves(color: Hsla, angle: f32) -> [Background; 2] { + let clear = color.opacity(0.0); + [ + linear_gradient( + angle, + linear_color_stop(clear, 0.0), + linear_color_stop(color, 1.0), + ), + linear_gradient( + angle, + linear_color_stop(color, 0.0), + linear_color_stop(clear, 1.0), + ), + ] +} + +/// Build an opaque gradient running from `top` at the top edge to `bottom` at the bottom edge, +/// both `0xRRGGBB`. +fn vertical(top: u32, bottom: u32) -> Background { + linear_gradient( + 180.0, + linear_color_stop(rgba_from(top, 1.0), 0.0), + linear_color_stop(rgba_from(bottom, 1.0), 1.0), + ) +} + +/// Mix `over` into `base` per sRGB channel, all `0xRRGGBB`. +/// +/// Args: +/// base: The colour at an `amount` of 0. +/// over: The colour at an `amount` of 1. +/// amount: The share of `over` in the result, clamped to 0..=1. +/// +/// Returns: +/// The mixed colour, each channel rounded to the nearest level. +fn mix(base: u32, over: u32, amount: f32) -> u32 { + let amount = amount.clamp(0.0, 1.0); + let channel = |shift: u32| { + let from = ((base >> shift) & 0xFF) as f32; + let to = ((over >> shift) & 0xFF) as f32; + ((from + (to - from) * amount).round() as u32) << shift + }; + channel(16) | channel(8) | channel(0) +} + +/// Mix `rgb` toward white by `amount`: 0 keeps the colour, 1 is white. +fn lift(rgb: u32, amount: f32) -> u32 { + mix(rgb, WHITE, amount) +} + +/// Mix `rgb` toward black by `amount`: 0 keeps the colour, 1 is black. +fn sink(rgb: u32, amount: f32) -> u32 { + mix(rgb, BLACK, amount) +} + +/// Resolve how far the top of a well is sunk below its `gutter` floor. +/// +/// A share of black, so the same share darkens a light floor many times more than a dark one: +/// 15% is barely a step on a near-black gutter and a slab of grey on a near-white one. A light +/// palette therefore sinks its wells by a fraction of what a dark one does. +fn well_sink(p: MoonPalette) -> f32 { + if p.is_light() { 0.04 } else { 0.15 } +} + +/// Resolve a highlight ink: the palette's `overlay` at `dark` on a dark palette, white at +/// `light` on a light one, where `overlay` is black and cannot light anything. +fn highlight(p: MoonPalette, dark: f32, light: f32) -> Hsla { + if p.is_light() { + rgba_from(WHITE, light) + } else { + rgba_from(p.overlay, dark) + } +} + +/// Resolve a shadow ink: the palette's `shadow` at `dark` on a dark palette and at `light` on +/// a light one, where a shadow needs far less to show. +fn shade(p: MoonPalette, dark: f32, light: f32) -> Hsla { + rgba_from(p.shadow, if p.is_light() { light } else { dark }) +} + +/// Resolve an edge colour: the `overlay` ink at `dark` on a dark palette, the opaque +/// `light_role` border entry on a light one. +fn edge(p: MoonPalette, dark: f32, light_role: u32) -> Hsla { + if p.is_light() { + rgba_from(light_role, 1.0) + } else { + rgba_from(p.overlay, dark) + } +} + +/// Build a shadow outside its element, `y` below it and blurred by the design blur `blur`, both +/// in design-reference pixels. +fn drop_shadow(y: f32, blur: f32, color: Hsla, tokens: &MoonThemeTokens) -> BoxShadow { + box_shadow( + px(0.0), + px(tokens.ui(y)), + renderer_blur(blur, tokens), + px(0.0), + color, + ) +} + +/// Build a shadow inside its element, reaching `y` down from the top edge and blurred by the +/// design blur `blur`, both in design-reference pixels. +fn inset_shadow(y: f32, blur: f32, color: Hsla, tokens: &MoonThemeTokens) -> BoxShadow { + drop_shadow(y, blur, color, tokens).inset() +} + +#[cfg(test)] +mod tests; diff --git a/crates/moon-ui-components/src/moon/skin/tests.rs b/crates/moon-ui-components/src/moon/skin/tests.rs new file mode 100644 index 0000000..8872d27 --- /dev/null +++ b/crates/moon-ui-components/src/moon/skin/tests.rs @@ -0,0 +1,540 @@ +//! Guards the facet rules — what each surface resolves to on a dark and on a light palette, and +//! how a blur of the design reaches the renderer — and where the Facet backdrop is painted. + +use gpui::{ + AppContext as _, Bounds, BoxShadow, Context, Hsla, IntoElement, Pixels, Render, TestAppContext, + Window, div, linear_color_stop, linear_gradient, point, px, +}; + +use super::{ + MoonFacetKind, MoonFacetTint, MoonSkin, ambient_light, backdrop_fill, facet, facet_glow, + facet_tinted, fading_halves, lift, mix, moon_backdrop, sink, +}; +use crate::moon::{ + MoonBackgroundPolicy, MoonRoot, MoonTheme, MoonThemeConfig, MoonThemeTokens, + foundation::ThemeMode, + tokens::{MoonPalette, rgba_from}, +}; + +const WHITE: u32 = 0xFFFFFF; + +const ALL_KINDS: [MoonFacetKind; 7] = [ + MoonFacetKind::Slab, + MoonFacetKind::Raised, + MoonFacetKind::RaisedHover, + MoonFacetKind::Well, + MoonFacetKind::Socket, + MoonFacetKind::Popup, + MoonFacetKind::Card, +]; + +/// Theme tokens for `palette` at the default scale. +fn tokens_for(palette: MoonPalette) -> MoonThemeTokens { + MoonThemeTokens { + palette, + ..MoonThemeTokens::default() + } +} + +/// Theme tokens for `palette` with every length doubled. +fn doubled_tokens_for(palette: MoonPalette) -> MoonThemeTokens { + let mut tokens = tokens_for(palette); + tokens.scale.ui = 2.0; + tokens +} + +/// An opaque gradient from `top` to `bottom`, written out rather than taken from `skin.rs`. +fn top_to_bottom(top: u32, bottom: u32) -> gpui::Background { + linear_gradient( + 180.0, + linear_color_stop(rgba_from(top, 1.0), 0.0), + linear_color_stop(rgba_from(bottom, 1.0), 1.0), + ) +} + +/// A shadow with no horizontal offset and no spread, written out rather than taken from +/// `skin.rs`. `design_blur` is the blur as the design gives it, a CSS `box-shadow` blur. +fn shadow(y: f32, design_blur: f32, color: Hsla, inset: bool) -> BoxShadow { + BoxShadow { + color, + offset: point(px(0.0), px(y)), + // The renderer's blur radius is the gaussian's standard deviation: half a CSS blur. + blur_radius: px(design_blur / 2.0), + spread_radius: px(0.0), + inset, + } +} + +/// Catches `skin.rs:mix` weighting the wrong end, or truncating a channel instead of rounding +/// it: every facet fill is built from these three, so a slip here tints or darkens all of them. +#[test] +fn lift_sink_and_mix_move_each_channel_by_the_given_share() { + // 0x20 + 0.25 * (0xFF - 0x20) = 87.75, 0x40 -> 111.75, 0x60 -> 135.75, each rounded up. + assert_eq!(lift(0x204060, 0.25), 0x587088); + // 0x20 * 0.75 = 24, 0x40 -> 48, 0x60 -> 72. + assert_eq!(sink(0x204060, 0.25), 0x183048); + // A quarter of the way from black to (0xFF, 0x80, 0x00): 63.75, 32, 0. + assert_eq!(mix(0x000000, 0xFF8000, 0.25), 0x402000); + + assert_eq!(lift(0x204060, 0.0), 0x204060); + assert_eq!(lift(0x204060, 1.0), WHITE); + assert_eq!(sink(0x204060, 1.0), 0x000000); + assert_eq!( + mix(0x204060, 0xFF8000, 2.0), + 0xFF8000, + "a share above one is clamped, not extrapolated past the end colour" + ); +} + +/// Catches a kind resolving without shadows, or without the inset one in the documented first +/// position: under the Facet skin that surface would lose its top light or its lift off the +/// surface below, on a dark palette or a light one. +#[test] +fn every_kind_stacks_an_inset_shadow_ahead_of_its_outer_ones() { + for (name, palette) in [ + ("terminal", MoonPalette::TERMINAL), + ("light", MoonPalette::LIGHT), + ("graphite", MoonPalette::GRAPHITE), + ("obsidian", MoonPalette::OBSIDIAN), + ] { + let tokens = tokens_for(palette); + for kind in ALL_KINDS { + let shadows = facet(kind, &tokens).shadows; + assert!( + !shadows.is_empty(), + "{name} {kind:?} must resolve a shadow stack" + ); + assert!( + shadows[0].inset, + "{name} {kind:?} lists its inset shadow first" + ); + assert!( + shadows.len() > 1 && shadows[1..].iter().all(|shadow| !shadow.inset), + "{name} {kind:?} follows it with outer shadows only" + ); + } + } +} + +/// Catches swapping the gradient ends in `skin.rs:facet`, which lights a raised control from +/// below so that it reads as pressed, and catches a wrong strength on its edge or shadows. +#[test] +fn a_raised_facet_runs_from_panel_high_down_to_panel_on_a_dark_palette() { + let p = MoonPalette::OBSIDIAN; + let raised = facet(MoonFacetKind::Raised, &tokens_for(p)); + + assert_eq!(raised.fill, top_to_bottom(p.panel_high, p.panel)); + assert_eq!(raised.edge, rgba_from(p.overlay, 0.09)); + assert_eq!( + raised.shadows, + vec![ + shadow(1.0, 0.0, rgba_from(p.overlay, 0.16), true), + shadow(1.0, 2.0, rgba_from(p.shadow, 0.55), false), + ] + ); + + let hover = facet(MoonFacetKind::RaisedHover, &tokens_for(p)); + assert_eq!(hover.edge, rgba_from(p.overlay, 0.2)); + assert_eq!( + hover.shadows, raised.shadows, + "hover relights the fill and the edge, and leaves the shadows alone" + ); + assert_ne!(hover.fill, raised.fill); +} + +/// Catches `skin.rs:facet` reading the dark strengths on a light palette: a 55% black shadow +/// under every light-theme button, a black "highlight" along its top and a 9% black edge +/// where the palette's own border belongs. +#[test] +fn a_light_palette_takes_its_own_borders_white_highlights_and_faint_shadows() { + let p = MoonPalette::LIGHT; + let tokens = tokens_for(p); + + let raised = facet(MoonFacetKind::Raised, &tokens); + assert_eq!(raised.edge, rgba_from(p.border, 1.0)); + assert_eq!( + raised.shadows, + vec![ + shadow(1.0, 0.0, rgba_from(WHITE, 0.9), true), + shadow(1.0, 2.0, rgba_from(p.shadow, 0.12), false), + ] + ); + + assert_eq!( + facet(MoonFacetKind::RaisedHover, &tokens).edge, + rgba_from(p.border_hover, 1.0) + ); + assert_eq!( + facet(MoonFacetKind::Popup, &tokens).shadows, + vec![ + shadow(1.0, 0.0, rgba_from(WHITE, 0.9), true), + shadow(28.0, 60.0, rgba_from(p.shadow, 0.16), false), + shadow(6.0, 16.0, rgba_from(p.shadow, 0.10), false), + ] + ); + assert_eq!( + facet(MoonFacetKind::Well, &tokens).shadows, + vec![ + shadow(1.0, 3.0, rgba_from(p.shadow, 0.12), true), + shadow(1.0, 0.0, rgba_from(WHITE, 0.8), false), + ] + ); +} + +/// Catches turning a well into one more raised surface in `skin.rs:facet`: an input would be +/// lit along its top like the button beside it instead of reading as sunk into the panel. +#[test] +fn a_well_is_darkest_at_the_top_and_carries_its_shadow_inside() { + let p = MoonPalette::OBSIDIAN; + let well = facet(MoonFacetKind::Well, &tokens_for(p)); + + // `gutter` 0x08090B sunk by 15%: 6.8, 7.65 and 9.35, rounded. + const WELL_TOP: u32 = 0x070809; + assert_eq!(well.fill, top_to_bottom(WELL_TOP, p.gutter)); + assert_eq!(well.edge, rgba_from(p.overlay, 0.06)); + assert_eq!( + well.shadows, + vec![ + shadow(1.0, 3.0, rgba_from(p.shadow, 0.7), true), + shadow(1.0, 0.0, rgba_from(p.overlay, 0.07), false), + ] + ); +} + +/// Catches a socket losing its stronger edge in `skin.rs:facet`, or drifting from the well it +/// is cut from: an unchecked checkbox would vanish into its card, or stop matching the input +/// beside it. +#[test] +fn a_socket_is_a_well_under_an_edge_that_reads_at_small_sizes() { + for p in [MoonPalette::OBSIDIAN, MoonPalette::LIGHT] { + let tokens = tokens_for(p); + let well = facet(MoonFacetKind::Well, &tokens); + let socket = facet(MoonFacetKind::Socket, &tokens); + + assert_eq!(socket.fill, well.fill); + assert_eq!(socket.shadows, well.shadows); + let expected_edge = if p.is_light() { + rgba_from(p.border_hover, 1.0) + } else { + rgba_from(p.overlay, 0.17) + }; + assert_eq!(socket.edge, expected_edge); + assert_ne!(socket.edge, well.edge); + } +} + +/// Catches `skin.rs:MoonFacet::paint` dropping one of the three parts of a surface, or setting +/// a border width or radius of its own: a component painting through it would lose its edge or +/// shadow, or have its frame resized by the skin. +#[test] +fn painting_a_facet_sets_its_fill_edge_and_shadows_and_nothing_else() { + use gpui::{Fill, StyleRefinement}; + + let surface = facet(MoonFacetKind::Raised, &tokens_for(MoonPalette::OBSIDIAN)); + let painted = surface.clone().paint(StyleRefinement::default()); + + assert_eq!(painted.background, Some(Fill::from(surface.fill))); + assert_eq!(painted.border_color, Some(surface.edge)); + assert_eq!(painted.box_shadow, Some(surface.shadows)); + assert_eq!( + StyleRefinement { + background: None, + border_color: None, + box_shadow: None, + ..painted + }, + StyleRefinement::default() + ); +} + +/// Catches a light palette's well taking the dark palette's depth in `skin.rs:well_sink`: 15% of +/// black on a near-white floor turns every input and groove into a slab of grey. +#[test] +fn a_light_well_sinks_a_fraction_of_what_a_dark_one_does() { + let p = MoonPalette::LIGHT; + let well = facet(MoonFacetKind::Well, &tokens_for(p)); + + // `gutter` 0xEEF2F6 sunk by 4%: 228.48, 232.32 and 236.16, rounded. + const WELL_TOP: u32 = 0xE4E8EC; + assert_eq!(well.fill, top_to_bottom(WELL_TOP, p.gutter)); +} + +/// Catches a facet shadow built from a raw `px(..)` instead of `tokens.ui(..)`: at a doubled UI +/// scale the control doubles and its shadow, glow or backdrop light would stay the old size. +#[test] +fn facet_lengths_follow_the_ui_scale() { + let p = MoonPalette::OBSIDIAN; + let tokens = doubled_tokens_for(p); + + // Each length is the design's, doubled: the slab's `0 18px 40px` and `0 2px 6px` shadows, + // and a glow blurred by 18. + assert_eq!( + facet(MoonFacetKind::Slab, &tokens).shadows, + vec![ + shadow(2.0, 0.0, rgba_from(p.overlay, 0.12), true), + shadow(36.0, 80.0, rgba_from(p.shadow, 0.65), false), + shadow(4.0, 12.0, rgba_from(p.shadow, 0.5), false), + ] + ); + assert_eq!( + facet_glow(p.blue, 0.26, 18.0, &tokens), + shadow(0.0, 36.0, rgba_from(p.blue, 0.26), false), + "a glow has no offset and no spread, and its design blur is scaled" + ); + // The light written out: 260 design pixels of blur, doubled and then halved for the renderer, + // around 140 of spread, doubled only. + assert_eq!( + ambient_light(p.accent, 0.10, &tokens), + BoxShadow { + color: rgba_from(p.accent, 0.10), + offset: point(px(0.0), px(0.0)), + blur_radius: px(260.0), + spread_radius: px(280.0), + inset: false, + } + ); +} + +/// Catches `skin.rs:renderer_blur` passing a design blur through as it is, or the conversion +/// reaching an offset or a spread. A blur of the design is a CSS `box-shadow` blur, twice the +/// gaussian's standard deviation that the renderer takes as its blur radius: unhalved, every +/// glow and shadow spreads twice as wide and arrives much fainter than designed. +#[test] +fn a_design_blur_reaches_the_renderer_halved_and_offsets_and_spread_do_not() { + let p = MoonPalette::OBSIDIAN; + let tokens = tokens_for(p); + + // A glow the design blurs by 18. + assert_eq!( + facet_glow(p.accent, 0.26, 18.0, &tokens), + BoxShadow { + color: rgba_from(p.accent, 0.26), + offset: point(px(0.0), px(0.0)), + blur_radius: px(9.0), + spread_radius: px(0.0), + inset: false, + } + ); + + // The slab's long shadow, `0 18px 40px` in the design: 18 down still, blurred by 20. + assert_eq!( + facet(MoonFacetKind::Slab, &tokens).shadows[1], + BoxShadow { + color: rgba_from(p.shadow, 0.65), + offset: point(px(0.0), px(18.0)), + blur_radius: px(20.0), + spread_radius: px(0.0), + inset: false, + } + ); + + // A backdrop light, `0 0 260px 140px` in the design: spread by 140 still, blurred by 130. + assert_eq!( + ambient_light(p.blue, 0.08, &tokens), + BoxShadow { + color: rgba_from(p.blue, 0.08), + offset: point(px(0.0), px(0.0)), + blur_radius: px(130.0), + spread_radius: px(140.0), + inset: false, + } + ); +} + +/// Catches `skin.rs:facet_tinted` mixing the tone over the wrong base or with the two shares +/// swapped, and catches a selected surface losing the glow that tells it from a soft one. +#[test] +fn tinted_facets_wash_the_panel_with_the_tone_and_only_selected_and_solid_glow() { + let p = MoonPalette::OBSIDIAN; + let tokens = tokens_for(p); + let tone = p.accent; + // `panel` 0x171A1F with `accent` 0xFFB347 mixed in at 14%, 6%, 20% and 8%. + const SOFT_TOP: u32 = 0x372F25; + const SOFT_BOTTOM: u32 = 0x252321; + const SELECTED_TOP: u32 = 0x453927; + const SELECTED_BOTTOM: u32 = 0x2A2622; + // `accent` lifted by 25%, sunk by 6% and sunk by 20%. + const SOLID_TOP: u32 = 0xFFC675; + const SOLID_BOTTOM: u32 = 0xF0A843; + const SOLID_EDGE: u32 = 0xCC8F39; + + let soft = facet_tinted(tone, MoonFacetTint::Soft, &tokens); + assert_eq!(soft.fill, top_to_bottom(SOFT_TOP, SOFT_BOTTOM)); + assert_eq!(soft.edge, rgba_from(tone, 0.42)); + assert_eq!( + soft.shadows, + vec![ + shadow(1.0, 0.0, rgba_from(p.overlay, 0.10), true), + shadow(1.0, 2.0, rgba_from(p.shadow, 0.55), false), + ] + ); + + let selected = facet_tinted(tone, MoonFacetTint::Selected, &tokens); + assert_eq!(selected.fill, top_to_bottom(SELECTED_TOP, SELECTED_BOTTOM)); + assert_eq!(selected.edge, rgba_from(tone, 0.7)); + let mut soft_and_glow = soft.shadows.clone(); + soft_and_glow.push(shadow(0.0, 18.0, rgba_from(tone, 0.26), false)); + assert_eq!(selected.shadows, soft_and_glow); + + let solid = facet_tinted(tone, MoonFacetTint::Solid, &tokens); + assert_eq!(solid.fill, top_to_bottom(SOLID_TOP, SOLID_BOTTOM)); + assert_eq!(solid.edge, rgba_from(SOLID_EDGE, 1.0)); + assert_eq!( + solid.shadows, + vec![ + shadow(1.0, 0.0, rgba_from(WHITE, 0.45), true), + shadow(1.0, 2.0, rgba_from(p.shadow, 0.55), false), + shadow(0.0, 18.0, rgba_from(tone, 0.32), false), + ] + ); + + let light = MoonPalette::LIGHT; + let solid_on_light = facet_tinted(light.accent, MoonFacetTint::Solid, &tokens_for(light)); + assert_eq!( + solid_on_light.shadows, + vec![ + shadow(1.0, 0.0, rgba_from(WHITE, 0.45), true), + shadow(1.0, 2.0, rgba_from(light.shadow, 0.12), false), + shadow(0.0, 18.0, rgba_from(light.accent, 0.2), false), + ], + "a light palette keeps the white highlight and takes a fainter shadow and glow" + ); +} + +/// Catches swapping the two halves in `skin.rs:fading_halves`: the rule would be solid at both +/// ends and vanish in the middle, a gap where the line belongs. +#[test] +fn a_fading_rule_rises_to_its_colour_in_the_middle_and_falls_away_again() { + let color = rgba_from(MoonPalette::OBSIDIAN.accent, 0.6); + let clear = Hsla { a: 0.0, ..color }; + + for angle in [90.0, 180.0] { + assert_eq!( + fading_halves(color, angle), + [ + linear_gradient( + angle, + linear_color_stop(clear, 0.0), + linear_color_stop(color, 1.0) + ), + linear_gradient( + angle, + linear_color_stop(color, 0.0), + linear_color_stop(clear, 1.0) + ), + ] + ); + } +} + +/// Catches the backdrop fill losing its top light, or carrying it all the way down the window +/// so that the whole window is lifted off `shell` instead of only its upper part. +#[test] +fn the_backdrop_fill_is_lit_at_the_top_and_plain_shell_from_halfway_down() { + let p = MoonPalette::OBSIDIAN; + // `shell` 0x050608 lifted by 3.5%: 13.75, 14.715 and 16.645, rounded. + const LIT_SHELL: u32 = 0x0E0F11; + + assert_eq!( + backdrop_fill(p), + linear_gradient( + 180.0, + linear_color_stop(rgba_from(LIT_SHELL, 1.0), 0.0), + linear_color_stop(rgba_from(p.shell, 1.0), 0.5), + ) + ); +} + +/// Catches `skin.rs:moon_backdrop` ignoring the skin: every flat theme would get a gradient +/// window fill and two coloured lights it never asked for. +#[test] +fn flat_tokens_have_no_backdrop() { + for palette in [ + MoonPalette::TERMINAL, + MoonPalette::LIGHT, + MoonPalette::OBSIDIAN, + ] { + assert!(moon_backdrop(&tokens_for(palette)).is_none()); + } +} + +/// Catches `skin.rs:MoonSkin::active` reading the configured dark side instead of the active +/// one, or defaulting to Facet with no theme: components would draw facets on a flat theme. +#[gpui::test] +fn the_active_skin_is_the_installed_themes_active_side(cx: &mut TestAppContext) { + cx.update(|cx| assert_eq!(MoonSkin::active(cx), MoonSkin::Flat, "no theme installed")); + + cx.update(crate::init); + cx.update(|cx| MoonTheme::install_config(MoonThemeConfig::moon_obsidian(), cx)); + cx.update(|cx| assert_eq!(MoonSkin::active(cx), MoonSkin::Facet)); + + cx.update(|cx| MoonTheme::set_mode(ThemeMode::Light, cx)); + cx.update(|cx| { + assert_eq!( + MoonSkin::active(cx), + MoonSkin::Flat, + "Obsidian's light side is flat" + ) + }); + + cx.update(|cx| MoonTheme::install_config(MoonThemeConfig::moon_terminal(), cx)); + cx.update(|cx| assert_eq!(MoonSkin::active(cx), MoonSkin::Flat)); +} + +/// The content view of the probe windows below; it paints nothing. +struct Blank; + +impl Render for Blank { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + } +} + +/// Open a window on a `MoonRoot` with `policy` and return where its backdrop was painted and +/// the size of the window it was painted in. +fn backdrop_in_a_root( + policy: MoonBackgroundPolicy, + cx: &mut TestAppContext, +) -> (Option>, gpui::Size) { + let (_root, cx) = cx.add_window_view(|window, cx| { + let view = cx.new(|_| Blank); + MoonRoot::new(view, window, cx) + .bordered(false) + .background_policy(policy) + }); + cx.run_until_parked(); + let viewport = cx.update(|window, _| window.viewport_size()); + (cx.debug_bounds("moon-backdrop"), viewport) +} + +/// Catches `root.rs:MoonRoot::render` painting the backdrop under the Flat skin, which would +/// change every existing theme, or under a root that paints no fill, where the opaque backdrop +/// would cover the GPU canvas such a root exists to show through. +#[gpui::test] +fn only_an_opaque_root_under_the_facet_skin_paints_the_backdrop(cx: &mut TestAppContext) { + cx.update(crate::init); + + cx.update(|cx| MoonTheme::install_config(MoonThemeConfig::moon_terminal(), cx)); + let (flat, _) = backdrop_in_a_root(MoonBackgroundPolicy::Opaque, cx); + assert_eq!(flat, None, "a flat theme's root has no backdrop element"); + + cx.update(|cx| MoonTheme::install_config(MoonThemeConfig::moon_obsidian(), cx)); + let (faceted, viewport) = backdrop_in_a_root(MoonBackgroundPolicy::Opaque, cx); + assert_eq!( + faceted, + Some(Bounds::new(point(px(0.0), px(0.0)), viewport)), + "the backdrop fills the window behind the content" + ); + + for policy in [ + MoonBackgroundPolicy::NoFill, + MoonBackgroundPolicy::Transparent, + ] { + let (see_through, _) = backdrop_in_a_root(policy, cx); + assert_eq!(see_through, None, "{policy:?} root must stay see-through"); + } + + cx.update(|cx| MoonTheme::set_mode(ThemeMode::Light, cx)); + let (light_side, _) = backdrop_in_a_root(MoonBackgroundPolicy::Opaque, cx); + assert_eq!(light_side, None, "Obsidian's light side is flat"); +} diff --git a/crates/moon-ui-components/src/moon/slider.rs b/crates/moon-ui-components/src/moon/slider.rs index eff3198..416d330 100644 --- a/crates/moon-ui-components/src/moon/slider.rs +++ b/crates/moon-ui-components/src/moon/slider.rs @@ -61,3 +61,6 @@ impl RenderOnce for MoonSlider { slider } } + +#[cfg(test)] +mod tests; diff --git a/crates/moon-ui-components/src/moon/slider/tests.rs b/crates/moon-ui-components/src/moon/slider/tests.rs new file mode 100644 index 0000000..a0d9746 --- /dev/null +++ b/crates/moon-ui-components/src/moon/slider/tests.rs @@ -0,0 +1,161 @@ +//! Guards the lengths and the thumb a slider draws under each skin, and that the skin never +//! changes the row a slider takes. + +use gpui::{ + AppContext as _, BoxShadow, Context, Entity, Hsla, InteractiveElement as _, IntoElement, + ParentElement as _, Render, Rgba, Styled as _, TestAppContext, VisualTestContext, Window, div, + point, px, +}; + +use super::{MoonSlider, MoonSliderState}; +use crate::moon::{MoonPalette, MoonSkin, MoonTheme, MoonThemeConfig, rgba_from}; +use crate::slider::{SliderMetrics, SliderThumbFacet}; + +/// A shadow with no horizontal offset and no spread, written out rather than taken from +/// `slider.rs`. +fn shadow(y: f32, blur: f32, color: Hsla) -> BoxShadow { + BoxShadow { + color, + offset: point(px(0.0), px(y)), + blur_radius: px(blur), + spread_radius: px(0.0), + inset: false, + } +} + +/// Catches the Flat skin picking up the groove's lengths or the faceted thumb: every flat +/// theme's slider must stay the hairline it was — a 2px track its fill covers whole, under a 7px +/// thumb — and resolve no faceted thumb, so the thumb paints its flat disc and glow alone. +#[test] +fn the_flat_skin_keeps_the_hairline_slider() { + assert_eq!( + SliderMetrics::for_skin(MoonSkin::Flat), + SliderMetrics { + track: px(2.), + fill: px(2.), + thumb: px(7.), + } + ); + assert_eq!(SliderMetrics::for_skin(MoonSkin::default()).thumb, px(7.)); + + for p in [ + MoonPalette::TERMINAL, + MoonPalette::LIGHT, + MoonPalette::OBSIDIAN, + ] { + for disabled in [false, true] { + assert_eq!( + SliderThumbFacet::resolve(MoonSkin::Flat, p, rgba_from(p.accent, 1.0), disabled), + None + ); + } + } +} + +/// Catches the Facet skin's slider drifting from its spec: the track becomes a 4px groove, the +/// fill keeps its 2px along the groove's middle, and the thumb grows to 10px. A fill as thick as +/// the groove would hide the groove; a thumb left at 7px would sit lost in it. +#[test] +fn the_facet_skin_sinks_a_groove_under_a_larger_thumb() { + assert_eq!( + SliderMetrics::for_skin(MoonSkin::Facet), + SliderMetrics { + track: px(4.), + fill: px(2.), + thumb: px(10.), + } + ); +} + +/// Catches the faceted thumb drifting from its spec: it runs from its colour mixed halfway to +/// white down to that colour, and casts a 1px-down, 2px-blur shadow at 60% under the 14px glow +/// at 72% the flat thumb has. A disabled thumb fades the fill to 42%, the shadow to 25% and the +/// glow to 14%; left at full strength it would look as usable as an enabled one. +#[test] +fn a_faceted_thumb_is_lit_from_above_and_keeps_the_flat_glow() { + let p = MoonPalette::OBSIDIAN; + let accent = rgba_from(p.accent, 1.0); + // The accent 0xFFB347 is (255, 179, 71); halfway to 255 is (255, 217, 163). + let lit_top = [255.0, 217.0, 163.0]; + + for (disabled, fill_alpha, drop_alpha, glow_alpha) in + [(false, 1.0, 0.6, 0.72), (true, 0.42, 0.25, 0.14)] + { + let thumb = + SliderThumbFacet::resolve(MoonSkin::Facet, p, accent, disabled).expect("faceted"); + + let top = Rgba::from(thumb.fill[0]); + for (level, expected) in [top.r, top.g, top.b].into_iter().zip(lit_top) { + assert!( + (level * 255.0 - expected).abs() < 0.1, + "thumb top {top:?} is not {lit_top:?}" + ); + } + assert!((top.a - fill_alpha).abs() < 1e-6, "disabled={disabled}"); + assert_eq!( + thumb.fill[1], + Hsla { + a: fill_alpha, + ..accent + }, + "disabled={disabled}" + ); + assert_eq!( + thumb.shadows, + vec![ + shadow(1.0, 2.0, rgba_from(p.shadow, drop_alpha)), + shadow( + 0.0, + 14.0, + Hsla { + a: glow_alpha, + ..accent + } + ), + ], + "disabled={disabled}" + ); + } +} + +/// A slider in a fixed-width column, inside a probe that measures the row it takes. +struct SliderHarness { + state: Entity, +} + +impl Render for SliderHarness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div().w(px(200.)).child( + div() + .debug_selector(|| "slider-row".into()) + .flex() + .child(MoonSlider::new(&self.state)), + ) + } +} + +/// Catches the Facet skin growing the row a slider takes — a container sized from the larger +/// thumb or the thicker groove instead of the fixed 18px hit area: under the faceted theme the +/// slider must take exactly the 200x18 row it takes under a flat one, so a form never reflows +/// when the theme changes. +#[gpui::test] +fn a_faceted_slider_takes_the_same_row_as_a_flat_one(cx: &mut TestAppContext) { + cx.update(crate::init); + for (config, faceted) in [ + (MoonThemeConfig::moon_terminal(), false), + (MoonThemeConfig::moon_obsidian(), true), + ] { + cx.update(|cx| MoonTheme::install_config(config, cx)); + cx.update(|cx| assert_eq!(MoonSkin::active(cx).is_facet(), faceted)); + + let window = cx.add_window(|_, cx| SliderHarness { + state: cx.new(|_| MoonSliderState::new().default_value(40.)), + }); + let mut cx = VisualTestContext::from_window(window.into(), cx); + cx.run_until_parked(); + + let row = cx.debug_bounds("slider-row").expect("slider must render"); + assert_eq!(row.size.width, px(200.), "faceted={faceted}"); + assert_eq!(row.size.height, px(18.), "faceted={faceted}"); + } +} diff --git a/crates/moon-ui-components/src/moon/status_bar.rs b/crates/moon-ui-components/src/moon/status_bar.rs index 45bb040..f398905 100644 --- a/crates/moon-ui-components/src/moon/status_bar.rs +++ b/crates/moon-ui-components/src/moon/status_bar.rs @@ -3,10 +3,12 @@ use std::fmt; use crate::status_bar::StatusBar as CoreStatusBar; +use gpui::prelude::FluentBuilder as _; use gpui::*; use super::{ foundation::MoonClickHandler, + skin::fading_rule_h, text::MoonText, theme::{MoonTheme, MoonThemeTokens}, tokens::{MoonPalette, MoonRect, MoonTone, rgba_from}, @@ -317,6 +319,18 @@ impl MoonStatusBar { self.render_with_theme(p, MoonThemeTokens::default()) } + /// Render the bar with an explicit palette and theme tokens. + /// + /// Under the Flat skin the bar paints its fill and a solid top border. Under the Facet skin + /// it paints no fill and a rule along its top that fades out toward both ends; see + /// [`status_bar_facet_rule`]. + /// + /// Args: + /// p: Palette used for the bar and its items. + /// tokens: Tokens used for the bar's geometry, its text and the theme skin. + /// + /// Returns: + /// The rendered status bar. pub fn render_with_theme(self, p: MoonPalette, tokens: MoonThemeTokens) -> impl IntoElement { let Self { id, @@ -401,6 +415,10 @@ impl MoonStatusBar { &tokens, ); + // `None` is the Flat skin. Under the Facet skin the bar paints no fill, and its top + // border keeps its pixel of height but stops painting: the fading rule added below + // takes that row. + let facet_rule = status_bar_facet_rule(p, &tokens); let mut status = CoreStatusBar::new() .left(left_row) .h(px(height)) @@ -408,8 +426,14 @@ impl MoonStatusBar { .py(px(0.0)) .px(px(0.0)) .gap(px(0.0)) - .bg(rgba_from(bg, 1.0)) - .border_color(rgba_from(border, 1.0)); + .map(|this| match facet_rule { + Some(_) => this + .bg(transparent_black()) + .border_color(transparent_black()), + None => this + .bg(rgba_from(bg, 1.0)) + .border_color(rgba_from(border, 1.0)), + }); if !right_items.is_empty() { let right_row = Self::render_items( @@ -426,7 +450,19 @@ impl MoonStatusBar { status = status.right(right_row); } - root.child(status) + let root = root.child(status); + match facet_rule { + Some(color) => root.child( + div() + .absolute() + .left_0() + .right_0() + .top_0() + .h(px(1.0)) + .child(fading_rule_h(color)), + ), + None => root, + } } /// Render ordered text and separator items into one edge region. @@ -545,6 +581,25 @@ impl MoonStatusBar { } } +/// Alpha of the `overlay` ink at the middle of a faceted status bar's top rule. +const FACET_STATUS_RULE_ALPHA: f32 = 0.09; + +/// Resolve the colour of the fading rule a status bar draws along its top under the Facet skin. +/// +/// Args: +/// p: Palette the bar paints with. +/// tokens: Tokens the bar renders with; supply the skin. +/// +/// Returns: +/// The colour at the middle of the rule, or `None` under the Flat skin, where the bar keeps +/// its solid fill and its solid top border. +fn status_bar_facet_rule(p: MoonPalette, tokens: &MoonThemeTokens) -> Option { + tokens + .skin + .is_facet() + .then(|| rgba_from(p.overlay, FACET_STATUS_RULE_ALPHA)) +} + impl RenderOnce for MoonStatusBar { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let tokens = MoonTheme::active_tokens(cx); diff --git a/crates/moon-ui-components/src/moon/status_bar/tests.rs b/crates/moon-ui-components/src/moon/status_bar/tests.rs index c332aea..d771e19 100644 --- a/crates/moon-ui-components/src/moon/status_bar/tests.rs +++ b/crates/moon-ui-components/src/moon/status_bar/tests.rs @@ -106,3 +106,47 @@ fn interactive_text_item_dispatches_click_without_external_hitbox(cx: &mut gpui: assert_status_item_interactions(cx, palette); } } + +// ---- Theme skin: what the bar paints behind its items ---- + +use super::status_bar_facet_rule; +use crate::moon::{MoonSkin, MoonThemeTokens, rgba_from}; + +/// Theme tokens for `palette` drawn with `skin`, at the default scale. +fn skin_tokens(palette: MoonPalette, skin: MoonSkin) -> MoonThemeTokens { + MoonThemeTokens { + palette, + skin, + ..MoonThemeTokens::default() + } +} + +/// Catches `status_bar.rs:status_bar_facet_rule` ignoring the skin: every flat theme's status +/// bar would lose its `shell_high` fill and its solid top border, which `render_with_theme` +/// keeps only while this answers `None`. +#[test] +fn a_flat_status_bar_keeps_its_fill_and_top_border() { + for palette in [ + MoonPalette::TERMINAL, + MoonPalette::LIGHT, + MoonPalette::OBSIDIAN, + ] { + assert_eq!( + status_bar_facet_rule(palette, &skin_tokens(palette, MoonSkin::Flat)), + None + ); + } +} + +/// Catches `status_bar.rs:status_bar_facet_rule` drawing the rule at the flat border's full +/// strength, or in another ink: a faceted bar would carry a hard line across the bottom of a +/// window whose other lines fade at their ends. +#[test] +fn a_faceted_status_bar_draws_a_faint_rule_in_the_overlay_ink() { + let p = MoonPalette::OBSIDIAN; + + assert_eq!( + status_bar_facet_rule(p, &skin_tokens(p, MoonSkin::Facet)), + Some(rgba_from(p.overlay, 0.09)) + ); +} diff --git a/crates/moon-ui-components/src/moon/tab.rs b/crates/moon-ui-components/src/moon/tab.rs index 708431f..c66d215 100644 --- a/crates/moon-ui-components/src/moon/tab.rs +++ b/crates/moon-ui-components/src/moon/tab.rs @@ -11,6 +11,7 @@ use super::button::{MoonButtonSize, MoonButtonVariant}; use super::dropdown::{MoonDropdown, MoonMenuItem}; use super::foundation::{MoonSize, accent_underline, h_flex}; use super::icons::MOON_ICON_CARET_DOWN; +use super::skin::fading_rule_h; use super::text::MoonText; use super::theme::{MoonTheme, MoonThemeTokens}; use super::tokens::{MoonPalette, MoonRect, rgba_from}; @@ -73,6 +74,7 @@ pub struct MoonTabStrip { padding_left: f32, gap: f32, overflow_menu: bool, + bottom_rule: bool, on_click: Option, on_close: Option, } @@ -86,6 +88,7 @@ impl MoonTabStrip { padding_left: 20.0, gap: 8.0, overflow_menu: false, + bottom_rule: true, on_click: None, on_close: None, } @@ -117,6 +120,16 @@ impl MoonTabStrip { self } + /// Choose whether the strip draws the line along its bottom edge. Default `true`. + /// + /// A host that seats the strip in a wider row and rules that whole row itself turns the + /// strip's own line off, so the row carries one rule rather than two laid over each other + /// across the strip's slot. + pub fn bottom_rule(mut self, bottom_rule: bool) -> Self { + self.bottom_rule = bottom_rule; + self + } + pub fn item(mut self, item: MoonTabItem) -> Self { self.items.push(item); self @@ -171,7 +184,8 @@ impl MoonTabStrip { /// window: Window used to persist the scroll handle and tab bounds. /// cx: App context used to read keyed state. /// p: Palette used for the strip and item states. - /// tokens: Scale tokens used for tab geometry. + /// tokens: Tokens used for tab geometry and for the theme skin: under the Facet skin + /// the strip paints no fill and its bottom line fades out toward both ends. /// /// Returns: /// The themed tab-strip element. @@ -222,6 +236,10 @@ impl MoonTabStrip { prev_selected.update(cx, |value, _| *value = selected_ix); } + // Under the Facet skin the strip sits on a lit surface: it paints no fill of its own and + // its bottom line fades out toward both ends. `None` is the Flat skin, which keeps the + // solid fill and the solid line. + let facet_rule = strip_facet_rule(p, &tokens); let mut root = div() .id(self.id) .relative() @@ -231,16 +249,23 @@ impl MoonTabStrip { .min_w_0() .w_full() .h(px(tab_h)) - .bg(rgba_from(p.shell_high, 1.0)) - .child( - div() - .absolute() - .left(px(0.0)) - .bottom(px(0.0)) - .w_full() - .h(px(1.0)) - .bg(rgba_from(p.border, 0.78)), - ); + .when(facet_rule.is_none(), |this| { + this.bg(rgba_from(p.shell_high, 1.0)) + }) + .when(self.bottom_rule, |this| { + this.child( + div() + .absolute() + .left(px(0.0)) + .bottom(px(0.0)) + .w_full() + .h(px(1.0)) + .map(|line| match facet_rule { + Some(color) => line.child(fading_rule_h(color)), + None => line.bg(rgba_from(p.border, 0.78)), + }), + ) + }); if let Some(bounds) = self.bounds { root = root.max_w(px(bounds.w)).max_h(px(bounds.h)); @@ -307,6 +332,26 @@ impl RenderOnce for MoonTabStrip { } } +/// Alpha of the `overlay` ink at the middle of a faceted strip's bottom rule. +const FACET_STRIP_RULE_ALPHA: f32 = 0.07; + +/// Resolve the colour of the fading rule a tab strip draws along its bottom under the Facet +/// skin. +/// +/// Args: +/// p: Palette the strip paints with. +/// tokens: Tokens the strip renders with; supply the skin. +/// +/// Returns: +/// The colour at the middle of the rule, or `None` under the Flat skin, where the strip +/// keeps its solid fill and its solid bottom line. +fn strip_facet_rule(p: MoonPalette, tokens: &MoonThemeTokens) -> Option { + tokens + .skin + .is_facet() + .then(|| rgba_from(p.overlay, FACET_STRIP_RULE_ALPHA)) +} + fn render_moon_tab( ix: usize, item: MoonTabItem, @@ -490,3 +535,6 @@ pub fn moon_active_tab_underline(p: MoonPalette) -> Div { pub fn moon_active_tab_underline_scaled(p: MoonPalette, tokens: MoonThemeTokens) -> Div { accent_underline(p, &tokens, 5.0, 5.0, 0.0) } + +#[cfg(test)] +mod tests; diff --git a/crates/moon-ui-components/src/moon/tab/tests.rs b/crates/moon-ui-components/src/moon/tab/tests.rs new file mode 100644 index 0000000..c8e04dd --- /dev/null +++ b/crates/moon-ui-components/src/moon/tab/tests.rs @@ -0,0 +1,45 @@ +//! Guards what a tab strip paints under each theme skin. + +use super::strip_facet_rule; +use crate::moon::{MoonPalette, MoonSkin, MoonThemeTokens, rgba_from}; + +/// Theme tokens for `palette` drawn with `skin`, at the default scale. +fn skin_tokens(palette: MoonPalette, skin: MoonSkin) -> MoonThemeTokens { + MoonThemeTokens { + palette, + skin, + ..MoonThemeTokens::default() + } +} + +/// Catches `tab.rs:strip_facet_rule` ignoring the skin: every flat theme would lose the strip's +/// `shell_high` fill and its solid bottom line, which `render_with_theme` keeps only while this +/// answers `None`. +#[test] +fn a_flat_strip_keeps_its_fill_and_solid_line() { + for palette in [ + MoonPalette::TERMINAL, + MoonPalette::LIGHT, + MoonPalette::GRAPHITE, + MoonPalette::OBSIDIAN, + ] { + assert_eq!( + strip_facet_rule(palette, &skin_tokens(palette, MoonSkin::Flat)), + None + ); + } +} + +/// Catches `tab.rs:strip_facet_rule` taking the palette from the tokens instead of the palette +/// the strip was asked to paint with, or drawing the rule at the flat line's strength: a +/// faceted strip would carry a hard line across a surface that is meant to fade at its ends. +#[test] +fn a_faceted_strip_draws_a_faint_rule_in_the_overlay_ink_of_its_own_palette() { + let tokens = skin_tokens(MoonPalette::TERMINAL, MoonSkin::Facet); + let p = MoonPalette::OBSIDIAN; + + assert_eq!( + strip_facet_rule(p, &tokens), + Some(rgba_from(p.overlay, 0.07)) + ); +} diff --git a/crates/moon-ui-components/src/moon/table.rs b/crates/moon-ui-components/src/moon/table.rs index 60603b8..bc85d1c 100644 --- a/crates/moon-ui-components/src/moon/table.rs +++ b/crates/moon-ui-components/src/moon/table.rs @@ -301,6 +301,52 @@ impl MoonTableStyle { selection_bar_width: self.selection_bar_width, } } + + /// Resolve what a table header paints under the Facet skin. + /// + /// A faceted table sits on a lit surface, so the header drops the palette's own header fill + /// and takes a quieter rule under it. A colour the caller chose — one that differs from the + /// palette's own table style — is kept as given. + /// + /// Args: + /// p: Palette this style was themed for. + /// tokens: Active theme tokens; supply the skin. + /// + /// Returns: + /// The header's faceted look, or `None` under the Flat skin, where the header paints + /// `header_bg` and `header_separator` as it always has. + pub(crate) fn header_facet( + &self, + p: MoonPalette, + tokens: &MoonThemeTokens, + ) -> Option { + if !tokens.skin.is_facet() { + return None; + } + let own = Self::for_palette(p); + let own_separator = self.header_separator == own.header_separator + && self.header_separator_alpha == own.header_separator_alpha; + Some(TableHeaderFacet { + fill: (self.header_bg != own.header_bg).then(|| rgba_from(self.header_bg, 1.0)), + separator: if own_separator { + rgba_from(p.overlay, FACET_HEADER_SEPARATOR_ALPHA) + } else { + rgba_from(self.header_separator, self.header_separator_alpha) + }, + }) + } +} + +/// Alpha of the `overlay` ink a faceted table header draws its separators with. +const FACET_HEADER_SEPARATOR_ALPHA: f32 = 0.07; + +/// What a table header paints under the Facet skin. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct TableHeaderFacet { + /// Fill of the header; `None` leaves it unpainted, so the surface under the table shows. + pub(crate) fill: Option, + /// Colour of the rule under the header and of the row-header gutter's edge. + pub(crate) separator: Hsla, } pub(crate) struct MoonTable; diff --git a/crates/moon-ui-components/src/moon/tag.rs b/crates/moon-ui-components/src/moon/tag.rs index c3b9fe0..b3e62d5 100644 --- a/crates/moon-ui-components/src/moon/tag.rs +++ b/crates/moon-ui-components/src/moon/tag.rs @@ -31,6 +31,13 @@ const DIMMED_OPACITY: f32 = 0.65; /// Design-reference width of the accent ring around a selected clickable tag. const SELECTED_RING_WIDTH: f32 = 2.0; +/// Opacity of the tone at the top edge of the wash an outline tag or badge carries under the +/// Facet skin. +const FACET_WASH_TOP_ALPHA: f32 = 0.14; + +/// Opacity of the tone at the bottom edge of that wash. +const FACET_WASH_BOTTOM_ALPHA: f32 = 0.04; + /// How a [`MoonTag`] paints its tone. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum MoonTagVariant { @@ -309,6 +316,46 @@ pub(super) fn tone_wash(tone: MoonTone, p: MoonPalette) -> (u32, u32) { (fill, soft_label(tone, color, fill, p)) } +/// Build the faint vertical wash of a tone that lights an outlined chip from above. +/// +/// Shared by the outline tag and the outline badge, which carry it under the Facet skin only. +/// +/// Args: +/// color: The tone as `0xRRGGBB`. +/// strength: A share from 0.0 to 1.0 that both ends of the wash are scaled by, for a chip +/// that fades its paint, such as a disabled badge. +/// +/// Returns: +/// A gradient running from the tone at [`FACET_WASH_TOP_ALPHA`] along the top edge down to +/// the tone at [`FACET_WASH_BOTTOM_ALPHA`] along the bottom edge. +pub(super) fn facet_outline_wash(color: u32, strength: f32) -> Background { + linear_gradient( + 180.0, + linear_color_stop(rgba_from(color, FACET_WASH_TOP_ALPHA * strength), 0.0), + linear_color_stop(rgba_from(color, FACET_WASH_BOTTOM_ALPHA * strength), 1.0), + ) +} + +/// Resolve the wash a tag paints over its flat background. +/// +/// Args: +/// tokens: Active theme tokens (skin and palette). +/// tone: The tag's tone. +/// variant: How the tone is painted. +/// +/// Returns: +/// The tone wash for an outline tag under the Facet skin. `None` under the Flat skin and +/// for the soft, filled and tinted variants, which keep the flat background of +/// [`tag_chrome`]. +fn tag_wash( + tokens: &MoonThemeTokens, + tone: MoonTone, + variant: MoonTagVariant, +) -> Option { + (tokens.skin.is_facet() && variant == MoonTagVariant::Outline) + .then(|| facet_outline_wash(tone.color(tokens.palette), 1.0)) +} + /// Resolve a tag's paint and geometry. /// /// The outline variant reproduces the original tag exactly; the label contrast of the soft and @@ -412,10 +459,13 @@ fn count_color(tone: MoonTone, variant: MoonTagVariant, p: MoonPalette) -> u32 { } impl RenderOnce for MoonTag { + /// Render the tag with its flat chrome and, for an outline tag under the Facet skin, the + /// tone wash of [`tag_wash`] over its transparent fill. fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { let tokens = MoonTheme::active_tokens(cx); let text = tokens.text(11.0, 14.0); let chrome = tag_chrome(&tokens, self.tone, self.variant, self.radius, self.compact); + let wash = tag_wash(&tokens, self.tone, self.variant); let p = tokens.palette; let count_color = count_color(self.tone, self.variant, p); @@ -429,6 +479,7 @@ impl RenderOnce for MoonTag { .border_1() .border_color(chrome.border) .bg(chrome.background) + .when_some(wash, |this, wash| this.bg(wash)) .text_color(rgba_from( if self.dimmed { p.text } else { chrome.text }, 1.0, diff --git a/crates/moon-ui-components/src/moon/tag/tests.rs b/crates/moon-ui-components/src/moon/tag/tests.rs index 3a64e14..831a04a 100644 --- a/crates/moon-ui-components/src/moon/tag/tests.rs +++ b/crates/moon-ui-components/src/moon/tag/tests.rs @@ -5,16 +5,16 @@ use std::{cell::Cell, rc::Rc}; use gpui::{ - Context, IntoElement, Modifiers, ParentElement as _, Render, VisualTestContext, Window, point, - px, + Context, IntoElement, Modifiers, ParentElement as _, Render, VisualTestContext, Window, + linear_color_stop, linear_gradient, point, px, }; use super::{ DIMMED_OPACITY, LABEL_CONTRAST_FLOOR, MoonTag, MoonTagRadius, MoonTagVariant, SOFT_FILL_ALPHA, - TagChrome, composite, contrast_ratio, count_color, tag_chrome, + TagChrome, composite, contrast_ratio, count_color, facet_outline_wash, tag_chrome, tag_wash, }; use crate::h_flex; -use crate::moon::{MoonPalette, MoonThemeTokens, MoonTone, rgba_from}; +use crate::moon::{MoonPalette, MoonSkin, MoonThemeTokens, MoonTone, rgba_from}; /// The status tones a soft tag is offered in, on both built-in palettes. const SOFT_TONES: [MoonTone; 4] = [ @@ -316,3 +316,168 @@ fn dimmed_label_stays_above_the_contrast_floor() { assert!(ratio >= LABEL_CONTRAST_FLOOR, "dimmed label: {ratio:.2}"); } } + +/// Every way a tag paints its tone. +const ALL_VARIANTS: [MoonTagVariant; 4] = [ + MoonTagVariant::Outline, + MoonTagVariant::Soft, + MoonTagVariant::Filled, + MoonTagVariant::Tinted, +]; + +/// Theme tokens painting `palette` with the Facet skin at the reference scale. +fn faceted_tokens_with(palette: MoonPalette) -> MoonThemeTokens { + MoonThemeTokens { + skin: MoonSkin::Facet, + ..tokens_with(palette) + } +} + +/// Catches `tag.rs:facet_outline_wash` running the wash upside down or at the wrong strength: +/// an outline chip lit from below, or a wash as heavy as a soft tag's fill. The oracle is the +/// depth study's `.d1 .tg` rule: the tone at 14% along the top edge down to 4% at the bottom. +#[test] +fn the_facet_outline_wash_fades_the_tone_from_the_top_edge_down() { + let tone = MoonPalette::OBSIDIAN.accent; + + assert_eq!( + facet_outline_wash(tone, 1.0), + linear_gradient( + 180.0, + linear_color_stop(rgba_from(tone, 0.14), 0.0), + linear_color_stop(rgba_from(tone, 0.04), 1.0), + ) + ); + assert_eq!( + facet_outline_wash(tone, 0.5), + linear_gradient( + 180.0, + linear_color_stop(rgba_from(tone, 0.07), 0.0), + linear_color_stop(rgba_from(tone, 0.02), 1.0), + ), + "a faded chip scales both ends of the wash" + ); +} + +/// Catches `tag.rs:tag_wash` answering for the Flat skin, which would put a fill behind every +/// outline tag of every existing theme, or for a soft, filled or tinted tag, whose own fill +/// the wash would replace. +#[test] +fn only_an_outline_tag_under_the_facet_skin_is_washed() { + for palette in [MoonPalette::OBSIDIAN, MoonPalette::LIGHT] { + let faceted = faceted_tokens_with(palette); + let flat = tokens_with(palette); + + for tone in SOFT_TONES { + assert_eq!( + tag_wash(&faceted, tone, MoonTagVariant::Outline), + Some(facet_outline_wash(tone.color(palette), 1.0)), + "outline {tone:?} takes the wash of its own tone" + ); + for variant in ALL_VARIANTS { + assert_eq!( + tag_wash(&flat, tone, variant), + None, + "{variant:?} {tone:?} is washed under the Flat skin" + ); + } + for variant in [ + MoonTagVariant::Soft, + MoonTagVariant::Filled, + MoonTagVariant::Tinted, + ] { + assert_eq!( + tag_wash(&faceted, tone, variant), + None, + "{variant:?} {tone:?} keeps its own fill under the Facet skin" + ); + } + } + } +} + +/// Catches `tag.rs:MoonTag::render` painting a fill behind an outline tag on a flat theme, not +/// painting the wash it resolves on the faceted one, or letting the wash replace the fill of a +/// soft, filled or tinted tag. The flat colours are the flat design's own, written out. +#[test] +fn only_the_outline_tag_of_the_faceted_theme_paints_a_wash() { + use super::super::button::scene_probe::{Painted, Pointer, painted}; + use crate::moon::MoonThemeConfig; + + let p = MoonPalette::OBSIDIAN; + let tone = MoonTone::Warning.color(p); + let obsidian_with = |skin: MoonSkin| { + let mut config = MoonThemeConfig::moon_obsidian(); + config.dark.skin = skin; + config + }; + let tag = |skin: MoonSkin, variant: MoonTagVariant| { + painted(obsidian_with(skin), Pointer::Away, move || { + MoonTag::warning().variant(variant).label("live") + }) + }; + let flat_outline = Painted::flat(rgba_from(tone, 0.0), rgba_from(tone, 0.48)); + assert_eq!(flat_outline.fill, None, "a flat outline tag has no fill"); + + assert_eq!(tag(MoonSkin::Flat, MoonTagVariant::Outline), flat_outline); + assert_eq!( + tag(MoonSkin::Facet, MoonTagVariant::Outline), + Painted { + fill: Some(linear_gradient( + 180.0, + linear_color_stop(rgba_from(tone, 0.14), 0.0), + linear_color_stop(rgba_from(tone, 0.04), 1.0), + )), + ..flat_outline + } + ); + + let unchanged = [ + ( + MoonTagVariant::Soft, + Painted::flat(rgba_from(tone, 0.14), rgba_from(tone, 0.0)), + ), + ( + MoonTagVariant::Filled, + Painted::flat(rgba_from(tone, 1.0), rgba_from(tone, 1.0)), + ), + ( + MoonTagVariant::Tinted, + Painted::flat(rgba_from(tone, 0.14), rgba_from(tone, 0.35)), + ), + ]; + for (variant, expected) in unchanged { + for skin in [MoonSkin::Flat, MoonSkin::Facet] { + assert_eq!(tag(skin, variant), expected, "{variant:?} under {skin:?}"); + } + } +} + +/// Catches `tag.rs:tag_chrome` reading the skin: the border, label, radius, padding and row +/// height of a tag are the same under both skins, so the Facet skin only ever adds the wash. +#[test] +fn the_facet_skin_leaves_the_tag_chrome_unchanged() { + for palette in [MoonPalette::OBSIDIAN, MoonPalette::LIGHT] { + for tone in SOFT_TONES { + for variant in ALL_VARIANTS { + assert_eq!( + tag_chrome( + &faceted_tokens_with(palette), + tone, + variant, + MoonTagRadius::Full, + false + ), + tag_chrome( + &tokens_with(palette), + tone, + variant, + MoonTagRadius::Full, + false + ), + "{variant:?} {tone:?}" + ); + } + } + } +} diff --git a/crates/moon-ui-components/src/moon/theme.rs b/crates/moon-ui-components/src/moon/theme.rs index 727b1ce..7fc9268 100644 --- a/crates/moon-ui-components/src/moon/theme.rs +++ b/crates/moon-ui-components/src/moon/theme.rs @@ -7,6 +7,7 @@ use super::{ colors::MoonColors, foundation::selected_flat, foundation::{MoonControlMetrics, MoonSize, ThemeMode}, + skin::MoonSkin, tokens::{MoonMetrics, MoonPalette, rgba_from}, }; use crate::theme::{Theme as BaseTheme, ThemeColor, ThemeMode as BaseThemeMode}; @@ -75,6 +76,12 @@ impl Default for MoonTypography { #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default)] pub struct MoonThemeTokens { + /// The manner in which this side draws depth; [`MoonSkin::Flat`] unless the theme names one. + /// + /// Declared ahead of the tables below: in TOML a table's plain values come before its + /// sub-tables, and some serializers write fields in declaration order and reject a plain + /// value that follows a table. + pub skin: MoonSkin, pub palette: MoonPalette, pub scale: MoonScale, pub metrics: MoonMetrics, @@ -92,6 +99,7 @@ pub struct MoonThemeTokens { impl Default for MoonThemeTokens { fn default() -> Self { Self { + skin: MoonSkin::Flat, palette: MoonPalette::TERMINAL, scale: MoonScale::default(), metrics: MoonMetrics::TERMINAL, @@ -383,6 +391,7 @@ impl Default for MoonThemeConfig { mode: ThemeMode::Dark, dark: MoonThemeTokens::default(), light: MoonThemeTokens { + skin: MoonSkin::Flat, palette: MoonPalette::LIGHT, scale: MoonScale::default(), metrics: MoonMetrics::TERMINAL, @@ -427,6 +436,18 @@ impl MoonThemeConfig { .expect("bundled moon-graphite theme must parse") } + /// The faceted theme — [`MoonPalette::OBSIDIAN`] drawn with [`MoonSkin::Facet`] on the DARK + /// side, `LIGHT` with the flat skin on the light side. + /// + /// `mode` is `Dark`, for the reason [`Self::moon_graphite`] gives: Obsidian is a dark theme + /// by its own colours. It is the only bundled theme whose file names a skin, so it is the + /// only one a component draws faceted; the skin is per side, and switching this theme to + /// its light side returns to the flat look. + pub fn moon_obsidian() -> Self { + toml::from_str(include_str!("../../themes/moon-obsidian.toml")) + .expect("bundled moon-obsidian theme must parse") + } + /// The colour modes: [`MoonColors::DARK`] on the dark side and [`MoonColors::LIGHT`] on the /// light side, in `Dark` mode. /// @@ -535,6 +556,8 @@ fn normalized_zoom(zoom: f32) -> f32 { #[derive(Clone, Debug)] pub struct MoonTheme { pub mode: ThemeMode, + /// The active side's skin; see [`MoonThemeTokens::skin`]. + pub skin: MoonSkin, pub palette: MoonPalette, pub scale: MoonScale, pub metrics: MoonMetrics, @@ -567,6 +590,7 @@ impl MoonTheme { }; Self { mode: config.mode, + skin: tokens.skin, palette: tokens.palette, scale: tokens.scale, metrics: tokens.metrics, @@ -647,6 +671,7 @@ impl MoonTheme { fn tokens(&self) -> MoonThemeTokens { MoonThemeTokens { + skin: self.skin, palette: self.palette, scale: self.scale, metrics: self.metrics, diff --git a/crates/moon-ui-components/src/moon/theme/tests.rs b/crates/moon-ui-components/src/moon/theme/tests.rs index 381b233..bbd87c8 100644 --- a/crates/moon-ui-components/src/moon/theme/tests.rs +++ b/crates/moon-ui-components/src/moon/theme/tests.rs @@ -28,6 +28,7 @@ fn selection_inks_are_readable_on_the_surface_each_one_lands_on() { for (name, palette) in [ ("dark", MoonPalette::TERMINAL), ("graphite", MoonPalette::GRAPHITE), + ("obsidian", MoonPalette::OBSIDIAN), ("light", MoonPalette::LIGHT), ("dark colour mode", MoonColors::DARK.to_palette()), ("light colour mode", MoonColors::LIGHT.to_palette()), @@ -289,3 +290,117 @@ zoom = -2.0 assert_eq!(theme.scale.zoom, 1.0); assert_eq!(theme.config.light.zoom(), 1.0); } + +/// Catches a bundled theme other than Obsidian gaining a skin, or a `MoonThemeTokens::default` +/// that is not flat: components would start drawing facets on themes whose every pixel is +/// meant to stay as it was before skins existed. +#[test] +fn every_bundled_theme_but_obsidian_is_flat_on_both_sides() { + use super::{MoonSkin, ThemeMode}; + + for (name, config) in [ + ("terminal", MoonThemeConfig::moon_terminal()), + ("light", MoonThemeConfig::moon_light()), + ("graphite", MoonThemeConfig::moon_graphite()), + ("colour modes", MoonThemeConfig::moon_color_modes()), + ("default", MoonThemeConfig::default()), + ] { + assert_eq!(config.dark.skin, MoonSkin::Flat, "{name}: dark side"); + assert_eq!(config.light.skin, MoonSkin::Flat, "{name}: light side"); + for mode in [ThemeMode::Dark, ThemeMode::Light, ThemeMode::System] { + let mut config = config.clone(); + config.mode = mode; + let theme = MoonTheme::from_config(config); + assert_eq!(theme.skin, MoonSkin::Flat, "{name}: installed in {mode:?}"); + assert_eq!( + theme.tokens().skin, + MoonSkin::Flat, + "{name}: tokens in {mode:?}" + ); + } + } + assert_eq!(MoonThemeTokens::default().skin, MoonSkin::Flat); +} + +/// Catches `themes/moon-obsidian.toml` drifting from `MoonPalette::OBSIDIAN`, losing its skin +/// line, or putting the skin on the light side: Obsidian would paint another palette, come up +/// flat, or facet a light side that is meant to be the plain light theme. +#[test] +fn obsidian_theme_config_is_the_obsidian_palette_faceted_on_the_dark_side_only() { + use super::{MoonSkin, ThemeMode}; + + let config = MoonThemeConfig::moon_obsidian(); + assert_eq!(config.mode, ThemeMode::Dark); + assert_eq!(config.dark.palette, MoonPalette::OBSIDIAN); + assert_eq!(config.dark.skin, MoonSkin::Facet); + assert_eq!(config.light.palette, MoonPalette::LIGHT); + assert_eq!(config.light.skin, MoonSkin::Flat); + + // The light side is Graphite's light side in every field, not only in its palette. + let written = |tokens: &MoonThemeTokens| toml::to_string(tokens).unwrap(); + assert_eq!( + written(&config.light), + written(&MoonThemeConfig::moon_graphite().light) + ); +} + +/// Catches `theme.rs:MoonTheme::from_config` or `MoonTheme::tokens` dropping the skin, or +/// taking it from the dark side in light mode: `MoonSkin::active` and `active_tokens` would +/// report a flat Obsidian, or a faceted light theme. +#[test] +fn the_installed_theme_carries_the_skin_of_its_active_side() { + use super::{MoonSkin, ThemeMode}; + + let dark = MoonTheme::from_config(MoonThemeConfig::moon_obsidian()); + assert_eq!(dark.skin, MoonSkin::Facet); + assert_eq!(dark.tokens().skin, MoonSkin::Facet); + + let mut config = MoonThemeConfig::moon_obsidian(); + config.mode = ThemeMode::Light; + let light = MoonTheme::from_config(config); + assert_eq!(light.skin, MoonSkin::Flat); + assert_eq!(light.tokens().skin, MoonSkin::Flat); +} + +/// Catches removing the serde default from `MoonThemeTokens::skin`, which would reject every +/// theme file written before skins existed, and catches a renamed variant, which would reject +/// the one file that names a skin. +#[test] +fn a_theme_toml_without_a_skin_is_flat_and_one_naming_facet_is_faceted() { + use super::MoonSkin; + + let legacy: MoonThemeConfig = toml::from_str( + "[dark.scale] +ui = 1.2 +[light.scale] +font = 1.1 +", + ) + .unwrap(); + assert_eq!(legacy.dark.skin, MoonSkin::Flat); + assert_eq!(legacy.light.skin, MoonSkin::Flat); + + let faceted: MoonThemeConfig = toml::from_str( + "[dark] +skin = \"Facet\" +[light.scale] +font = 1.1 +", + ) + .unwrap(); + assert_eq!(faceted.dark.skin, MoonSkin::Facet); + assert_eq!(faceted.light.skin, MoonSkin::Flat); +} + +/// Catches a `#[serde(skip)]` on `MoonThemeTokens::skin`: a host that saves the theme it +/// installed would write Obsidian back as a flat theme and lose the facets on the next start. +#[test] +fn a_written_theme_keeps_the_skin_of_each_side() { + use super::MoonSkin; + + let text = toml::to_string(&MoonThemeConfig::moon_obsidian()).unwrap(); + let read: MoonThemeConfig = toml::from_str(&text).unwrap(); + assert_eq!(read.dark.skin, MoonSkin::Facet); + assert_eq!(read.light.skin, MoonSkin::Flat); + assert_eq!(read.dark.palette, MoonPalette::OBSIDIAN); +} diff --git a/crates/moon-ui-components/src/moon/toggle.rs b/crates/moon-ui-components/src/moon/toggle.rs index e88be47..46fe399 100644 --- a/crates/moon-ui-components/src/moon/toggle.rs +++ b/crates/moon-ui-components/src/moon/toggle.rs @@ -7,7 +7,8 @@ use crate::checkbox::{ChoiceColors, MoonCheckboxMetrics, choice_text_column}; use super::{ colors::MoonColors, - foundation::{MoonSize, moon_cubic_bezier, moon_shadow_sm, snap_centered}, + foundation::{MoonSize, box_shadow, moon_cubic_bezier, moon_shadow_sm, snap_centered}, + skin::{MoonFacetKind, facet, facet_glow}, theme::{MoonTheme, MoonThemeTokens}, tokens::{MoonPalette, MoonRect, MoonTone, rgba_from}, }; @@ -514,6 +515,9 @@ struct TogglePaint { /// How far the track drops to sit on the first line of the text beside it, or `None` where /// there is no text. box_offset: Option, + /// The faceted surfaces that replace `colors` on the track and the thumb, from + /// [`ToggleFacet::resolve`]; `None` leaves the flat colours standing. + facet: Option, } impl TogglePaint { @@ -522,6 +526,9 @@ impl TogglePaint { /// It is a child of the track rather than the track itself, so that its own border never insets /// the layers placed against the track's box, and so that it can give up the cap a settled /// thumb draws. + /// + /// Under the Facet skin the same quad is a well: the facet's fill, a one-pixel edge and its + /// shadows, on the geometry the flat rail has. fn rail(&self, settled: bool) -> Div { let (left, width, left_radius, right_radius) = self.metrics.rail(self.checked, settled); let hover_group = self.hover_group.clone(); @@ -536,13 +543,28 @@ impl TogglePaint { .rounded_bl(px(left_radius)) .rounded_tr(px(right_radius)) .rounded_br(px(right_radius)) - .bg(self.colors.track.rest) - .border(px(self.metrics.border)) - .border_color(self.colors.border.rest) - .when(!self.disabled, |this| { - this.group_hover(hover_group, move |this| { - this.bg(track_hover).border_color(border_hover) - }) + .map(|rail| match &self.facet { + // A facet is only resolved for an enabled toggle, so its hover needs no gate. + Some(facet) => { + let (fill_hover, edge_hover) = + (facet.track_fill_hovered, facet.track_edge.hovered); + rail.bg(facet.track_fill) + .border_1() + .border_color(facet.track_edge.rest) + .shadow(facet.track_shadows.clone()) + .group_hover(hover_group, move |this| { + this.bg(fill_hover).border_color(edge_hover) + }) + } + None => rail + .bg(self.colors.track.rest) + .border(px(self.metrics.border)) + .border_color(self.colors.border.rest) + .when(!self.disabled, |this| { + this.group_hover(hover_group, move |this| { + this.bg(track_hover).border_color(border_hover) + }) + }), }) } @@ -552,6 +574,9 @@ impl TogglePaint { /// the outline's width. Drawing the outline as a border instead leaves the disc's own edge and /// the stroke on one curve: GPUI's quad shader mixes the fill into a 1px stroke wherever its /// inner and outer bands meet, which thins the outline and steps it around the circle. + /// + /// Under the Facet skin the thumb of either variant is one disc lit from above, with no + /// outline to draw and so no face: its gradient and its shadows set it off the track. fn thumb(&self, travel: ThumbTravel, shadow: Vec) -> AnyElement { let m = self.metrics; let size = m.thumb_size; @@ -562,6 +587,8 @@ impl TogglePaint { let outline_hover = self.colors.thumb_border.hovered; let thumb_selector = format!("{}:thumb", self.id); let face_selector = format!("{}:thumb-face", self.id); + let facet_fill = self.facet.as_ref().map(|facet| vertical(facet.thumb)); + let outlined = rides && facet_fill.is_none(); let thumb = div() .debug_selector(|| thumb_selector) @@ -576,11 +603,11 @@ impl TogglePaint { } else { face }) - .when(!disabled && rides, |this| { + .when(!disabled && outlined, |this| { this.group_hover(hover_group, move |this| this.bg(outline_hover)) }) .shadow(shadow) - .when(rides, |this| { + .when(outlined, |this| { this.child( div() .debug_selector(|| face_selector) @@ -592,7 +619,8 @@ impl TogglePaint { .rounded(px((size - ring * 2.0) * 0.5)) .bg(face), ) - }); + }) + .when_some(facet_fill, |this, fill| this.bg(fill)); let Some(from) = travel.from() else { return thumb.into_any_element(); @@ -641,8 +669,11 @@ impl TogglePaint { .bottom_0() .rounded(px(m.radius())) // A thumb that rides the track is not clipped at all: clipping would flatten - // its shadow against the track it sits on. - .when(!self.rides, |this| this.overflow_hidden()) + // its shadow against the track it sits on. Neither is a faceted thumb, whose + // glow reaches past the track it sits in. + .when(!self.rides && self.facet.is_none(), |this| { + this.overflow_hidden() + }) .child(self.thumb(travel, shadow)), ) .when(self.focused, |this| { @@ -716,6 +747,12 @@ impl RenderOnce for MoonToggle { layer.color = layer.color.opacity(ToggleColors::DISABLED_ALPHA); } } + // Under the Facet skin an enabled toggle is a well with a lit thumb, which brings its + // own shadows; a disabled one keeps the flat colours and the shadow above. + let facet = ToggleFacet::resolve(&tokens, roles, self.tone, checked, disabled); + if let Some(facet) = &facet { + thumb_shadow = facet.thumb_shadows.clone(); + } // Empty text counts as no text, so a bare toggle never gains the text column and its gap. let label = self.label.filter(|label| !label.is_empty()); @@ -738,6 +775,7 @@ impl RenderOnce for MoonToggle { focused: focus_handle.is_focused(window), ring_accent: rgba_from(self.tone.unwrap_or(MoonTone::Info).color(p), 1.0), box_offset: has_text.then(|| choice.box_offset()), + facet, }; let switch = paint.track(settled, current, thumb_shadow); @@ -971,5 +1009,149 @@ impl ToggleColors { } } +/// How far an unchecked faceted thumb's top edge is mixed toward white. +const FACET_THUMB_LIFT: f32 = 0.25; +/// How far a checked faceted thumb's top edge is mixed toward white. +const FACET_THUMB_LIFT_ON: f32 = 0.45; +/// The share of the tone washed over a checked faceted track at its top edge, then its bottom. +const FACET_TRACK_WASH: [f32; 2] = [0.16, 0.08]; +/// Opacity of the tone in a checked faceted track's edge. +const FACET_TRACK_EDGE_ALPHA: f32 = 0.45; +/// Opacity of the glow a checked faceted thumb casts in its tone. +const FACET_THUMB_GLOW_ALPHA: f32 = 0.75; +/// Blur of that glow as the design gives it, in design-reference pixels. +const FACET_THUMB_GLOW_BLUR: f32 = 10.0; + +/// The surfaces a toggle paints under the Facet skin, in place of its [`ToggleColors`]. +/// +/// The track is a well sunk into the surface and the thumb a disc lit from above. Both variants +/// paint the same surfaces; they keep their own geometry. +#[derive(Clone, Debug, PartialEq)] +struct ToggleFacet { + /// The track's fill at rest. + track_fill: Background, + /// The track's fill under the pointer, equal to `track_fill` where hovering changes nothing. + track_fill_hovered: Background, + /// The track's one-pixel edge. + track_edge: Hovered, + /// The well's shadow stack, which the track keeps whether the toggle is on or off. + track_shadows: Vec, + /// The thumb's fill: the colour at its top edge, then the one at its bottom edge. + thumb: [Hsla; 2], + /// The thumb's shadow on the track, followed by its glow once the toggle is on. + thumb_shadows: Vec, +} + +impl ToggleFacet { + /// Resolves a toggle's faceted surfaces. + /// + /// An unchecked toggle is the skin's well under a thumb of the muted text ink. A checked one + /// washes the well with its tone, edges it in that tone, and lights the thumb in it with a + /// glow. The tone is the one set on the toggle, or the palette accent that `bg_brand_solid` + /// stands for. + /// + /// Hovering follows [`ToggleColors::resolve`]: only a checked track with no tone of its own + /// answers to the pointer, by taking `bg_brand_solid_hover` for its tone. A disabled toggle + /// keeps the flat colours that function mixes into the surface, so it resolves no facet. + /// + /// Args: + /// tokens: The active theme side; supplies the skin, the palette and the UI scale. + /// roles: The active colour roles, for the brand's hover colour. + /// tone: The tone set on the toggle, or `None` for the accent. + /// checked: Whether the toggle is on. + /// disabled: Whether the toggle is disabled. + /// + /// Returns: + /// The surfaces to paint, or `None` where the flat colours stand: under the Flat skin and + /// on a disabled toggle. + fn resolve( + tokens: &MoonThemeTokens, + roles: MoonColors, + tone: Option, + checked: bool, + disabled: bool, + ) -> Option { + if !tokens.skin.is_facet() || disabled { + return None; + } + let p = tokens.palette; + let well = facet(MoonFacetKind::Socket, tokens); + let drop = box_shadow( + px(0.0), + px(tokens.ui(1.0)), + px(tokens.ui(2.0)), + px(0.0), + rgba_from(p.shadow, thumb_drop_alpha(p)), + ); + if !checked { + return Some(Self { + track_fill: well.fill, + track_fill_hovered: well.fill, + track_edge: Hovered::flat(well.edge), + track_shadows: well.shadows, + thumb: lit(rgba_from(p.text_muted, 1.0), FACET_THUMB_LIFT), + thumb_shadows: vec![drop], + }); + } + + let tone_rgb = tone.map_or(p.accent, |tone| tone.color(p)); + let rest = rgba_from(tone_rgb, 1.0); + let hovered = match tone { + Some(_) => rest, + None => roles.bg_brand_solid_hover.into(), + }; + Some(Self { + track_fill: vertical(washed_well(p, rest)), + track_fill_hovered: vertical(washed_well(p, hovered)), + track_edge: Hovered { + rest: rest.opacity(FACET_TRACK_EDGE_ALPHA), + hovered: hovered.opacity(FACET_TRACK_EDGE_ALPHA), + }, + track_shadows: well.shadows, + thumb: lit(rest, FACET_THUMB_LIFT_ON), + thumb_shadows: vec![ + drop, + facet_glow( + tone_rgb, + FACET_THUMB_GLOW_ALPHA, + FACET_THUMB_GLOW_BLUR, + tokens, + ), + ], + }) + } +} + +/// Returns the opacity of the shadow a faceted thumb casts on its track: strong on a dark +/// palette, and far weaker on a light one, where a shadow needs little to show. +fn thumb_drop_alpha(p: MoonPalette) -> f32 { + if p.is_light() { 0.2 } else { 0.6 } +} + +/// Returns the two ends of a checked faceted track's fill: `tone` washed over the palette's +/// `gutter` by [`FACET_TRACK_WASH`], top edge first. +/// +/// The tone's own alpha thins the wash, so a translucent hover colour tints the well less than +/// the opaque colour it stands in for. +fn washed_well(p: MoonPalette, tone: Hsla) -> [Hsla; 2] { + let gutter = rgba_from(p.gutter, 1.0); + FACET_TRACK_WASH.map(|share| gutter.blend(tone.opacity(share))) +} + +/// Returns a surface lit from above in `color`: `color` mixed toward white by `lift` for the top +/// edge, then `color` itself for the bottom edge. +fn lit(color: Hsla, lift: f32) -> [Hsla; 2] { + [color.blend(white().opacity(lift)), color] +} + +/// Builds the gradient running from `stops[0]` at the top edge to `stops[1]` at the bottom edge. +fn vertical(stops: [Hsla; 2]) -> Background { + linear_gradient( + 180.0, + linear_color_stop(stops[0], 0.0), + linear_color_stop(stops[1], 1.0), + ) +} + #[cfg(test)] mod tests; diff --git a/crates/moon-ui-components/src/moon/toggle/tests.rs b/crates/moon-ui-components/src/moon/toggle/tests.rs index 957b84f..8240589 100644 --- a/crates/moon-ui-components/src/moon/toggle/tests.rs +++ b/crates/moon-ui-components/src/moon/toggle/tests.rs @@ -2,15 +2,16 @@ use super::{ MoonToggle, MoonToggleLabelSide, MoonToggleSize, MoonToggleVariant, THUMB_TRAVEL, - THUMB_TRAVEL_CURVE, ThumbTravel, ToggleColors, moon_toggle_click_plan, + THUMB_TRAVEL_CURVE, ThumbTravel, ToggleColors, ToggleFacet, moon_toggle_click_plan, vertical, + washed_well, }; use crate::checkbox::{ChoiceColors, MoonCheckboxMetrics}; use crate::moon::checkbox::tier_size; use crate::moon::{ - MoonColors, MoonPalette, MoonScale, MoonSize, MoonThemeConfig, MoonThemeTokens, MoonTone, - rgba_from, + MoonColors, MoonFacetKind, MoonPalette, MoonScale, MoonSize, MoonTheme, MoonThemeConfig, + MoonThemeTokens, MoonTone, facet, rgba_from, }; -use gpui::{px, size}; +use gpui::{BoxShadow, Hsla, Rgba, point, px, size}; /// Catches changing a tier's unscaled reference geometry in `MoonToggleSize::reference_metrics` /// away from the reviewed designer reference (Sm 36x20 track/16 thumb/14px-20 text/8 gap, Md @@ -381,6 +382,39 @@ fn render_toggle( #[gpui::test] fn track_and_thumb_render_at_the_reviewed_size(cx: &mut gpui::TestAppContext) { cx.update(crate::init); + assert_track_and_thumb_at_the_reviewed_size(cx); +} + +/// Catches the Facet skin moving a toggle off its reviewed geometry — a facet edge that insets +/// the thumb, a thumb resized to the design mock-up's, or a slim thumb pulled inside its track: +/// under the faceted theme every variant, tier and state must measure exactly as under a flat +/// one, so switching themes never reflows a toolbar. A faceted slim thumb is one lit disc, so it +/// draws no face. +#[gpui::test] +fn a_faceted_toggle_keeps_the_reviewed_geometry(cx: &mut gpui::TestAppContext) { + cx.update(crate::init); + cx.update(|cx| MoonTheme::install_config(MoonThemeConfig::moon_obsidian(), cx)); + cx.update(|cx| assert!(MoonTheme::active_tokens(cx).skin.is_facet())); + + assert_track_and_thumb_at_the_reviewed_size(cx); + + let mut cx = render_toggle( + cx, + SizedToggleHarness { + variant: MoonToggleVariant::Slim, + ..SizedToggleHarness::bare(MoonSize::Sm, true) + }, + ); + assert!(cx.debug_bounds("sized:thumb").is_some()); + assert!( + cx.debug_bounds("sized:thumb-face").is_none(), + "a faceted thumb has no outline, so it needs no face" + ); +} + +/// Renders every variant, tier and state under the installed theme and asserts the reviewed +/// track and thumb geometry. +fn assert_track_and_thumb_at_the_reviewed_size(cx: &mut gpui::TestAppContext) { // A default toggle keeps its thumb 2px inside the track; a slim one's thumb is the track's // own height, so it sits flush against every edge. for (variant, tier, track, thumb, gap) in [ @@ -914,3 +948,203 @@ fn the_thumb_keeps_one_gap_on_every_side_at_any_scale_factor(cx: &mut gpui::Test } } } + +/// The sRGB channel levels of `color`, each from 0 to 255 and left unrounded. +fn levels(color: Hsla) -> [f32; 3] { + let color = Rgba::from(color); + [color.r * 255.0, color.g * 255.0, color.b * 255.0] +} + +/// Asserts `color` sits at `expected` levels per channel, to within a tenth of a level. +fn assert_levels(color: Hsla, expected: [f32; 3], what: &str) { + let actual = levels(color); + for (actual_level, expected_level) in actual.into_iter().zip(expected) { + assert!( + (actual_level - expected_level).abs() < 0.1, + "{what}: {actual:?} is not {expected:?}" + ); + } +} + +/// A shadow straight below its element with no spread, written out rather than taken from +/// `toggle.rs`. `blur` is the renderer's blur radius, which the thumb's own shadow is built in. +fn shadow_below(y: f32, blur: f32, color: Hsla) -> BoxShadow { + BoxShadow { + color, + offset: point(px(0.0), px(y)), + blur_radius: px(blur), + spread_radius: px(0.0), + inset: false, + } +} + +/// A glow centred on its element with no spread, written out rather than taken from `skin.rs`. +/// `design_blur` is the blur as the design gives it, a CSS `box-shadow` blur. +fn glow(design_blur: f32, color: Hsla) -> BoxShadow { + // The renderer's blur radius is the gaussian's standard deviation: half a CSS blur. + shadow_below(0.0, design_blur / 2.0, color) +} + +/// Catches `toggle.rs:ToggleFacet::resolve` ignoring the skin or the disabled flag: every flat +/// theme's toggles would be repainted as wells, or a disabled toggle on the faceted theme would +/// lose the flat colours the design mixes into the surface. With no facet resolved, a toggle +/// paints `ToggleColors` alone, exactly as it did before skins existed. +#[test] +fn flat_themes_and_disabled_toggles_resolve_no_facet() { + let flat_sides = [ + MoonThemeConfig::moon_terminal().dark, + MoonThemeConfig::moon_terminal().light, + MoonThemeConfig::moon_graphite().dark, + MoonThemeConfig::moon_obsidian().light, + MoonThemeTokens::default(), + ]; + for tokens in &flat_sides { + let roles = MoonColors::from_palette(tokens.palette); + for tone in [None, Some(MoonTone::Info)] { + for checked in [false, true] { + for disabled in [false, true] { + assert_eq!( + ToggleFacet::resolve(tokens, roles, tone, checked, disabled), + None, + "a flat theme never resolves a facet" + ); + } + } + } + } + + let faceted = MoonThemeConfig::moon_obsidian().dark; + let roles = MoonColors::from_palette(faceted.palette); + for checked in [false, true] { + assert_eq!( + ToggleFacet::resolve(&faceted, roles, None, checked, true), + None, + "a disabled toggle keeps the flat colours" + ); + assert!(ToggleFacet::resolve(&faceted, roles, None, checked, false).is_some()); + } +} + +/// Catches an unchecked faceted toggle drifting from its spec: the track is the skin's socket — +/// a well under an edge that reads at the track's size — with the same fill and edge under +/// the pointer, and the thumb runs from the muted text ink lifted a quarter of the way to white +/// down to that ink, over a 1px-down, 2px-blur shadow at 60%. A thumb lit from below, or one +/// carrying the checked glow, would read as switched on. +#[test] +fn an_unchecked_faceted_toggle_is_a_well_under_a_muted_thumb() { + let tokens = MoonThemeConfig::moon_obsidian().dark; + let p = tokens.palette; + let roles = MoonColors::from_palette(p); + let well = facet(MoonFacetKind::Socket, &tokens); + + let off = ToggleFacet::resolve(&tokens, roles, None, false, false).expect("faceted"); + assert_eq!(off.track_fill, well.fill); + assert_eq!(off.track_fill_hovered, well.fill); + assert_eq!(off.track_edge.rest, rgba_from(p.overlay, 0.17)); + assert_eq!(off.track_edge.hovered, off.track_edge.rest); + assert_eq!(off.track_shadows, well.shadows); + + // `text_muted` 0x878279 is (135, 130, 121); a quarter of the way to 255 is +30, +31.25, +33.5. + assert_levels(off.thumb[0], [165.0, 161.25, 154.5], "thumb top"); + assert_eq!(off.thumb[1], rgba_from(p.text_muted, 1.0)); + assert_eq!( + off.thumb_shadows, + vec![shadow_below(1.0, 2.0, rgba_from(p.shadow, 0.6))] + ); +} + +/// Catches a checked faceted toggle drifting from its spec, with the accent and with an explicit +/// tone: the well is washed with the tone, 16% at the top and 8% at the bottom, and edged in it +/// at 45%; the thumb runs from the tone lifted 45% toward white down to the tone, and adds a +/// glow in the tone at 75% to its shadow — 10px of blur in the design, so 5px at the renderer. +/// Swapping the two washes lights the track from below; dropping the tone repaints every toned +/// toggle in the accent. +#[test] +fn a_checked_faceted_toggle_washes_its_well_and_lights_its_thumb_in_the_tone() { + let tokens = MoonThemeConfig::moon_obsidian().dark; + let p = tokens.palette; + let roles = MoonColors::from_palette(p); + let well = facet(MoonFacetKind::Socket, &tokens); + + // `gutter` 0x08090B is (8, 9, 11). The accent 0xFFB347 is (255, 179, 71) and the Info tone, + // `blue` 0x7FC9FF, is (127, 201, 255). + for (tone, rgb, wash_top, wash_bottom, thumb_top) in [ + ( + None, + p.accent, + [47.52, 36.2, 20.6], + [27.76, 22.6, 15.8], + [255.0, 213.2, 153.8], + ), + ( + Some(MoonTone::Info), + p.blue, + [27.04, 39.72, 50.04], + [17.52, 24.36, 30.52], + [184.6, 225.3, 255.0], + ), + ] { + let ink = rgba_from(rgb, 1.0); + let on = ToggleFacet::resolve(&tokens, roles, tone, true, false).expect("faceted"); + + let [top, bottom] = washed_well(p, ink); + assert_levels(top, wash_top, "track top"); + assert_levels(bottom, wash_bottom, "track bottom"); + assert_eq!(on.track_fill, vertical([top, bottom])); + assert_eq!(on.track_edge.rest, Hsla { a: 0.45, ..ink }); + assert_eq!(on.track_shadows, well.shadows); + + assert_levels(on.thumb[0], thumb_top, "thumb top"); + assert_eq!(on.thumb[1], ink); + assert_eq!( + on.thumb_shadows, + vec![ + shadow_below(1.0, 2.0, rgba_from(p.shadow, 0.6)), + glow(10.0, rgba_from(rgb, 0.75)), + ] + ); + } +} + +/// Catches the faceted track losing the one hover a flat toggle has, or gaining one it has not: +/// a checked track with no tone takes `bg_brand_solid_hover` for its tone under the pointer — +/// on a legacy palette the accent at 82%, which thins the wash and the edge by that much — while +/// a toned track keeps its fill and edge, as a toned flat track keeps its fill. +#[test] +fn only_an_untoned_faceted_track_answers_to_the_pointer() { + let tokens = MoonThemeConfig::moon_obsidian().dark; + let p = tokens.palette; + let roles = MoonColors::from_palette(p); + + let brand = ToggleFacet::resolve(&tokens, roles, None, true, false).expect("faceted"); + let [top, bottom] = washed_well(p, roles.bg_brand_solid_hover.into()); + // The accent's wash at 209/255 of its strength: 16% becomes 13.11% and 8% becomes 6.56%. + assert_levels(top, [40.39, 31.29, 18.87], "hovered track top"); + assert_levels(bottom, [24.20, 20.15, 14.93], "hovered track bottom"); + assert_eq!(brand.track_fill_hovered, vertical([top, bottom])); + assert_ne!(brand.track_fill_hovered, brand.track_fill); + assert!((brand.track_edge.hovered.a - 0.45 * 209.0 / 255.0).abs() < 1e-4); + + let toned = + ToggleFacet::resolve(&tokens, roles, Some(MoonTone::Info), true, false).expect("faceted"); + assert_eq!(toned.track_fill_hovered, toned.track_fill); + assert_eq!(toned.track_edge.hovered, toned.track_edge.rest); +} + +/// Catches a facet shadow built from a raw `px(..)`: at a doubled UI scale the thumb doubles, and +/// its shadow and glow would stay the size they have at 100%. +#[test] +fn faceted_thumb_shadows_follow_the_ui_scale() { + let tokens = MoonThemeConfig::moon_obsidian().with_ui_scale(2.0).dark; + let p = tokens.palette; + let on = ToggleFacet::resolve(&tokens, MoonColors::from_palette(p), None, true, false) + .expect("faceted"); + // The thumb's 1px-down, 2px shadow and its glow's 10 design pixels of blur, each doubled. + assert_eq!( + on.thumb_shadows, + vec![ + shadow_below(2.0, 4.0, rgba_from(p.shadow, 0.6)), + glow(20.0, rgba_from(p.accent, 0.75)), + ] + ); +} diff --git a/crates/moon-ui-components/src/moon/tokens.rs b/crates/moon-ui-components/src/moon/tokens.rs index b2a0495..5c8fdc1 100644 --- a/crates/moon-ui-components/src/moon/tokens.rs +++ b/crates/moon-ui-components/src/moon/tokens.rs @@ -278,6 +278,82 @@ impl MoonPalette { yellow: 0xFFD93D, }; + /// The palette of the faceted "Obsidian" theme: near-black surfaces that the + /// [`MoonSkin::Facet`] skin lights from above, darker than [`Self::TERMINAL`]. + /// + /// It is a DARK palette by measurement, not by declaration — `shell` has a luma of 5.9, so + /// [`Self::is_light`] answers `false` and every consumer that branches on it takes the dark + /// arm with no new code, exactly as [`Self::GRAPHITE`] does. What makes Obsidian look + /// different is the skin its theme file selects, never a third colour table. + /// + /// The surface steps are wider apart than `TERMINAL`'s, and the skin depends on that: a + /// facet's fill is a gradient between two palette entries, so it reads as depth only where + /// those entries differ. `panel_high` and `panel`, the two ends of a raised control, are 11 + /// to 14 levels apart per channel here and 1 to 2 in `TERMINAL`; `shell` has about a + /// quarter of `TERMINAL`'s relative luminance, and `gutter` stays below `panel` so a well + /// reads as sunk. Moving those entries toward each other flattens every facet at once. + /// + /// Measured with [`contrast_ratio`] against the WCAG floor of 4.5:1 — `text` 15.99 / 15.99 / + /// 13.76 and `text_muted` 5.31 / 5.31 / 4.57 on `shell` / `window` / `panel`. `text_muted` + /// is NOT the dark palette's `0x7D7669`, which measures 3.88 on this `panel`. Even the + /// lifted ink misses the floor on `panel_high` (3.98), the lit top of a raised facet: muted + /// ink belongs on `panel` and below, and `text_soft` (5.91) is the quiet ink for a raised + /// control. + /// + /// `green` and `red` are lighter than `TERMINAL`'s at nearly the same hue, because on a + /// near-black surface they are inks as often as fills: `red` measures 6.80 / 5.85 / 6.12 on + /// `shell` / `panel` / `card`, where `TERMINAL`'s `0xE5484D` would fall to 4.46 on `panel` + /// (see `GRAPHITE` on the outline danger button). The accent hues are carried over from + /// `TERMINAL` unchanged. + /// + /// [`MoonSkin::Facet`]: super::MoonSkin::Facet + pub const OBSIDIAN: Self = Self { + shell: 0x050608, + shell_high: 0x0D0F12, + window: 0x050608, + surface: 0x0A0B0E, + panel: 0x171A1F, + panel_high: 0x22262D, + chrome: 0x0D0F12, + tabbar: 0x0D0F12, + panel_head: 0x1A1D22, + gutter: 0x08090B, + chart_bg: 0x08090B, + card: 0x13151A, + row_alt: 0x0D0F12, + head_row: 0x101216, + border: 0x1F2227, + border_soft: 0x1A1D21, + border_card: 0x1F2227, + border_hover: 0x3A3F47, + row_line: 0x1A1D21, + shadow: 0x000000, + overlay: 0xFFFFFF, + on_accent: 0xFFFFFF, + text: 0xE8E4DC, + text_soft: 0xA6A198, + text_dim: 0xE8E4DC, + text_muted: 0x878279, + text_faint: 0x878279, + table_head: 0x101216, + table_body: 0x0D0F12, + table_selected: 0xFFB347, + table_hover: 0x171A1F, + green: 0x34C07A, + green_btn: 0x1E8C5B, + green_text: 0x34C07A, + red: 0xF26A69, + red_text: 0xF26A69, + red_soft_bd: 0xF26A69, + orange: 0xFF8E5A, + amber: 0xFFB347, + blue: 0x7FC9FF, + accent: 0xFFB347, + accent_fg: 0xFFCF94, + accent_tint_a: 0.11, + yellow: 0xFFD93D, + }; + pub fn with_legacy_defaults(mut self) -> Self { if self.window == 0 { self.window = self.shell; diff --git a/crates/moon-ui-components/src/moon/tokens/tests.rs b/crates/moon-ui-components/src/moon/tokens/tests.rs index a0be816..1a6f71d 100644 --- a/crates/moon-ui-components/src/moon/tokens/tests.rs +++ b/crates/moon-ui-components/src/moon/tokens/tests.rs @@ -16,6 +16,7 @@ fn selected_row_ink_stays_readable_on_the_panel_it_is_tinted_over() { for (name, p) in [ ("dark", MoonPalette::TERMINAL), ("graphite", MoonPalette::GRAPHITE), + ("obsidian", MoonPalette::OBSIDIAN), ("light", MoonPalette::LIGHT), ] { let ratio = contrast_ratio(p.selected_fg(), p.panel); @@ -36,6 +37,7 @@ fn ink_on_a_solid_fill_is_readable_in_both_palettes() { for (name, p) in [ ("dark", MoonPalette::TERMINAL), ("graphite", MoonPalette::GRAPHITE), + ("obsidian", MoonPalette::OBSIDIAN), ("light", MoonPalette::LIGHT), ] { for (fill_name, fill) in [("accent", p.accent), ("panel", p.panel)] { @@ -93,6 +95,48 @@ fn graphite_text_and_muted_text_clear_the_surface_contrast_floor() { } } +/// Catches raising `tokens.rs:MoonPalette::OBSIDIAN.shell` above the dark luma threshold. +/// A light classification flips every `is_light()` consumer to the light branch at once, and +/// hands the Facet skin its light-palette strengths on a near-black theme. +#[test] +fn obsidian_palette_is_a_dark_palette_by_measured_shell_luma() { + assert!(!MoonPalette::OBSIDIAN.is_light()); +} + +/// Catches darkening Obsidian's `text_muted` back toward the dark palette's value, which +/// measures 3.88:1 on this panel. Text and muted text on Obsidian's shell, window, or panel +/// must remain readable at the WCAG floor. +#[test] +fn obsidian_text_and_muted_text_clear_the_surface_contrast_floor() { + let palette = MoonPalette::OBSIDIAN; + for (ink_name, ink) in [("text", palette.text), ("text_muted", palette.text_muted)] { + for (surface_name, surface) in [ + ("shell", palette.shell), + ("window", palette.window), + ("panel", palette.panel), + ] { + let ratio = contrast_ratio(ink, surface); + assert!( + ratio >= SELECTED_TEXT_CONTRAST_FLOOR, + "Obsidian {ink_name} on {surface_name} is {ratio:.2}:1, below {SELECTED_TEXT_CONTRAST_FLOOR}:1" + ); + } + } +} + +/// Catches flattening Obsidian's surface steps: the Facet skin fills a raised control from +/// `panel_high` down to `panel`, a popup from `panel_high` down to `card` and a well with +/// `gutter`, so a `panel_high` no lighter than the other two draws those facets flat or lit +/// from below, and a `gutter` no darker than `panel` makes a well stand out instead of in. +#[test] +fn obsidian_surface_steps_keep_the_order_the_facet_skin_lights() { + let luminance = super::relative_luminance; + let p = MoonPalette::OBSIDIAN; + assert!(luminance(p.panel) < luminance(p.panel_high)); + assert!(luminance(p.card) < luminance(p.panel_high)); + assert!(luminance(p.gutter) < luminance(p.panel)); +} + /// Catches changing `tokens.rs:MoonPalette::LIGHT` away from the neutral-terminal design /// specification, which would break semantic contrast and status coloring in the light theme. #[test] diff --git a/crates/moon-ui-components/src/moon/tooltip.rs b/crates/moon-ui-components/src/moon/tooltip.rs index b80746f..f044bcf 100644 --- a/crates/moon-ui-components/src/moon/tooltip.rs +++ b/crates/moon-ui-components/src/moon/tooltip.rs @@ -1,7 +1,9 @@ +use gpui::prelude::FluentBuilder as _; use gpui::*; use super::{ icons::{MOON_ICON_TOOLTIP_ARROW, moon_icon}, + skin::{MoonFacet, MoonFacetKind, facet}, text::MoonText, theme::{MoonTheme, MoonThemeTokens}, tokens::{MoonPalette, MoonRect, MoonTone, rgba_from}, @@ -254,6 +256,17 @@ impl ParentElement for MoonTooltip { } impl RenderOnce for MoonTooltip { + /// Render the tooltip box, its text or custom children, an optional shortcut and the arrow. + /// + /// The box is the solid Moon surface under the Flat skin and a popup surface under the + /// Facet skin; see [`tooltip_facet`]. + /// + /// Args: + /// _window: Window the tooltip renders in; unused. + /// cx: Application context used to resolve the active palette and theme tokens. + /// + /// Returns: + /// The rendered tooltip. fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { let p = MoonPalette::active(cx); let tokens = MoonTheme::active_tokens(cx); @@ -274,11 +287,22 @@ impl RenderOnce for MoonTooltip { .id(ElementId::from(self.id.clone())) .relative() .min_h(px(metrics.min_height)) - .rounded(px(metrics.radius)) - .border(px(1.0)) - .border_color(rgba_from(p.border, 1.0)) - .bg(rgba_from(p.shell_high, 0.98)) - .shadow(vec![shadow]) + // `None` is the Flat skin. A faceted tooltip is a popup surface: gradient fill, lit + // edge and the deeper shadow of something floating above the window. + .map(|this| match tooltip_facet(&tokens, self.size) { + Some((surface, radius)) => this + .rounded(px(radius)) + .border(px(1.0)) + .border_color(surface.edge) + .bg(surface.fill) + .shadow(surface.shadows), + None => this + .rounded(px(metrics.radius)) + .border(px(1.0)) + .border_color(rgba_from(p.border, 1.0)) + .bg(rgba_from(p.shell_high, 0.98)) + .shadow(vec![shadow]), + }) .occlude() .flex() .items_center() @@ -375,7 +399,74 @@ impl RenderOnce for MoonTooltip { } } +/// Design-reference corner radius of a normal tooltip under the Facet skin. +const FACET_TOOLTIP_RADIUS: f32 = 8.0; +/// Design-reference corner radius of a compact tooltip under the Facet skin. +const FACET_TOOLTIP_COMPACT_RADIUS: f32 = 6.0; + +/// Resolve the surface a tooltip paints under the Facet skin. +/// +/// Args: +/// tokens: Active theme tokens; supply the skin, the palette and the UI scale. +/// size: The tooltip's size; a custom size keeps the radius its caller chose. +/// +/// Returns: +/// The popup surface and the corner radius in rendered pixels, or `None` under the Flat +/// skin, where the tooltip keeps its solid fill, its palette border and its soft shadow. +fn tooltip_facet(tokens: &MoonThemeTokens, size: MoonTooltipSize) -> Option<(MoonFacet, f32)> { + if !tokens.skin.is_facet() { + return None; + } + let radius = match size { + MoonTooltipSize::Compact => FACET_TOOLTIP_COMPACT_RADIUS, + MoonTooltipSize::Normal => FACET_TOOLTIP_RADIUS, + MoonTooltipSize::Custom { radius, .. } => radius, + }; + Some((facet(MoonFacetKind::Popup, tokens), tokens.ui(radius))) +} + +/// Resolve the colour of the tooltip arrow, as `0xRRGGBB` and alpha. +/// +/// The arrow continues the tooltip's fill past one of its edges. Under the Flat skin that fill +/// is one colour. A faceted tooltip is lit from above — its fill runs from `panel_high` at the +/// top down to `card` — so the arrow takes the colour of the edge it hangs from: `card` below +/// a tooltip placed above its target, `panel_high` on the top edge and near the top of a side. +/// +/// Args: +/// p: Palette the tooltip paints with. +/// tokens: Active theme tokens; supply the skin. +/// placement: Where the tooltip sits relative to its target. +/// +/// Returns: +/// The arrow's colour and alpha. +fn tooltip_arrow_color( + p: MoonPalette, + tokens: &MoonThemeTokens, + placement: MoonTooltipPlacement, +) -> (u32, f32) { + if !tokens.skin.is_facet() { + return (p.shell_high, 0.98); + } + match placement { + MoonTooltipPlacement::Top => (p.card, 1.0), + MoonTooltipPlacement::Bottom | MoonTooltipPlacement::Left | MoonTooltipPlacement::Right => { + (p.panel_high, 1.0) + } + } +} + impl MoonTooltip { + /// Render the arrow that points from the tooltip to its target. + /// + /// Args: + /// p: Palette the tooltip paints with. + /// metrics: Scaled tooltip metrics; supply the arrow size. + /// placement: Where the tooltip sits relative to its target, which picks the edge the + /// arrow hangs from and its rotation. + /// tokens: Active theme tokens; supply the UI scale and the skin. + /// + /// Returns: + /// The absolutely positioned arrow, coloured to continue the tooltip's fill. fn render_arrow( p: MoonPalette, metrics: TooltipMetrics, @@ -384,11 +475,12 @@ impl MoonTooltip { ) -> Svg { let half = metrics.arrow_size * 0.5; let offset = tokens.ui(18.0); + let (arrow_color, arrow_alpha) = tooltip_arrow_color(p, tokens, placement); let mut arrow = moon_icon( MOON_ICON_TOOLTIP_ARROW, metrics.arrow_size, - p.shell_high, - 0.98, + arrow_color, + arrow_alpha, ) .absolute(); @@ -419,3 +511,6 @@ impl MoonTooltip { arrow } } + +#[cfg(test)] +mod tests; diff --git a/crates/moon-ui-components/src/moon/tooltip/tests.rs b/crates/moon-ui-components/src/moon/tooltip/tests.rs new file mode 100644 index 0000000..b5e7233 --- /dev/null +++ b/crates/moon-ui-components/src/moon/tooltip/tests.rs @@ -0,0 +1,106 @@ +//! Guards the surface and the arrow a tooltip paints under each theme skin. + +use super::{MoonTooltipPlacement, MoonTooltipSize, tooltip_arrow_color, tooltip_facet}; +use crate::moon::{MoonFacetKind, MoonPalette, MoonSkin, MoonThemeTokens, facet}; + +const ALL_PLACEMENTS: [MoonTooltipPlacement; 4] = [ + MoonTooltipPlacement::Top, + MoonTooltipPlacement::Bottom, + MoonTooltipPlacement::Left, + MoonTooltipPlacement::Right, +]; + +/// Theme tokens for `palette` drawn with `skin`, with every length doubled. +fn doubled_skin_tokens(palette: MoonPalette, skin: MoonSkin) -> MoonThemeTokens { + let mut tokens = MoonThemeTokens { + palette, + skin, + ..MoonThemeTokens::default() + }; + tokens.scale.ui = 2.0; + tokens +} + +/// A custom tooltip size whose only distinguishing value is its corner radius. +fn custom_size(radius: f32) -> MoonTooltipSize { + MoonTooltipSize::Custom { + min_height: 20.0, + font_size: 10.0, + line_height: 13.0, + pad_x: 6.0, + pad_y: 3.0, + radius, + gap: 6.0, + arrow_size: 6.0, + } +} + +/// Catches `tooltip.rs:tooltip_facet` or `tooltip_arrow_color` ignoring the skin: a flat +/// tooltip would get a gradient popup surface, or an arrow that no longer matches its +/// `shell_high` body. +#[test] +fn a_flat_tooltip_keeps_its_solid_surface_and_matching_arrow() { + for palette in [MoonPalette::TERMINAL, MoonPalette::LIGHT] { + let tokens = doubled_skin_tokens(palette, MoonSkin::Flat); + for size in [ + MoonTooltipSize::Compact, + MoonTooltipSize::Normal, + custom_size(3.0), + ] { + assert_eq!(tooltip_facet(&tokens, size), None); + } + for placement in ALL_PLACEMENTS { + assert_eq!( + tooltip_arrow_color(palette, &tokens, placement), + (palette.shell_high, 0.98) + ); + } + } +} + +/// Catches `tooltip.rs:tooltip_facet` giving every size one radius, overriding a caller's own +/// radius, or leaving the radius unscaled: a compact tooltip would be rounder than its height +/// allows, and at a doubled UI scale the corners would stay half as round as the box. +#[test] +fn a_faceted_tooltip_is_a_popup_whose_radius_follows_its_size_and_the_ui_scale() { + let tokens = doubled_skin_tokens(MoonPalette::OBSIDIAN, MoonSkin::Facet); + let popup = facet(MoonFacetKind::Popup, &tokens); + + for (size, radius) in [ + (MoonTooltipSize::Normal, 16.0), + (MoonTooltipSize::Compact, 12.0), + (custom_size(3.0), 6.0), + ] { + assert_eq!( + tooltip_facet(&tokens, size), + Some((popup.clone(), radius)), + "{size:?}" + ); + } +} + +/// Catches `tooltip.rs:tooltip_arrow_color` keeping the flat `shell_high` under the Facet skin, +/// or swapping the two ends of the popup fill: the arrow would show as a patch of another +/// colour against the edge of the tooltip it is meant to continue. +#[test] +fn a_faceted_arrow_takes_the_colour_of_the_edge_it_hangs_from() { + let p = MoonPalette::OBSIDIAN; + let tokens = doubled_skin_tokens(p, MoonSkin::Facet); + + assert_eq!( + tooltip_arrow_color(p, &tokens, MoonTooltipPlacement::Top), + (p.card, 1.0), + "a tooltip above its target points down, from the dark end of its fill" + ); + for placement in [ + MoonTooltipPlacement::Bottom, + MoonTooltipPlacement::Left, + MoonTooltipPlacement::Right, + ] { + assert_eq!( + tooltip_arrow_color(p, &tokens, placement), + (p.panel_high, 1.0), + "{placement:?}" + ); + } +} diff --git a/crates/moon-ui-components/src/progress/progress.rs b/crates/moon-ui-components/src/progress/progress.rs index 51360de..e7e9a01 100644 --- a/crates/moon-ui-components/src/progress/progress.rs +++ b/crates/moon-ui-components/src/progress/progress.rs @@ -17,6 +17,7 @@ pub struct Progress { value: f32, size: Size, loading: bool, + surface_styles: Option<(StyleRefinement, StyleRefinement)>, } impl Progress { @@ -29,6 +30,7 @@ impl Progress { style: StyleRefinement::default(), size: Size::default(), loading: false, + surface_styles: None, } } @@ -54,6 +56,12 @@ impl Progress { self.value = value.clamp(0., 100.); self } + + /// Let the Moon wrapper change track/fill paint while retaining this bar's animation state. + pub(crate) fn surface_styles(mut self, track: StyleRefinement, fill: StyleRefinement) -> Self { + self.surface_styles = Some((track, fill)); + self + } } impl Styled for Progress { @@ -99,6 +107,9 @@ impl RenderOnce for Progress { .rounded(radius) .refine_style(&self.style) .bg(color.opacity(0.2)) + .when_some(self.surface_styles.as_ref(), |this, (track, _)| { + this.refine_style(track) + }) .child( div() .absolute() @@ -108,6 +119,9 @@ impl RenderOnce for Progress { .bg(color) .rounded(radius) .refine_style(&inner_style) + .when_some(self.surface_styles.as_ref(), |this, (_, fill)| { + this.refine_style(fill) + }) .map(|this| match value { v if v >= 100. || loading => this, _ => this.rounded_r_none(), diff --git a/crates/moon-ui-components/src/root.rs b/crates/moon-ui-components/src/root.rs index fd6b93f..418d253 100644 --- a/crates/moon-ui-components/src/root.rs +++ b/crates/moon-ui-components/src/root.rs @@ -3,7 +3,7 @@ use crate::{ dialog::{ANIMATION_DURATION, Dialog}, focus_trap::FocusTrapManager, input::{Copy, InputState}, - moon::{MoonDialog, MoonTheme}, + moon::{MoonDialog, MoonSkin, MoonTheme, moon_backdrop}, native_menu::FallbackMenuOverlay, notification::{Notification, NotificationList}, sheet::Sheet, @@ -182,12 +182,19 @@ impl MoonRoot { } /// Set whether the root paints its background. + /// + /// Under the Facet skin a [`BackgroundPolicy::Opaque`] root also paints the theme's window + /// backdrop behind its content. The other policies never do: they exist to show what lies + /// behind the scene, and the backdrop is opaque. pub fn background_policy(mut self, policy: BackgroundPolicy) -> Self { self.background_policy = policy; self } /// Override root background with a packed RGB value. + /// + /// Under the Facet skin an opaque root covers this fill with the theme's window backdrop, + /// which is derived from the palette's `shell`. pub fn background(mut self, rgb_hex: u32) -> Self { self.background = Some(rgb(rgb_hex).into()); self @@ -750,6 +757,14 @@ impl Render for MoonRoot { let dialog_layer = self.render_dialog_layer_inline(window, cx); let context_menu_layer = self.render_context_menu_layer_inline(window, cx); let notification_layer = self.render_notification_layer_inline(cx); + // Only a root that owns an opaque window fill paints the Facet backdrop: the backdrop is + // opaque, and a transparent or unfilled root exists to show what lies behind the scene. + // The skin is read from the theme global before any tokens are built, so under the Flat + // skin this costs one comparison and adds no element. + let backdrop = (self.background_policy == BackgroundPolicy::Opaque + && MoonSkin::active(cx).is_facet()) + .then(|| moon_backdrop(&MoonTheme::active_tokens(cx))) + .flatten(); let inner = div() .id("root") @@ -773,6 +788,7 @@ impl Render for MoonRoot { }) .text_color(self.text_color.unwrap_or(cx.theme().foreground)) .refine_style(&self.style) + .when_some(backdrop, |this, backdrop| this.child(backdrop)) .child(TextSelectionController) .child(self.view.clone()) .when_some(sheet_layer, |this, layer| this.child(layer)) diff --git a/crates/moon-ui-components/src/select.rs b/crates/moon-ui-components/src/select.rs index c404cbb..c37a8fa 100644 --- a/crates/moon-ui-components/src/select.rs +++ b/crates/moon-ui-components/src/select.rs @@ -14,9 +14,9 @@ use crate::{ actions::{Cancel, Confirm, SelectDown, SelectUp}, button::ButtonVariant, h_flex, - input::{clear_button, input_style}, + input::{clear_button, input_style, trigger_facets}, list::List, - moon::{MoonPalette, rgba_from}, + moon::{MoonPalette, MoonTheme, rgba_from}, searchable_list::{ SearchableListChange, SearchableListDelegate, SearchableListItem, SearchableListState, }, @@ -512,6 +512,17 @@ where .map(|style| rgba_from(style.border, style.border_alpha)) .unwrap_or_else(|| cx.theme().input); + // Under the Facet skin the trigger is a surface of its own, with its own hover and + // press ramp. A caller that set a background on it keeps that background. + let trigger_facet = trigger_facets( + &MoonTheme::active_tokens(cx), + self.trigger_variant, + outline_visible, + self.state.disabled, + ) + .filter(|_| self.state.appearance && self.state.style.background.is_none()); + let flat_ramp = !self.state.disabled && trigger_facet.is_none(); + self.state.list.update(cx, |list, cx| { list.set_searchable(searchable, cx); list.delegate_mut().size = self.state.size; @@ -548,7 +559,7 @@ where .input_size(self.state.size) .input_text_size(self.state.size) .refine_style(&self.state.style) - .when(!self.state.disabled, |this| { + .when(flat_ramp, |this| { this.when_some(trigger_style, |this, style| { this.hover(move |this| { this.bg(rgba_from(style.bg, style.hover_bg_alpha)) @@ -563,6 +574,11 @@ where }) }) }) + .when_some(trigger_facet, |this, [rest, hovered, pressed]| { + rest.paint(this) + .hover(move |this| hovered.paint(this)) + .active(move |this| pressed.paint(this)) + }) .when(outline_visible, |this| this.focused_border(cx)) .when(allow_open, |this| { this.on_click(cx.listener(Self::toggle_menu)) diff --git a/crates/moon-ui-components/src/sidebar/menu.rs b/crates/moon-ui-components/src/sidebar/menu.rs index 52a709a..1a1111c 100644 --- a/crates/moon-ui-components/src/sidebar/menu.rs +++ b/crates/moon-ui-components/src/sidebar/menu.rs @@ -3,17 +3,64 @@ use crate::{ button::{Button, ButtonVariants as _}, h_flex, menu::{ContextMenuExt, PopupMenu}, - moon::{MoonPalette, foundation::selected_background}, + moon::{ + MoonPalette, MoonTheme, MoonThemeTokens, facet_glow, foundation::selected_background, + rgba_from, + }, sidebar::SidebarItem, v_flex, }; use gpui::{ - AnyElement, App, ClickEvent, ElementId, InteractiveElement as _, IntoElement, + AnyElement, App, BoxShadow, ClickEvent, ElementId, Hsla, InteractiveElement as _, IntoElement, ParentElement as _, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled, Window, div, percentage, prelude::FluentBuilder, px, }; use std::rc::Rc; +/// Alpha of the `overlay` ink at the middle of a faceted sidebar's edge rule. +const FACET_EDGE_RULE_ALPHA: f32 = 0.1; +/// Alpha of the accent glow around the active item's bar under the Facet skin. +const FACET_ACTIVE_BAR_GLOW_ALPHA: f32 = 0.6; +/// Blur of that glow as the design gives it, in design-reference pixels. +const FACET_ACTIVE_BAR_GLOW_BLUR: f32 = 8.0; + +/// Resolve the colour of the fading rule a sidebar draws along its inner edge under the Facet +/// skin. +/// +/// A faceted sidebar paints no fill of its own, and the solid border between it and the page +/// becomes a rule that fades out toward both ends. +/// +/// Args: +/// tokens: Active theme tokens; supply the skin and the palette. +/// +/// Returns: +/// The colour at the middle of the rule, or `None` under the Flat skin, where the sidebar +/// keeps its theme fill and its solid border. +pub(super) fn sidebar_facet_edge_rule(tokens: &MoonThemeTokens) -> Option { + tokens + .skin + .is_facet() + .then(|| rgba_from(tokens.palette.overlay, FACET_EDGE_RULE_ALPHA)) +} + +/// Resolve the glow the active item's bar casts under the Facet skin. +/// +/// Args: +/// tokens: Active theme tokens; supply the skin, the palette and the UI scale. +/// +/// Returns: +/// The glow, or `None` under the Flat skin, where the bar casts none. +fn active_bar_facet_glow(tokens: &MoonThemeTokens) -> Option { + tokens.skin.is_facet().then(|| { + facet_glow( + tokens.palette.accent, + FACET_ACTIVE_BAR_GLOW_ALPHA, + FACET_ACTIVE_BAR_GLOW_BLUR, + tokens, + ) + }) +} + /// Menu for the [`super::Sidebar`] #[derive(Clone)] pub struct SidebarMenu { @@ -295,6 +342,8 @@ impl SidebarMenuItem { .as_ref() .map_or(false, |s| !is_collapsed && *s.read(cx)); let p = MoonPalette::active(cx); + let active_bar_glow = active_bar_facet_glow(&MoonTheme::active_tokens(cx)); + let faceted = active_bar_glow.is_some(); let hover_group = SharedString::from(format!("sidebar-row-{id}")); let child_count = self.children.len(); let submenu_half_gap = window.rem_size() * 0.125; @@ -341,18 +390,26 @@ impl SidebarMenuItem { .top(px(3.0)) .bottom(px(3.0)) .w(px(3.0)) - .bg(cx.theme().sidebar_primary), - ) - .child( - div() - .absolute() - .left(px(8.0)) - .top(px(12.0)) - .w(px(4.0)) - .h(px(4.0)) - .rounded_full() - .bg(cx.theme().sidebar_primary), + .bg(cx.theme().sidebar_primary) + .when_some(active_bar_glow, |bar, glow| { + bar.shadow(vec![glow]) + }), ) + // The marker dot sits where the label starts. A faceted item + // is already marked by its glowing bar, so it drops the dot + // rather than print it over the first letter. + .when(!faceted, |this| { + this.child( + div() + .absolute() + .left(px(8.0)) + .top(px(12.0)) + .w(px(4.0)) + .h(px(4.0)) + .rounded_full() + .bg(cx.theme().sidebar_primary), + ) + }) }) .when_some(self.icon.clone(), |this, icon| this.child(icon)) .when(is_collapsed, |this| { diff --git a/crates/moon-ui-components/src/sidebar/menu/tests.rs b/crates/moon-ui-components/src/sidebar/menu/tests.rs index a8aef60..4220566 100644 --- a/crates/moon-ui-components/src/sidebar/menu/tests.rs +++ b/crates/moon-ui-components/src/sidebar/menu/tests.rs @@ -342,3 +342,56 @@ fn grouped_menus_share_gap_hits_at_multiple_rem_sizes(cx: &mut TestAppContext) { assert_eq!(&*clicks.borrow(), &[1, 2]); } } + +// ---- Moon theme skin: the sidebar's edge and the active item's bar ---- + +use crate::moon::{MoonSkin, MoonThemeTokens}; + +/// Theme tokens for `palette` drawn with `skin`, with every length doubled. +fn doubled_skin_tokens(palette: MoonPalette, skin: MoonSkin) -> MoonThemeTokens { + let mut tokens = MoonThemeTokens { + palette, + skin, + ..MoonThemeTokens::default() + }; + tokens.scale.ui = 2.0; + tokens +} + +/// Catches `menu.rs:sidebar_facet_edge_rule` or `active_bar_facet_glow` ignoring the skin: +/// every flat theme's sidebar would lose its fill and solid border, and its active item would +/// grow a glow. +#[test] +fn a_flat_sidebar_keeps_its_fill_border_and_plain_active_bar() { + for palette in [MoonPalette::TERMINAL, MoonPalette::LIGHT] { + let tokens = doubled_skin_tokens(palette, MoonSkin::Flat); + assert_eq!(sidebar_facet_edge_rule(&tokens), None); + assert_eq!(active_bar_facet_glow(&tokens), None); + } +} + +/// Catches `menu.rs:sidebar_facet_edge_rule` drawing the rule at the flat border's strength, +/// and `active_bar_facet_glow` leaving the blur unscaled or giving the glow an offset: the +/// active item's bar would cast a lopsided or undersized halo at a larger UI scale. +#[test] +fn a_faceted_sidebar_fades_its_edge_and_lights_the_active_bar() { + let p = MoonPalette::OBSIDIAN; + let tokens = doubled_skin_tokens(p, MoonSkin::Facet); + + assert_eq!( + sidebar_facet_edge_rule(&tokens), + Some(rgba_from(p.overlay, 0.1)) + ); + // The glow written out: centred on the bar, no spread, 8 design pixels of blur doubled with + // the UI scale and halved into the renderer's blur radius. + assert_eq!( + active_bar_facet_glow(&tokens), + Some(BoxShadow { + color: rgba_from(p.accent, 0.6), + offset: point(px(0.0), px(0.0)), + blur_radius: px(8.0), + spread_radius: px(0.0), + inset: false, + }) + ); +} diff --git a/crates/moon-ui-components/src/sidebar/mod.rs b/crates/moon-ui-components/src/sidebar/mod.rs index 552138b..03205e6 100644 --- a/crates/moon-ui-components/src/sidebar/mod.rs +++ b/crates/moon-ui-components/src/sidebar/mod.rs @@ -401,6 +401,9 @@ impl RenderOnce for Sidebar { let layout = SidebarLayout::new(self.collapsible, self.collapsed, expanded_width, self.side); + let facet_edge_rule = + menu::sidebar_facet_edge_rule(&crate::moon::MoonTheme::active_tokens(cx)); + // Sidebar content renders at its target width immediately. A wrapper // div animates clip-width for smooth transitions without re-laying out // sidebar content each animation frame. @@ -413,6 +416,12 @@ impl RenderOnce for Sidebar { .bg(cx.theme().sidebar) .text_color(cx.theme().sidebar_foreground) .border_color(cx.theme().sidebar_border) + // Moon Facet skin: no fill, and the border keeps its pixel of width but stops + // painting; the fading rule added below takes its place. + .when(facet_edge_rule.is_some(), |this| { + this.bg(gpui::transparent_black()) + .border_color(gpui::transparent_black()) + }) .map(|this| match self.side { Side::Left => this.border_r_1(), Side::Right => this.border_l_1(), @@ -480,6 +489,22 @@ impl RenderOnce for Sidebar { .when(layout.icon_collapsed, |this| this.pt_2().px_2()) .child(footer), ) + }) + // An absolute child is placed inside its parent's border, hence the negative inset + // that puts the rule on the border's own pixel. + .when_some(facet_edge_rule, |this, color| { + this.child( + div() + .absolute() + .top_0() + .bottom_0() + .w(px(1.)) + .map(|rule| match self.side { + Side::Left => rule.right(px(-1.)), + Side::Right => rule.left(px(-1.)), + }) + .child(crate::moon::fading_rule_v(color)), + ) }); let target_width = match layout.wrapper { diff --git a/crates/moon-ui-components/src/slider.rs b/crates/moon-ui-components/src/slider.rs index 90a1ca6..02aef49 100644 --- a/crates/moon-ui-components/src/slider.rs +++ b/crates/moon-ui-components/src/slider.rs @@ -2,14 +2,15 @@ use std::ops::Range; use crate::{ AxisExt, ElementExt, StyledExt, box_shadow, h_flex, - moon::{MoonPalette, rgba_from}, + moon::{MoonFacetKind, MoonPalette, MoonSkin, MoonTheme, facet, rgba_from}, }; use gpui::{ - Along, App, AppContext as _, Axis, Bounds, Context, Corners, DefiniteLength, DragMoveEvent, - ElementId, Empty, Entity, EntityId, EventEmitter, Hsla, InteractiveElement, IntoElement, - MouseButton, MouseDownEvent, ParentElement as _, Pixels, Point, Render, RenderOnce, - ScrollDelta, ScrollWheelEvent, StatefulInteractiveElement as _, StyleRefinement, Styled, - Window, div, linear_color_stop, linear_gradient, prelude::FluentBuilder as _, px, relative, + Along, App, AppContext as _, Axis, Background, Bounds, BoxShadow, Context, Corners, + DefiniteLength, DragMoveEvent, ElementId, Empty, Entity, EntityId, EventEmitter, Hsla, + InteractiveElement, IntoElement, MouseButton, MouseDownEvent, ParentElement as _, Pixels, + Point, Render, RenderOnce, ScrollDelta, ScrollWheelEvent, StatefulInteractiveElement as _, + StyleRefinement, Styled, Window, div, linear_color_stop, linear_gradient, + prelude::FluentBuilder as _, px, relative, white, }; #[derive(Clone)] @@ -33,9 +34,117 @@ impl Render for DragSlider { const MOON_SLIDER_HEIGHT: Pixels = px(18.); const MOON_SLIDER_TRACK_HEIGHT: Pixels = px(2.); const MOON_SLIDER_THUMB_SIZE: Pixels = px(7.); +/// Thickness of the groove the track becomes under the Facet skin. +const MOON_SLIDER_GROOVE_HEIGHT: Pixels = px(4.); +/// Diameter of the thumb under the Facet skin. +const MOON_SLIDER_FACET_THUMB_SIZE: Pixels = px(10.); /// Pixel wheel distance (a trackpad's continuous scroll) that counts as one wheel notch. const WHEEL_NOTCH_PIXELS: f32 = 20.; +/// The lengths a slider is drawn with, which follow the theme's skin. +/// +/// The hit area ([`MOON_SLIDER_HEIGHT`]) is the same under every skin and is not part of this. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct SliderMetrics { + /// Thickness of the track across the slider's axis. + pub(crate) track: Pixels, + /// Thickness of the filled part, which runs along the middle of the track. + pub(crate) fill: Pixels, + /// Diameter of a thumb. + pub(crate) thumb: Pixels, +} + +impl SliderMetrics { + /// Returns the lengths `skin` draws a slider with. + /// + /// The Flat skin draws the hairline slider: a 2px track that its fill covers whole, under a + /// 7px thumb. The Facet skin sinks the track into a 4px groove, keeps the fill at 2px along + /// its middle, and raises a 10px thumb over it. + pub(crate) fn for_skin(skin: MoonSkin) -> Self { + if skin.is_facet() { + Self { + track: MOON_SLIDER_GROOVE_HEIGHT, + fill: MOON_SLIDER_TRACK_HEIGHT, + thumb: MOON_SLIDER_FACET_THUMB_SIZE, + } + } else { + Self { + track: MOON_SLIDER_TRACK_HEIGHT, + fill: MOON_SLIDER_TRACK_HEIGHT, + thumb: MOON_SLIDER_THUMB_SIZE, + } + } + } +} + +/// Returns the glow a thumb casts in its own colour, fainter on a disabled slider. +fn thumb_glow(thumb_color: Hsla, disabled: bool) -> BoxShadow { + box_shadow( + px(0.), + px(0.), + px(14.), + px(0.), + thumb_color.opacity(if disabled { 0.14 } else { 0.72 }), + ) +} + +/// What a slider thumb paints under the Facet skin, in place of its flat disc. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct SliderThumbFacet { + /// The thumb's fill: the colour at its top edge, then the one at its bottom edge. + pub(crate) fill: [Hsla; 2], + /// The thumb's shadow on the groove, followed by the glow the flat thumb has. + pub(crate) shadows: Vec, +} + +impl SliderThumbFacet { + /// Resolves a slider thumb's faceted surface. + /// + /// The thumb is lit from above: `thumb_color` mixed halfway toward white at its top edge and + /// `thumb_color` itself at the bottom. It casts a short shadow on the groove under the same + /// glow the flat thumb has. A disabled thumb fades all three by the flat thumb's factor. + /// + /// Args: + /// skin: The active theme side's skin. + /// p: The active palette, for the shadow ink. + /// thumb_color: The thumb's colour: the accent, or the text colour set on the slider. + /// disabled: Whether the slider is disabled. + /// + /// Returns: + /// The surface to paint, or `None` under the Flat skin, where the flat disc stands. + pub(crate) fn resolve( + skin: MoonSkin, + p: MoonPalette, + thumb_color: Hsla, + disabled: bool, + ) -> Option { + if !skin.is_facet() { + return None; + } + let color = thumb_color.opacity(if disabled { 0.42 } else { 1.0 }); + let drop = box_shadow( + px(0.), + px(1.), + px(2.), + px(0.), + rgba_from(p.shadow, if disabled { 0.25 } else { 0.6 }), + ); + Some(Self { + fill: [color.blend(white().opacity(0.5)), color], + shadows: vec![drop, thumb_glow(thumb_color, disabled)], + }) + } + + /// Builds the thumb's background: the gradient from the top edge's colour to the bottom's. + fn background(&self) -> Background { + linear_gradient( + 180.0, + linear_color_stop(self.fill[0], 0.0), + linear_color_stop(self.fill[1], 1.0), + ) + } +} + /// Translate one wheel event into slider notches, positive meaning "increase". /// /// A line-based delta is one discrete mouse-wheel notch whatever its size: Windows multiplies a @@ -562,39 +671,42 @@ impl Slider { radius: Corners, disabled: bool, window: &mut Window, - _cx: &mut App, + cx: &mut App, ) -> impl gpui::IntoElement { let entity_id = self.state.entity_id(); let axis = self.axis; let id = ("slider-thumb", is_start as u32); + let skin = MoonSkin::active(cx); + let metrics = SliderMetrics::for_skin(skin); + let (thumb_size, track_height) = (metrics.thumb, metrics.track); + let thumb_facet = + SliderThumbFacet::resolve(skin, MoonPalette::active(cx), thumb_color, disabled); div() .id(id) .absolute() .when(axis.is_horizontal(), |this| { - this.top(-(MOON_SLIDER_THUMB_SIZE - MOON_SLIDER_TRACK_HEIGHT) * 0.5) + this.top(-(thumb_size - track_height) * 0.5) .left(start) - .ml(-MOON_SLIDER_THUMB_SIZE * 0.5) + .ml(-thumb_size * 0.5) }) .when(axis.is_vertical(), |this| { this.bottom(start) - .left(-(MOON_SLIDER_THUMB_SIZE - MOON_SLIDER_TRACK_HEIGHT) * 0.5) - .mb(-MOON_SLIDER_THUMB_SIZE * 0.5) + .left(-(thumb_size - track_height) * 0.5) + .mb(-thumb_size * 0.5) }) .flex() .items_center() .justify_center() .flex_shrink_0() .corner_radii(radius) - .size(MOON_SLIDER_THUMB_SIZE) + .size(thumb_size) .bg(thumb_color.opacity(if disabled { 0.42 } else { 1.0 })) - .shadow(vec![box_shadow( - px(0.), - px(0.), - px(14.), - px(0.), - thumb_color.opacity(if disabled { 0.14 } else { 0.72 }), - )]) + .shadow(vec![thumb_glow(thumb_color, disabled)]) + // Under the Facet skin the thumb is lit from above and casts a shadow on the groove. + .when_some(thumb_facet, |this, thumb| { + this.bg(thumb.background()).shadow(thumb.shadows) + }) .on_mouse_down(MouseButton::Left, |_, _, cx| { cx.stop_propagation(); }) @@ -649,6 +761,13 @@ impl RenderOnce for Slider { let p = MoonPalette::active(cx); let accent = rgba_from(p.accent, 1.0); let fill_alpha = if self.disabled { 0.24 } else { 0.88 }; + let skin = MoonSkin::active(cx); + let metrics = SliderMetrics::for_skin(skin); + // Under the Facet skin the track is a groove: the skin's well, without its edge, which a + // groove this thin has no room for. + let groove = skin + .is_facet() + .then(|| facet(MoonFacetKind::Well, &MoonTheme::active_tokens(cx))); let thumb_color = self.style.text.color.unwrap_or(accent); let corner_radii = self.style.corner_radii.clone(); @@ -772,17 +891,14 @@ impl RenderOnce for Slider { div() .id("slider-bar") .relative() - .when(axis.is_horizontal(), |this| { - this.w_full().h(MOON_SLIDER_TRACK_HEIGHT) - }) - .when(axis.is_vertical(), |this| { - this.h_full().w(MOON_SLIDER_TRACK_HEIGHT) - }) + .when(axis.is_horizontal(), |this| this.w_full().h(metrics.track)) + .when(axis.is_vertical(), |this| this.h_full().w(metrics.track)) .bg(rgba_from( p.overlay, if self.disabled { 0.03 } else { 0.05 }, )) .corner_radii(radius) + .when_some(groove, |this, well| this.bg(well.fill).shadow(well.shadows)) .child( div() .absolute() @@ -792,6 +908,16 @@ impl RenderOnce for Slider { .when(axis.is_vertical(), |this| { this.w_full().bottom(bar_start).top(bar_end) }) + // A groove is thicker than its fill, which then keeps its + // own thickness and runs along the groove's middle. + .when(metrics.fill < metrics.track, |this| { + let inset = (metrics.track - metrics.fill) * 0.5; + if axis.is_horizontal() { + this.top(inset).h(metrics.fill) + } else { + this.left(inset).w(metrics.fill) + } + }) .bg(linear_gradient( if axis.is_horizontal() { 90.0 } else { 0.0 }, linear_color_stop(accent.opacity(0.0), 0.0), diff --git a/crates/moon-ui-components/themes/moon-obsidian.toml b/crates/moon-ui-components/themes/moon-obsidian.toml new file mode 100644 index 0000000..b7bd390 --- /dev/null +++ b/crates/moon-ui-components/themes/moon-obsidian.toml @@ -0,0 +1,144 @@ +mode = "Dark" + +[dark] +skin = "Facet" + +[dark.palette] +shell = 0x050608 +shell_high = 0x0D0F12 +window = 0x050608 +surface = 0x0A0B0E +panel = 0x171A1F +panel_high = 0x22262D +chrome = 0x0D0F12 +tabbar = 0x0D0F12 +panel_head = 0x1A1D22 +gutter = 0x08090B +chart_bg = 0x08090B +card = 0x13151A +row_alt = 0x0D0F12 +head_row = 0x101216 +border = 0x1F2227 +border_soft = 0x1A1D21 +border_card = 0x1F2227 +border_hover = 0x3A3F47 +row_line = 0x1A1D21 +shadow = 0x000000 +overlay = 0xFFFFFF +on_accent = 0xFFFFFF +text = 0xE8E4DC +text_soft = 0xA6A198 +text_dim = 0xE8E4DC +text_muted = 0x878279 +text_faint = 0x878279 +table_head = 0x101216 +table_body = 0x0D0F12 +table_selected = 0xFFB347 +table_hover = 0x171A1F +green = 0x34C07A +green_btn = 0x1E8C5B +green_text = 0x34C07A +red = 0xF26A69 +red_text = 0xF26A69 +red_soft_bd = 0xF26A69 +orange = 0xFF8E5A +amber = 0xFFB347 +blue = 0x7FC9FF +accent = 0xFFB347 +accent_fg = 0xFFCF94 +accent_tint_a = 0.11 +yellow = 0xFFD93D + +[dark.metrics] +header_top_h = 32.0 +toolbar_h = 32.0 +status_h = 22.0 +dock_tab_h = 26.0 +table_header_h = 26.0 +table_row_h = 25.0 +button_radius = 4.0 +container_radius = 8.0 +hairline = 1.0 + +[dark.scale] +ui = 1.0 +font = 1.0 +font_delta = 0.0 +zoom = 1.0 + +[dark.typography] +font_family = "Inter" +mono_font_family = "Geist Mono" +font_size = 12.0 +mono_font_size = 11.0 +rem_size = 16.0 + +[light.palette] +shell = 0xF3F5F7 +shell_high = 0xFAFBFC +window = 0xF7F8FA +surface = 0xFFFFFF +panel = 0xF8FAFC +panel_high = 0xFFFFFF +chrome = 0xF5F7FA +tabbar = 0xF2F5F8 +panel_head = 0xF5F7FA +gutter = 0xEEF2F6 +chart_bg = 0xFFFFFF +card = 0xFFFFFF +row_alt = 0xFCFDFE +head_row = 0xF3F6F8 +border = 0xD5DBE1 +border_soft = 0xE1E5EA +border_card = 0xDCE2E8 +border_hover = 0xB8C2CC +row_line = 0xECEFF2 +shadow = 0x000000 +overlay = 0x000000 +on_accent = 0xFFFFFF +text = 0x17202A +text_soft = 0x4B5865 +text_dim = 0x2D3945 +text_muted = 0x768391 +text_faint = 0x98A3AE +table_head = 0xF3F6F8 +table_body = 0xFFFFFF +table_selected = 0x009DFF +table_hover = 0xEEF2F6 +green = 0x178A57 +green_btn = 0x178A57 +green_text = 0x0E6E45 +red = 0xD2483F +red_text = 0xB7352F +red_soft_bd = 0xE1B5B0 +orange = 0xD18A2B +amber = 0xB97824 +blue = 0x2B6F9E +accent = 0x009DFF +accent_fg = 0x0A3F68 +accent_tint_a = 0.08 +yellow = 0xB8860B + +[light.metrics] +header_top_h = 32.0 +toolbar_h = 32.0 +status_h = 22.0 +dock_tab_h = 26.0 +table_header_h = 26.0 +table_row_h = 25.0 +button_radius = 4.0 +container_radius = 8.0 +hairline = 1.0 + +[light.scale] +ui = 1.0 +font = 1.0 +font_delta = 0.0 +zoom = 1.0 + +[light.typography] +font_family = "Inter" +mono_font_family = "Geist Mono" +font_size = 12.0 +mono_font_size = 11.0 +rem_size = 16.0 diff --git a/crates/moon-ui-gallery/src/gallery.rs b/crates/moon-ui-gallery/src/gallery.rs index 3233cde..9b55ef6 100644 --- a/crates/moon-ui-gallery/src/gallery.rs +++ b/crates/moon-ui-gallery/src/gallery.rs @@ -63,7 +63,13 @@ pub(super) struct Gallery { enum GalleryStyle { Dark, Graphite, + /// The faceted dark theme: `MoonPalette::OBSIDIAN` drawn with `MoonSkin::Facet`. + Obsidian, Light, + /// The light palette drawn with `MoonSkin::Facet`: the rules Obsidian is drawn by, on a + /// light theme. Not a shipped theme — it is here to show that the skin is not tied to one + /// palette. + LightFacet, /// The dark colour mode, `MoonColors::DARK`. DarkNew, /// The light colour mode, `MoonColors::LIGHT`. @@ -72,20 +78,24 @@ enum GalleryStyle { impl GalleryStyle { /// Every style, in menu order. - const ALL: [Self; 5] = [ + const ALL: [Self; 7] = [ Self::Dark, Self::Graphite, + Self::Obsidian, Self::Light, + Self::LightFacet, Self::DarkNew, Self::LightNew, ]; - /// Returns the style a launch mode opens in: `Light` for the light mode, `Dark` otherwise, - /// since the system mode renders the dark theme. - fn for_mode(mode: ThemeMode) -> Self { - match mode { - ThemeMode::Light => Self::Light, - ThemeMode::Dark | ThemeMode::System => Self::Dark, + /// Returns the style a launch theme opens in: `Obsidian` for that theme, otherwise `Light` + /// for the light mode and `Dark` for the rest, since the system mode renders the dark theme. + fn for_theme(theme: GalleryTheme) -> Self { + match theme { + GalleryTheme::Obsidian => Self::Obsidian, + GalleryTheme::LightFacet => Self::LightFacet, + GalleryTheme::Mode(ThemeMode::Light) => Self::Light, + GalleryTheme::Mode(ThemeMode::Dark | ThemeMode::System) => Self::Dark, } } @@ -94,7 +104,9 @@ impl GalleryStyle { match self { Self::Dark => "Dark", Self::Graphite => "Graphite", + Self::Obsidian => "Obsidian", Self::Light => "Light", + Self::LightFacet => "Light (facet)", Self::DarkNew => "Dark (new)", Self::LightNew => "Light (new)", } @@ -112,10 +124,11 @@ impl GalleryStyle { /// Returns `current` switched to this style. /// - /// Only the mode and each side's palette and colour roles change, so the theme's scale, - /// metrics and typography carry over. Both sides are set for every style: switching back from - /// Graphite or a colour mode must put the legacy palettes back and drop the installed roles, - /// or the previous style's colours would stay behind the new label. + /// Only the mode and each side's skin, palette and colour roles change, so the theme's + /// scale, metrics and typography carry over. Both sides are set for every style: switching + /// back from Graphite, Obsidian or a colour mode must put the legacy palettes and the flat + /// skin back and drop the installed roles, or the previous style's colours or facets would + /// stay behind the new label. fn config(self, mut current: MoonThemeConfig) -> MoonThemeConfig { if self.is_color_mode() { let modes = MoonThemeConfig::moon_color_modes(); @@ -123,18 +136,28 @@ impl GalleryStyle { (current.light.palette, current.light.colors) = (modes.light.palette, modes.light.colors); } else { - current.dark.palette = if self == Self::Graphite { - MoonPalette::GRAPHITE - } else { - MoonPalette::TERMINAL + current.dark.palette = match self { + Self::Graphite => MoonPalette::GRAPHITE, + Self::Obsidian => MoonPalette::OBSIDIAN, + _ => MoonPalette::TERMINAL, }; current.light.palette = MoonPalette::LIGHT; current.dark.colors = None; current.light.colors = None; } + current.dark.skin = if self == Self::Obsidian { + MoonSkin::Facet + } else { + MoonSkin::Flat + }; + current.light.skin = if self == Self::LightFacet { + MoonSkin::Facet + } else { + MoonSkin::Flat + }; current.mode = match self { - Self::Light | Self::LightNew => ThemeMode::Light, - Self::Dark | Self::Graphite | Self::DarkNew => ThemeMode::Dark, + Self::Light | Self::LightFacet | Self::LightNew => ThemeMode::Light, + Self::Dark | Self::Graphite | Self::Obsidian | Self::DarkNew => ThemeMode::Dark, }; current } @@ -172,7 +195,7 @@ impl Gallery { cx: &mut Context, active_page: usize, snapshot_dir: Option, - theme_mode: ThemeMode, + theme: GalleryTheme, ) -> Self { let select_state = cx.new(|cx| { MoonSelectState::new( @@ -321,7 +344,7 @@ impl Gallery { Self { active_page: active_page.min(GALLERY_PAGES.len().saturating_sub(1)), - style: GalleryStyle::for_mode(theme_mode), + style: GalleryStyle::for_theme(theme), snapshot: snapshot_dir.map(|dir| SnapshotRun { dir, page_ix: active_page.min(GALLERY_PAGES.len().saturating_sub(1)), @@ -3562,7 +3585,11 @@ impl Render for Gallery { v_flex() .size_full() - .bg(rgba_from(p.shell, 1.0)) + // Under the Facet skin the root paints the window backdrop; an opaque fill here + // would cover it. + .when(!MoonSkin::active(cx).is_facet(), |this| { + this.bg(rgba_from(p.shell, 1.0)) + }) .text_color(rgb(p.text)) .child(self.render_header(cx)) .child(self.render_page_tabs(cx)) diff --git a/crates/moon-ui-gallery/src/gallery/tests.rs b/crates/moon-ui-gallery/src/gallery/tests.rs index 8bdc8ac..f13e14b 100644 --- a/crates/moon-ui-gallery/src/gallery/tests.rs +++ b/crates/moon-ui-gallery/src/gallery/tests.rs @@ -1,6 +1,6 @@ use super::GalleryStyle; -use crate::{COMPONENT_COVERAGE, page_index, parse_theme_mode, theme_mode_name}; -use moon_ui::{MoonColors, MoonPalette, MoonThemeConfig, ThemeMode}; +use crate::{COMPONENT_COVERAGE, GalleryTheme, page_index, parse_gallery_theme, parse_theme_mode}; +use moon_ui::{MoonColors, MoonPalette, MoonSkin, MoonThemeConfig, ThemeMode}; use serde::Deserialize; const COMPONENT_MANIFEST_JSON: &str = @@ -66,9 +66,10 @@ fn gallery_theme_cli_names_match_modes() { assert_eq!(parse_theme_mode("Light"), Some(ThemeMode::Light)); assert_eq!(parse_theme_mode("system"), Some(ThemeMode::System)); assert_eq!(parse_theme_mode("missing"), None); - assert_eq!(theme_mode_name(ThemeMode::Dark), "Dark"); - assert_eq!(theme_mode_name(ThemeMode::Light), "Light"); - assert_eq!(theme_mode_name(ThemeMode::System), "System"); + // The snapshot writer must not let a second dark/light skin overwrite the original baseline. + for name in ["dark", "light", "system", "obsidian", "light-facet"] { + assert_eq!(parse_gallery_theme(name).unwrap().snapshot_name(), name); + } } /// Catches `gallery.rs:GalleryStyle::config` leaving the previous style behind: choosing Dark after @@ -101,6 +102,119 @@ fn switching_styles_installs_each_styles_own_palette_and_mode() { assert_eq!(dark.dark.colors, None); } +/// Catches `main.rs:parse_gallery_theme` no longer recognising `obsidian`, which would open the +/// dark terminal theme under that name, or swallowing a mode name, which would change what +/// `--theme dark` and `--theme light` open. +#[test] +fn gallery_theme_cli_names_select_obsidian_or_a_mode() { + assert_eq!( + parse_gallery_theme("obsidian"), + Some(GalleryTheme::Obsidian) + ); + assert_eq!( + parse_gallery_theme("Obsidian"), + Some(GalleryTheme::Obsidian) + ); + assert_eq!( + parse_gallery_theme("dark"), + Some(GalleryTheme::Mode(ThemeMode::Dark)) + ); + assert_eq!( + parse_gallery_theme("Light"), + Some(GalleryTheme::Mode(ThemeMode::Light)) + ); + assert_eq!( + parse_gallery_theme("system"), + Some(GalleryTheme::Mode(ThemeMode::System)) + ); + assert_eq!(parse_gallery_theme("missing"), None); +} + +/// Catches `main.rs:GalleryTheme::config` installing the wrong bundled theme: `dark` and +/// `light` must stay the flat terminal theme in that mode, and only `obsidian` may bring the +/// Obsidian palette and the Facet skin, with the header's style menu opening on the same name. +#[test] +fn launch_themes_install_the_terminal_theme_by_mode_and_obsidian_by_name() { + let dark = GalleryTheme::Mode(ThemeMode::Dark).config(); + assert_eq!(dark.mode, ThemeMode::Dark); + assert_eq!(dark.dark.palette, MoonPalette::TERMINAL); + assert_eq!(dark.dark.skin, MoonSkin::Flat); + + let light = GalleryTheme::Mode(ThemeMode::Light).config(); + assert_eq!(light.mode, ThemeMode::Light); + assert_eq!(light.light.palette, MoonPalette::LIGHT); + assert_eq!(light.light.skin, MoonSkin::Flat); + + let obsidian = GalleryTheme::Obsidian.config(); + assert_eq!(obsidian.mode, ThemeMode::Dark); + assert_eq!(obsidian.dark.palette, MoonPalette::OBSIDIAN); + assert_eq!(obsidian.dark.skin, MoonSkin::Facet); + assert_eq!(GalleryTheme::Obsidian.config().mode, ThemeMode::Dark); + + for (theme, style) in [ + (GalleryTheme::Obsidian, GalleryStyle::Obsidian), + (GalleryTheme::Mode(ThemeMode::Dark), GalleryStyle::Dark), + (GalleryTheme::Mode(ThemeMode::System), GalleryStyle::Dark), + (GalleryTheme::Mode(ThemeMode::Light), GalleryStyle::Light), + (GalleryTheme::LightFacet, GalleryStyle::LightFacet), + ] { + assert_eq!(GalleryStyle::for_theme(theme), style); + } +} + +/// Catches the light faceted style losing its skin or its side: `--theme light-facet` and the +/// "Light (facet)" menu row exist to show the Facet rules on a light palette, so each must +/// install the light palette, in light mode, with the Facet skin on that side only. +#[test] +fn the_light_facet_style_draws_the_light_palette_with_the_facet_skin() { + let launched = GalleryTheme::LightFacet.config(); + let switched = GalleryStyle::LightFacet.config(MoonThemeConfig::moon_obsidian()); + for config in [launched, switched.clone()] { + assert_eq!(config.mode, ThemeMode::Light); + assert_eq!(config.light.palette, MoonPalette::LIGHT); + assert_eq!(config.light.skin, MoonSkin::Facet); + assert_eq!(config.dark.skin, MoonSkin::Flat); + } + assert_eq!(GalleryTheme::LightFacet.config().mode, ThemeMode::Light); + + let back = GalleryStyle::Light.config(switched); + assert_eq!(back.light.skin, MoonSkin::Flat); +} + +/// Catches `gallery.rs:GalleryStyle::config` leaving the Facet skin behind: choosing Graphite +/// after Obsidian would draw graphite surfaces faceted under a "Graphite" label. +#[test] +fn switching_away_from_obsidian_puts_the_flat_skin_back() { + let obsidian = GalleryStyle::Obsidian.config(MoonThemeConfig::moon_terminal()); + assert_eq!(obsidian.mode, ThemeMode::Dark); + assert_eq!(obsidian.dark.palette, MoonPalette::OBSIDIAN); + assert_eq!(obsidian.dark.skin, MoonSkin::Facet); + assert_eq!(obsidian.light.palette, MoonPalette::LIGHT); + assert_eq!(obsidian.light.skin, MoonSkin::Flat); + + for style in GalleryStyle::ALL { + if style == GalleryStyle::Obsidian { + continue; + } + let next = style.config(obsidian.clone()); + assert_eq!( + next.dark.skin, + MoonSkin::Flat, + "{}: dark side", + style.name() + ); + // The light faceted style is the one other style that draws with the skin, and only + // on the side it renders. + let light_skin = if style == GalleryStyle::LightFacet { + MoonSkin::Facet + } else { + MoonSkin::Flat + }; + assert_eq!(next.light.skin, light_skin, "{}: light side", style.name()); + assert_ne!(next.dark.palette, MoonPalette::OBSIDIAN, "{}", style.name()); + } +} + /// Catches a style's menu label drifting from the key `GalleryStyle::from_name` looks up: the /// dropdown would show the option, but selecting it would silently do nothing. #[test] diff --git a/crates/moon-ui-gallery/src/handoff.rs b/crates/moon-ui-gallery/src/handoff.rs index ec976ac..9adb9c4 100644 --- a/crates/moon-ui-gallery/src/handoff.rs +++ b/crates/moon-ui-gallery/src/handoff.rs @@ -56,7 +56,7 @@ struct CaseSnapshotRun { pub(super) struct CaseGallery { snapshot: Option, #[cfg(feature = "snapshot")] - theme_mode: ThemeMode, + theme: GalleryTheme, #[cfg(feature = "snapshot")] dialog_case_open: bool, slider_58_state: Entity, @@ -86,10 +86,10 @@ impl CaseGallery { cx: &mut Context, snapshot_dir: Option, snapshot_case_ids: Vec, - theme_mode: ThemeMode, + theme: GalleryTheme, ) -> Self { #[cfg(not(feature = "snapshot"))] - let _ = theme_mode; + let _ = theme; let first_case = HANDOFF_CASES.first().copied().unwrap_or(HandoffCase { id: "empty", @@ -209,7 +209,7 @@ impl CaseGallery { targeted, }), #[cfg(feature = "snapshot")] - theme_mode, + theme, #[cfg(feature = "snapshot")] dialog_case_open: false, slider_58_state, @@ -291,9 +291,7 @@ impl CaseGallery { return; } - let theme_dir = snapshot - .dir - .join(theme_mode_name(self.theme_mode).to_ascii_lowercase()); + let theme_dir = snapshot.dir.join(self.theme.snapshot_name()); if !snapshot.targeted && !snapshot.cleaned_dir { if let Err(err) = clear_snapshot_dir(&theme_dir) { eprintln!( diff --git a/crates/moon-ui-gallery/src/launch.rs b/crates/moon-ui-gallery/src/launch.rs index e6451ce..b4e6529 100644 --- a/crates/moon-ui-gallery/src/launch.rs +++ b/crates/moon-ui-gallery/src/launch.rs @@ -9,14 +9,12 @@ pub(super) fn run_gallery() { let snapshot_dir = args.snapshot_dir; let case_snapshot_dir = args.case_snapshot_dir; let snapshot_case_ids = args.snapshot_case_ids; - let theme_mode = args.theme_mode; + let theme = args.theme; application() .with_assets(moon_ui::MoonAssets) .run(move |cx: &mut App| { moon_ui::foundation::init(cx); - let mut theme_config = MoonThemeConfig::moon_terminal(); - theme_config.mode = theme_mode; - MoonTheme::install_config(theme_config, cx); + MoonTheme::install_config(theme.config(), cx); let p = MoonPalette::active(cx); if let Some(case_dir) = case_snapshot_dir.clone() { @@ -42,7 +40,7 @@ pub(super) fn run_gallery() { cx, Some(case_dir.clone()), snapshot_case_ids.clone(), - theme_mode, + theme, ) }); cx.new(|cx| { @@ -75,7 +73,7 @@ pub(super) fn run_gallery() { cx, initial_page, snapshot_dir.clone(), - theme_mode, + theme, ) }); cx.new(|cx| { diff --git a/crates/moon-ui-gallery/src/main.rs b/crates/moon-ui-gallery/src/main.rs index c381ea4..e80206e 100644 --- a/crates/moon-ui-gallery/src/main.rs +++ b/crates/moon-ui-gallery/src/main.rs @@ -34,16 +34,17 @@ use moon_ui::{ MoonSelectItem, MoonSelectState, MoonSelectableCard, MoonSelectorPill, MoonSelectorSegment, MoonSeparator, MoonSettingField, MoonSettingGroup, MoonSettingItem, MoonSettingPage, MoonSettings, MoonSidebar, MoonSidebarGroup, MoonSidebarMenu, MoonSidebarMenuItem, - MoonSidebarToggleButton, MoonSize, MoonSkeleton, MoonSlider, MoonSliderState, MoonSpinner, - MoonSpinnerSize, MoonStackedBar, MoonStatusBar, MoonStatusIndicator, MoonStatusItem, - MoonStepper, MoonSteps, MoonSurface, MoonSurfaceVariant, MoonTabItem, MoonTabStrip, - MoonTableCell, MoonTableColumn, MoonTableRow, MoonTableStyle, MoonTag, MoonTagRadius, - MoonTagVariant, MoonText, MoonTextArea, MoonTheme, MoonThemeConfig, MoonTimePicker, - MoonTimePickerState, MoonToggle, MoonToggleLabelSide, MoonToggleVariant, MoonTone, MoonTooltip, - MoonTooltipPlacement, MoonTooltipSize, MoonTooltipView, MoonTree, MoonTreeItem, - MoonTreeSelectionMode, MoonTreeState, MoonVirtualList, MoonVirtualListScrollHandle, - MoonWindowExt as _, MoonWindowFrame, MoonWindowFrameBrand, MoonWindowFrameControls, PanelView, - Root, TabPanel, ThemeMode, h_flex, moon_h_resizable, moon_resizable_panel, rgba_from, v_flex, + MoonSidebarToggleButton, MoonSize, MoonSkeleton, MoonSkin, MoonSlider, MoonSliderState, + MoonSpinner, MoonSpinnerSize, MoonStackedBar, MoonStatusBar, MoonStatusIndicator, + MoonStatusItem, MoonStepper, MoonSteps, MoonSurface, MoonSurfaceVariant, MoonTabItem, + MoonTabStrip, MoonTableCell, MoonTableColumn, MoonTableRow, MoonTableStyle, MoonTag, + MoonTagRadius, MoonTagVariant, MoonText, MoonTextArea, MoonTheme, MoonThemeConfig, + MoonTimePicker, MoonTimePickerState, MoonToggle, MoonToggleLabelSide, MoonToggleVariant, + MoonTone, MoonTooltip, MoonTooltipPlacement, MoonTooltipSize, MoonTooltipView, MoonTree, + MoonTreeItem, MoonTreeSelectionMode, MoonTreeState, MoonVirtualList, + MoonVirtualListScrollHandle, MoonWindowExt as _, MoonWindowFrame, MoonWindowFrameBrand, + MoonWindowFrameControls, PanelView, Root, TabPanel, ThemeMode, h_flex, moon_h_resizable, + moon_resizable_panel, rgba_from, v_flex, }; mod gallery; @@ -181,7 +182,53 @@ struct GalleryArgs { snapshot_dir: Option, case_snapshot_dir: Option, snapshot_case_ids: Vec, - theme_mode: ThemeMode, + theme: GalleryTheme, +} + +/// What a `--theme` value opens the gallery in. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum GalleryTheme { + /// `dark`, `light` or `system`: the terminal theme in that mode. + Mode(ThemeMode), + /// `obsidian`: the faceted dark theme, `MoonThemeConfig::moon_obsidian()`. + Obsidian, + /// `light-facet`: the terminal's light theme drawn with the Facet skin. + LightFacet, +} + +impl GalleryTheme { + /// What the gallery opens in when `--theme` is absent or names nothing known. + const DEFAULT: Self = Self::Mode(ThemeMode::Dark); + + /// Keep snapshot directories distinct even when two themes share the same dark/light mode. + #[cfg(any(feature = "snapshot", test))] + fn snapshot_name(self) -> &'static str { + match self { + Self::Mode(ThemeMode::Dark) => "dark", + Self::Mode(ThemeMode::Light) => "light", + Self::Mode(ThemeMode::System) => "system", + Self::Obsidian => "obsidian", + Self::LightFacet => "light-facet", + } + } + + /// Returns the theme configuration to install for this choice. + fn config(self) -> MoonThemeConfig { + match self { + Self::Mode(mode) => { + let mut config = MoonThemeConfig::moon_terminal(); + config.mode = mode; + config + } + Self::Obsidian => MoonThemeConfig::moon_obsidian(), + Self::LightFacet => { + let mut config = MoonThemeConfig::moon_terminal(); + config.mode = ThemeMode::Light; + config.light.skin = moon_ui::MoonSkin::Facet; + config + } + } + } } fn gallery_args_from_cli() -> GalleryArgs { @@ -190,7 +237,7 @@ fn gallery_args_from_cli() -> GalleryArgs { let mut snapshot_dir = None; let mut case_snapshot_dir = None; let mut snapshot_case_ids = Vec::new(); - let mut theme_mode = ThemeMode::Dark; + let mut theme = GalleryTheme::DEFAULT; while let Some(arg) = args.next() { if arg == "--page" { if let Some(page_name) = args.next() { @@ -217,11 +264,11 @@ fn gallery_args_from_cli() -> GalleryArgs { } else if let Some(cases) = arg.strip_prefix("--snapshot-cases=") { snapshot_case_ids.extend(parse_snapshot_case_ids(cases)); } else if arg == "--theme" { - if let Some(mode) = args.next() { - theme_mode = parse_theme_mode(&mode).unwrap_or(ThemeMode::Dark); + if let Some(name) = args.next() { + theme = parse_gallery_theme(&name).unwrap_or(GalleryTheme::DEFAULT); } - } else if let Some(mode) = arg.strip_prefix("--theme=") { - theme_mode = parse_theme_mode(mode).unwrap_or(ThemeMode::Dark); + } else if let Some(name) = arg.strip_prefix("--theme=") { + theme = parse_gallery_theme(name).unwrap_or(GalleryTheme::DEFAULT); } } if snapshot_dir.is_some() { @@ -232,7 +279,7 @@ fn gallery_args_from_cli() -> GalleryArgs { snapshot_dir, case_snapshot_dir, snapshot_case_ids, - theme_mode, + theme, } } @@ -250,6 +297,18 @@ fn page_index(page: &str) -> Option { .position(|candidate| candidate.eq_ignore_ascii_case(page)) } +/// Parses a `--theme` value, in any letter case: `obsidian` names that bundled theme, and +/// anything else is read as a theme mode by [`parse_theme_mode`]. +fn parse_gallery_theme(name: &str) -> Option { + if name.eq_ignore_ascii_case("obsidian") { + Some(GalleryTheme::Obsidian) + } else if name.eq_ignore_ascii_case("light-facet") { + Some(GalleryTheme::LightFacet) + } else { + parse_theme_mode(name).map(GalleryTheme::Mode) + } +} + fn parse_theme_mode(mode: &str) -> Option { match mode.to_ascii_lowercase().as_str() { "light" => Some(ThemeMode::Light), @@ -258,15 +317,3 @@ fn parse_theme_mode(mode: &str) -> Option { _ => None, } } - -/// Returns the display name of a theme mode, which the handoff snapshot run also uses for its -/// per-theme directory. Only that snapshot build reads it outside tests since the gallery header -/// switched to named styles. -#[cfg_attr(not(feature = "snapshot"), allow(dead_code))] -fn theme_mode_name(mode: ThemeMode) -> &'static str { - match mode { - ThemeMode::Light => "Light", - ThemeMode::Dark => "Dark", - ThemeMode::System => "System", - } -} diff --git a/docs/PALETTE_SPEC.md b/docs/PALETTE_SPEC.md index ce9898b..13ab387 100644 --- a/docs/PALETTE_SPEC.md +++ b/docs/PALETTE_SPEC.md @@ -94,19 +94,53 @@ application panels. ### Bundled themes -Three palettes ship in the crate, each a `MoonPalette` constant in +Four palettes ship in the crate, each a `MoonPalette` constant in `moon/tokens.rs` paired with a theme file under `crates/moon-ui-components/themes/`: -| theme | file | palette | colour set | -|---|---|---|---| -| Terminal | `moon-terminal.toml` | `MoonPalette::TERMINAL` | dark | -| Light | `moon-light.toml` | `MoonPalette::LIGHT` | light | -| Graphite | `moon-graphite.toml` | `MoonPalette::GRAPHITE` | dark | +| theme | file | palette | colour set | skin | +|---|---|---|---|---| +| Terminal | `moon-terminal.toml` | `MoonPalette::TERMINAL` | dark | flat | +| Light | `moon-light.toml` | `MoonPalette::LIGHT` | light | flat | +| Graphite | `moon-graphite.toml` | `MoonPalette::GRAPHITE` | dark | flat | +| Obsidian | `moon-obsidian.toml` | `MoonPalette::OBSIDIAN` | dark | facet | The constant and the file hold the same values on purpose — the constant is what code reads, the file is what a host installs — so a change to one is a change to both, and a test pins the pair. +The skin is the manner in which a theme side draws depth, `MoonSkin` in +`moon/skin.rs`, and it is separate from the palette. `Flat` is a solid fill and a +hairline, and is what a theme file gets when it names no skin. `Facet` lights +each surface from above — a gradient fill, a one-pixel edge, a highlight and a +shadow — with every colour derived from the palette by the rules in that module, +so it needs no palette entries of its own. A component consults the skin only to +draw the faceted look; under `Flat` it must paint exactly what it painted before +skins existed. The skin is set per side: Obsidian's light side is the flat light +theme. + +Three rules of the faceted look are easy to break from a component: + +- **A blur is written as the design gives it.** The design's shadows and glows + are CSS `box-shadow` values, where the gaussian's standard deviation is half + the blur; the renderer takes the standard deviation itself as its blur radius. + `facet`, `facet_tinted`, `facet_glow` and the backdrop lights convert on the + way in, so a component passes the design's number to them. A shadow built + directly with `box_shadow(..)` is not converted, and a design blur passed that + way renders twice as wide and far fainter than designed. +- **A small socket is not a well.** A checkbox box, a radio circle and a toggle + track take `MoonFacetKind::Socket`: the well's fill and shadows under an edge + that still reads at a dozen pixels. The plain `Well` edge is right for an + input and disappears on a control that small. +- **The ambient light belongs to the window, not to a panel.** `MoonRoot` paints + `moon_backdrop` when it owns an opaque window fill. A host whose root paints + nothing, because something shows through it, lays `moon_ambient_light` inside + an opaque band of its own, clipped to that band, and never over what shows + through. + +The rules hold on a light palette too: the gallery's "Light (facet)" style +(`--theme light-facet`) draws `MoonPalette::LIGHT` with the Facet skin. It is a +demonstration, not a bundled theme. + Two rules bind a new bundled palette: - **Which side it is on is MEASURED, never declared.** `MoonPalette::is_light` diff --git a/docs/component-api-baseline.json b/docs/component-api-baseline.json index 61cd6e9..c3d023a 100644 --- a/docs/component-api-baseline.json +++ b/docs/component-api-baseline.json @@ -2777,6 +2777,10 @@ "file": "crates/moon-ui-components/src/moon/mod.rs", "signature": "pub use skeleton::MoonSkeleton;" }, + { + "file": "crates/moon-ui-components/src/moon/mod.rs", + "signature": "pub use skin::{ MoonFacet, MoonFacetKind, MoonFacetTint, MoonSkin, facet, facet_glow, facet_tinted, fading_rule_h, fading_rule_v, moon_ambient_light, moon_backdrop, };" + }, { "file": "crates/moon-ui-components/src/moon/mod.rs", "signature": "pub use slider::{MoonSlider, MoonSliderEvent, MoonSliderState};" @@ -3973,6 +3977,62 @@ "file": "crates/moon-ui-components/src/moon/skeleton.rs", "signature": "pub struct MoonSkeleton" }, + { + "file": "crates/moon-ui-components/src/moon/skin.rs", + "signature": "pub enum MoonFacetKind" + }, + { + "file": "crates/moon-ui-components/src/moon/skin.rs", + "signature": "pub enum MoonFacetTint" + }, + { + "file": "crates/moon-ui-components/src/moon/skin.rs", + "signature": "pub enum MoonSkin" + }, + { + "file": "crates/moon-ui-components/src/moon/skin.rs", + "signature": "pub fn active(cx: &App) -> Self" + }, + { + "file": "crates/moon-ui-components/src/moon/skin.rs", + "signature": "pub fn facet(kind: MoonFacetKind, tokens: &MoonThemeTokens) -> MoonFacet" + }, + { + "file": "crates/moon-ui-components/src/moon/skin.rs", + "signature": "pub fn facet_glow(color: u32, alpha: f32, blur: f32, tokens: &MoonThemeTokens) -> BoxShadow" + }, + { + "file": "crates/moon-ui-components/src/moon/skin.rs", + "signature": "pub fn facet_tinted(tone: u32, tint: MoonFacetTint, tokens: &MoonThemeTokens) -> MoonFacet" + }, + { + "file": "crates/moon-ui-components/src/moon/skin.rs", + "signature": "pub fn fading_rule_h(color: Hsla) -> impl IntoElement" + }, + { + "file": "crates/moon-ui-components/src/moon/skin.rs", + "signature": "pub fn fading_rule_v(color: Hsla) -> impl IntoElement" + }, + { + "file": "crates/moon-ui-components/src/moon/skin.rs", + "signature": "pub fn is_facet(self) -> bool" + }, + { + "file": "crates/moon-ui-components/src/moon/skin.rs", + "signature": "pub fn moon_ambient_light(color: u32, alpha: f32, tokens: &MoonThemeTokens) -> Div" + }, + { + "file": "crates/moon-ui-components/src/moon/skin.rs", + "signature": "pub fn moon_backdrop(tokens: &MoonThemeTokens) -> Option" + }, + { + "file": "crates/moon-ui-components/src/moon/skin.rs", + "signature": "pub fn paint(self, this: S) -> S" + }, + { + "file": "crates/moon-ui-components/src/moon/skin.rs", + "signature": "pub struct MoonFacet" + }, { "file": "crates/moon-ui-components/src/moon/slider.rs", "signature": "pub fn bounds(mut self, bounds: MoonRect) -> Self" @@ -4353,6 +4413,10 @@ "file": "crates/moon-ui-components/src/moon/tab.rs", "signature": "pub fn badge(mut self, badge: impl Into) -> Self" }, + { + "file": "crates/moon-ui-components/src/moon/tab.rs", + "signature": "pub fn bottom_rule(mut self, bottom_rule: bool) -> Self" + }, { "file": "crates/moon-ui-components/src/moon/tab.rs", "signature": "pub fn bounds(mut self, bounds: MoonRect) -> Self" @@ -4869,6 +4933,10 @@ "file": "crates/moon-ui-components/src/moon/theme.rs", "signature": "pub fn moon_light() -> Self" }, + { + "file": "crates/moon-ui-components/src/moon/theme.rs", + "signature": "pub fn moon_obsidian() -> Self" + }, { "file": "crates/moon-ui-components/src/moon/theme.rs", "signature": "pub fn moon_terminal() -> Self" @@ -5133,6 +5201,10 @@ "file": "crates/moon-ui-components/src/moon/tokens.rs", "signature": "pub const LIGHT: Self = Self" }, + { + "file": "crates/moon-ui-components/src/moon/tokens.rs", + "signature": "pub const OBSIDIAN: Self = Self" + }, { "file": "crates/moon-ui-components/src/moon/tokens.rs", "signature": "pub const TERMINAL: Self = Self" diff --git a/docs/component-mirror-baseline.json b/docs/component-mirror-baseline.json index 120d9eb..e34ea9a 100644 --- a/docs/component-mirror-baseline.json +++ b/docs/component-mirror-baseline.json @@ -62,17 +62,17 @@ "local_paths": [ "button" ], - "local_hash": "070d99ef6e1f79a8", + "local_hash": "3bf7c056993cf169", "local_files": [ { "path": "button/button.rs", - "hash": "9c982d6195216000", - "bytes": 42342 + "hash": "bfd6bdd177a64797", + "bytes": 52705 }, { "path": "button/button/tests.rs", - "hash": "a82b0bdd58043fe7", - "bytes": 9930 + "hash": "363f18c2016d1532", + "bytes": 36536 }, { "path": "button/button_group.rs", @@ -175,12 +175,12 @@ "local_paths": [ "checkbox.rs" ], - "local_hash": "dbf68eda8a74c5d1", + "local_hash": "cc7339e30bb989a7", "local_files": [ { "path": "checkbox.rs", - "hash": "3044316dc40e01cd", - "bytes": 54366 + "hash": "218ded4eca4cf0ee", + "bytes": 56594 } ], "donor_hash": "c665cd46d9f5aa2a", @@ -229,12 +229,12 @@ "local_paths": [ "combobox.rs" ], - "local_hash": "84472eb8d6fba05b", + "local_hash": "db135c6582f7e22b", "local_files": [ { "path": "combobox.rs", - "hash": "7499b13b03302754", - "bytes": 52492 + "hash": "d0b726c2e4e1f3e7", + "bytes": 53249 } ], "donor_hash": "2324438aee108e17", @@ -309,7 +309,7 @@ "local_paths": [ "dialog" ], - "local_hash": "6edf5ce3633f7631", + "local_hash": "d2970489b66ca0ad", "local_files": [ { "path": "dialog/alert_dialog.rs", @@ -328,8 +328,8 @@ }, { "path": "dialog/dialog.rs", - "hash": "a5af807a6065db87", - "bytes": 26761 + "hash": "0b6f641649001d9e", + "bytes": 28321 }, { "path": "dialog/footer.rs", @@ -439,7 +439,7 @@ "local_paths": [ "input" ], - "local_hash": "323d2518270e40af", + "local_hash": "8495c855fab66c51", "local_files": [ { "path": "input/blink_cursor.rs", @@ -513,8 +513,8 @@ }, { "path": "input/input.rs", - "hash": "6e159fafcb8b076a", - "bytes": 22594 + "hash": "4ef3796b29c00462", + "bytes": 29010 }, { "path": "input/lsp/code_actions.rs", @@ -1080,7 +1080,7 @@ "local_paths": [ "progress" ], - "local_hash": "79bc8c926375335a", + "local_hash": "0337dc7e7a61b313", "local_files": [ { "path": "progress/mod.rs", @@ -1089,8 +1089,8 @@ }, { "path": "progress/progress.rs", - "hash": "e576030bca75de6c", - "bytes": 5648 + "hash": "b3e131632fefe07b", + "bytes": 6296 }, { "path": "progress/progress_circle.rs", @@ -1117,6 +1117,7 @@ } ], "donor_changed_files": [ + "progress/progress.rs", "progress/progress_circle.rs" ] }, @@ -1128,7 +1129,7 @@ "local_paths": [ "progress" ], - "local_hash": "79bc8c926375335a", + "local_hash": "0337dc7e7a61b313", "local_files": [ { "path": "progress/mod.rs", @@ -1137,8 +1138,8 @@ }, { "path": "progress/progress.rs", - "hash": "e576030bca75de6c", - "bytes": 5648 + "hash": "b3e131632fefe07b", + "bytes": 6296 }, { "path": "progress/progress_circle.rs", @@ -1165,6 +1166,7 @@ } ], "donor_changed_files": [ + "progress/progress.rs", "progress/progress_circle.rs" ] }, @@ -1226,12 +1228,12 @@ "local_paths": [ "select.rs" ], - "local_hash": "8b763297bb92a070", + "local_hash": "d299e90ae40a663a", "local_files": [ { "path": "select.rs", - "hash": "d79708fd374c195e", - "bytes": 32028 + "hash": "85f756d8ea8aa77c", + "bytes": 32853 } ], "donor_hash": "245acdb36368c661", @@ -1409,7 +1411,7 @@ "local_paths": [ "sidebar" ], - "local_hash": "0eeeac4e89296fa8", + "local_hash": "7b14f73bf58f5f44", "local_files": [ { "path": "sidebar/footer.rs", @@ -1428,18 +1430,18 @@ }, { "path": "sidebar/menu.rs", - "hash": "c7f8c396358965a5", - "bytes": 18454 + "hash": "eaaccc159c37f4ce", + "bytes": 20892 }, { "path": "sidebar/menu/tests.rs", - "hash": "357c42078fe07cec", - "bytes": 13872 + "hash": "431a202b07ee80a5", + "bytes": 15838 }, { "path": "sidebar/mod.rs", - "hash": "f0337ffc40d90ad4", - "bytes": 25166 + "hash": "07be9fff20623e46", + "bytes": 26340 } ], "donor_hash": "1b093a2a6db1a9b6", @@ -1475,7 +1477,8 @@ "sidebar/group.rs", "sidebar/header.rs", "sidebar/menu.rs", - "sidebar/menu/tests.rs" + "sidebar/menu/tests.rs", + "sidebar/mod.rs" ] }, { @@ -1486,12 +1489,12 @@ "local_paths": [ "slider.rs" ], - "local_hash": "a5ee3e262bc1a62f", + "local_hash": "a5bce9927ab28a52", "local_files": [ { "path": "slider.rs", - "hash": "49af155ee26c56da", - "bytes": 31788 + "hash": "f643ac8cbaba6ef2", + "bytes": 37106 } ], "donor_hash": "845f6d32d82ffd16", @@ -1514,7 +1517,7 @@ "local_paths": [ "input" ], - "local_hash": "323d2518270e40af", + "local_hash": "8495c855fab66c51", "local_files": [ { "path": "input/blink_cursor.rs", @@ -1588,8 +1591,8 @@ }, { "path": "input/input.rs", - "hash": "6e159fafcb8b076a", - "bytes": 22594 + "hash": "4ef3796b29c00462", + "bytes": 29010 }, { "path": "input/lsp/code_actions.rs", diff --git a/xtask/src/component_audit/contracts.rs b/xtask/src/component_audit/contracts.rs index 592e1bc..09d6692 100644 --- a/xtask/src/component_audit/contracts.rs +++ b/xtask/src/component_audit/contracts.rs @@ -564,7 +564,7 @@ pub(super) fn contract_checks(root: &Path) -> Result> { ContractVerifier::VisualGolden, gallery.contains("--snapshot-dir") && gallery.contains("--theme") - && gallery.contains("theme_mode_name") + && gallery.contains("snapshot_name") && snapshot_tool.contains("$Themes = @(\"Dark\", \"Light\")") && gallery.contains("snapshot_window_image(") && gallery.contains("clear_snapshot_dir")