wip
This commit is contained in:
@@ -0,0 +1,445 @@
|
||||
use gpui::{
|
||||
AbsoluteLength, AnyElement, App, Bounds, Div, Hsla, ImageSource, InteractiveElement,
|
||||
Interactivity, IntoElement, ObjectFit, ParentElement, PathBuilder, Pixels, Point, RenderOnce,
|
||||
SharedString, StyleRefinement, Styled, StyledImage, Window, canvas, div, img, point, px,
|
||||
};
|
||||
use gpui_kit::base::StyledExt as _;
|
||||
use gpui_kit::component::{ActiveTheme as _, Selectable, Sizable, Size};
|
||||
|
||||
/// Number of rows and columns in the generated pixel grid.
|
||||
const PIXEL_GRID: usize = 8;
|
||||
/// Empty cells kept between the pattern and the avatar edge.
|
||||
const MARGIN: usize = 1;
|
||||
/// Probability that a cell in the left half of the pattern area is filled.
|
||||
const FILL_PROBABILITY: f32 = 0.42;
|
||||
/// Probability that a filled cell uses the accent shade instead of the main color.
|
||||
const ACCENT_PROBABILITY: f32 = 0.25;
|
||||
/// Minimum number of filled left-half cells, so a pattern never reads as empty.
|
||||
const MIN_FILLED: usize = 5;
|
||||
/// Fallback seed for an avatar that has neither a picture nor a seed of its own.
|
||||
const FALLBACK_SEED: &str = "coop";
|
||||
/// Number of segments used to approximate the avatar circle.
|
||||
const CIRCLE_SEGMENTS: usize = 32;
|
||||
|
||||
/// Returns the size of the avatar based on the given [`Size].
|
||||
fn avatar_size(size: Size) -> AbsoluteLength {
|
||||
match size {
|
||||
Size::Large => px(64.).into(),
|
||||
Size::Medium => px(32.).into(),
|
||||
Size::Small => px(24.).into(),
|
||||
Size::XSmall => px(18.).into(),
|
||||
Size::Size(size) => size.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A deterministic, offline pixel-art avatar derived from a seed.
|
||||
#[derive(IntoElement)]
|
||||
struct PixelAvatar {
|
||||
seed: u64,
|
||||
size: Size,
|
||||
style: StyleRefinement,
|
||||
}
|
||||
|
||||
impl PixelAvatar {
|
||||
/// Creates a pixel avatar from `seed.
|
||||
fn new(seed: impl AsRef<str>) -> Self {
|
||||
Self {
|
||||
seed: fnv1a(seed.as_ref().as_bytes()),
|
||||
size: Size::Medium,
|
||||
style: StyleRefinement::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Sizable for PixelAvatar {
|
||||
fn with_size(mut self, size: impl Into<Size>) -> Self {
|
||||
self.size = size.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for PixelAvatar {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for PixelAvatar {
|
||||
fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement {
|
||||
let side = avatar_size(self.size).to_pixels(window.rem_size());
|
||||
let seed = self.seed;
|
||||
|
||||
div()
|
||||
.refine_style(&self.style)
|
||||
.size(side)
|
||||
.flex_shrink_0()
|
||||
.child(canvas(
|
||||
move |_bounds, _window, _cx| seed,
|
||||
move |bounds, seed, window, cx| {
|
||||
let theme = cx.theme();
|
||||
let main = Hsla {
|
||||
h: seed as f32 / u64::MAX as f32,
|
||||
s: 0.6,
|
||||
l: if theme.is_dark() { 0.6 } else { 0.45 },
|
||||
a: 1.,
|
||||
};
|
||||
let shade = if theme.is_dark() {
|
||||
Hsla {
|
||||
l: (main.l * 1.6).min(0.95),
|
||||
..main
|
||||
}
|
||||
} else {
|
||||
Hsla {
|
||||
l: (main.l * 0.45).max(0.18),
|
||||
..main
|
||||
}
|
||||
};
|
||||
|
||||
let circle = circle_polygon(bounds.center(), bounds.size.width.as_f32() / 2.);
|
||||
paint_polygons(window, std::iter::once(&circle), main.opacity(0.16));
|
||||
|
||||
let pattern = pixel_pattern(seed);
|
||||
let mut cells = Vec::new();
|
||||
|
||||
for (value, color) in [(1u8, main), (2u8, shade)] {
|
||||
cells.clear();
|
||||
|
||||
for row in 0..PIXEL_GRID {
|
||||
for col in 0..PIXEL_GRID {
|
||||
if pattern[row * PIXEL_GRID + col] != value {
|
||||
continue;
|
||||
}
|
||||
|
||||
let cell = clip_polygon(&cell_polygon(&bounds, row, col), &circle);
|
||||
if cell.len() >= 3 {
|
||||
cells.push(cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
paint_polygons(window, cells.iter(), color);
|
||||
}
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the mirrored fill pattern for `seed.
|
||||
fn pixel_pattern(seed: u64) -> [u8; PIXEL_GRID * PIXEL_GRID] {
|
||||
let mut rng = PixelRng::new(seed);
|
||||
let mut pattern = [0u8; PIXEL_GRID * PIXEL_GRID];
|
||||
let mut filled = 0usize;
|
||||
|
||||
let art_rows = PIXEL_GRID - 2 * MARGIN;
|
||||
let art_columns = PIXEL_GRID / 2 - MARGIN;
|
||||
|
||||
for row in MARGIN..PIXEL_GRID - MARGIN {
|
||||
for col in MARGIN..PIXEL_GRID / 2 {
|
||||
if rng.chance(FILL_PROBABILITY) {
|
||||
let accent = rng.chance(ACCENT_PROBABILITY);
|
||||
set_cell(&mut pattern, row, col, if accent { 2 } else { 1 });
|
||||
filled += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if filled < MIN_FILLED {
|
||||
let total = art_rows * art_columns;
|
||||
let start = (rng.next() % total as u64) as usize;
|
||||
|
||||
for offset in 0..total {
|
||||
if filled >= MIN_FILLED {
|
||||
break;
|
||||
}
|
||||
|
||||
let ix = (start + offset) % total;
|
||||
let row = MARGIN + ix / art_columns;
|
||||
let col = MARGIN + ix % art_columns;
|
||||
|
||||
if pattern[row * PIXEL_GRID + col] == 0 {
|
||||
set_cell(&mut pattern, row, col, 1);
|
||||
filled += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pattern
|
||||
}
|
||||
|
||||
/// Paints `polygons as a single anti-aliased filled path in `color.
|
||||
fn paint_polygons<'a>(
|
||||
window: &mut Window,
|
||||
polygons: impl IntoIterator<Item = &'a Vec<Point<Pixels>>>,
|
||||
color: Hsla,
|
||||
) {
|
||||
let mut builder = PathBuilder::fill();
|
||||
let mut painted = false;
|
||||
|
||||
for polygon in polygons {
|
||||
if polygon.len() >= 3 {
|
||||
builder.add_polygon(polygon, true);
|
||||
painted = true;
|
||||
}
|
||||
}
|
||||
|
||||
if painted && let Ok(path) = builder.build() {
|
||||
window.paint_path(path, color);
|
||||
}
|
||||
}
|
||||
|
||||
/// Approximates the circle of `radius around `center as a convex polygon wound left-interior.
|
||||
fn circle_polygon(center: Point<Pixels>, radius: f32) -> Vec<Point<Pixels>> {
|
||||
let center_x = center.x.as_f32();
|
||||
let center_y = center.y.as_f32();
|
||||
|
||||
(0..CIRCLE_SEGMENTS)
|
||||
.map(|index| {
|
||||
let angle = std::f32::consts::TAU * index as f32 / CIRCLE_SEGMENTS as f32;
|
||||
point(
|
||||
px(center_x + radius * angle.cos()),
|
||||
px(center_y + radius * angle.sin()),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The four corners of cell `(row, col) of the grid laid out in `bounds.
|
||||
fn cell_polygon(bounds: &Bounds<Pixels>, row: usize, col: usize) -> [Point<Pixels>; 4] {
|
||||
let cell = bounds.size.width.as_f32() / PIXEL_GRID as f32;
|
||||
let left = bounds.origin.x.as_f32() + col as f32 * cell;
|
||||
let top = bounds.origin.y.as_f32() + row as f32 * cell;
|
||||
|
||||
[
|
||||
point(px(left), px(top)),
|
||||
point(px(left + cell), px(top)),
|
||||
point(px(left + cell), px(top + cell)),
|
||||
point(px(left), px(top + cell)),
|
||||
]
|
||||
}
|
||||
|
||||
/// Clips `subject to the convex `clip polygon, keeping the part inside it.
|
||||
fn clip_polygon(subject: &[Point<Pixels>], clip: &[Point<Pixels>]) -> Vec<Point<Pixels>> {
|
||||
let mut current = subject.to_vec();
|
||||
let mut next = Vec::with_capacity(subject.len() + 4);
|
||||
|
||||
for (&start, &end) in clip.iter().zip(clip.iter().cycle().skip(1)) {
|
||||
if current.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
next.clear();
|
||||
let mut previous = match current.last() {
|
||||
Some(&vertex) => vertex,
|
||||
None => break,
|
||||
};
|
||||
|
||||
for &vertex in current.iter() {
|
||||
let previous_inside = is_inside(start, end, previous);
|
||||
let vertex_inside = is_inside(start, end, vertex);
|
||||
|
||||
if vertex_inside {
|
||||
if !previous_inside
|
||||
&& let Some(crossing) = line_intersection(start, end, previous, vertex)
|
||||
{
|
||||
next.push(crossing);
|
||||
}
|
||||
next.push(vertex);
|
||||
} else if previous_inside
|
||||
&& let Some(crossing) = line_intersection(start, end, previous, vertex)
|
||||
{
|
||||
next.push(crossing);
|
||||
}
|
||||
|
||||
previous = vertex;
|
||||
}
|
||||
|
||||
std::mem::swap(&mut current, &mut next);
|
||||
}
|
||||
|
||||
current
|
||||
}
|
||||
|
||||
/// Whether `vertex lies on the interior side of the directed edge `start -> end.
|
||||
fn is_inside(start: Point<Pixels>, end: Point<Pixels>, vertex: Point<Pixels>) -> bool {
|
||||
let start_x = start.x.as_f32();
|
||||
let start_y = start.y.as_f32();
|
||||
let edge_x = end.x.as_f32() - start_x;
|
||||
let edge_y = end.y.as_f32() - start_y;
|
||||
let to_vertex_x = vertex.x.as_f32() - start_x;
|
||||
let to_vertex_y = vertex.y.as_f32() - start_y;
|
||||
|
||||
edge_x * to_vertex_y - edge_y * to_vertex_x >= 0.
|
||||
}
|
||||
|
||||
/// The intersection of segment `from -> to with the infinite line `start -> end.
|
||||
fn line_intersection(
|
||||
start: Point<Pixels>,
|
||||
end: Point<Pixels>,
|
||||
from: Point<Pixels>,
|
||||
to: Point<Pixels>,
|
||||
) -> Option<Point<Pixels>> {
|
||||
let start_x = start.x.as_f32();
|
||||
let start_y = start.y.as_f32();
|
||||
let edge_x = end.x.as_f32() - start_x;
|
||||
let edge_y = end.y.as_f32() - start_y;
|
||||
let from_x = from.x.as_f32();
|
||||
let from_y = from.y.as_f32();
|
||||
let segment_x = to.x.as_f32() - from_x;
|
||||
let segment_y = to.y.as_f32() - from_y;
|
||||
let denominator = edge_x * segment_y - edge_y * segment_x;
|
||||
|
||||
if denominator.abs() < f32::EPSILON {
|
||||
return None;
|
||||
}
|
||||
|
||||
let offset_x = from_x - start_x;
|
||||
let offset_y = from_y - start_y;
|
||||
let t = (edge_y * offset_x - edge_x * offset_y) / denominator;
|
||||
|
||||
Some(point(
|
||||
px(from_x + segment_x * t),
|
||||
px(from_y + segment_y * t),
|
||||
))
|
||||
}
|
||||
|
||||
/// Fills `cell (row, col) and its horizontal mirror.
|
||||
fn set_cell(pattern: &mut [u8; PIXEL_GRID * PIXEL_GRID], row: usize, col: usize, value: u8) {
|
||||
pattern[row * PIXEL_GRID + col] = value;
|
||||
pattern[row * PIXEL_GRID + (PIXEL_GRID - 1 - col)] = value;
|
||||
}
|
||||
|
||||
/// FNV-1a 64-bit hash, stable across platforms and runs.
|
||||
fn fnv1a(bytes: &[u8]) -> u64 {
|
||||
let mut hash = 0xcbf2_9ce4_8422_2325u64;
|
||||
for &byte in bytes {
|
||||
hash ^= byte as u64;
|
||||
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
}
|
||||
hash
|
||||
}
|
||||
|
||||
/// Tiny xorshift64* PRNG for deriving the pattern from the seed.
|
||||
struct PixelRng(u64);
|
||||
|
||||
impl PixelRng {
|
||||
fn new(seed: u64) -> Self {
|
||||
Self(seed.max(1))
|
||||
}
|
||||
|
||||
fn next(&mut self) -> u64 {
|
||||
let mut x = self.0;
|
||||
x ^= x >> 12;
|
||||
x ^= x << 25;
|
||||
x ^= x >> 27;
|
||||
self.0 = x;
|
||||
x.wrapping_mul(0x2545_f491_4f6c_dd1d)
|
||||
}
|
||||
|
||||
fn chance(&mut self, probability: f32) -> bool {
|
||||
self.next() as f32 / (u64::MAX as f32) < probability
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the generated pixel avatar shown in place of a missing picture.
|
||||
fn generated_avatar(seed: Option<&str>, size: Pixels) -> AnyElement {
|
||||
PixelAvatar::new(seed.unwrap_or(FALLBACK_SEED))
|
||||
.with_size(size)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// An element that renders a user avatar with customizable appearance options.
|
||||
#[derive(IntoElement)]
|
||||
pub struct Avatar {
|
||||
base: Div,
|
||||
picture: Option<ImageSource>,
|
||||
seed: Option<String>,
|
||||
style: StyleRefinement,
|
||||
size: Size,
|
||||
selected: bool,
|
||||
}
|
||||
|
||||
impl Avatar {
|
||||
/// Creates an avatar for an entity whose profile picture may be missing.
|
||||
pub fn new(picture: Option<SharedString>) -> Self {
|
||||
Self::from_source(picture)
|
||||
}
|
||||
|
||||
/// Creates an avatar from an already-resolved source, which may be missing.
|
||||
pub fn from_source(picture: Option<impl Into<ImageSource>>) -> Self {
|
||||
Self::from_picture(picture.map(Into::into))
|
||||
}
|
||||
|
||||
fn from_picture(picture: Option<ImageSource>) -> Self {
|
||||
Avatar {
|
||||
base: div(),
|
||||
picture,
|
||||
seed: None,
|
||||
style: StyleRefinement::default(),
|
||||
size: Size::Medium,
|
||||
selected: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the seed for the generated pixel avatar.
|
||||
pub fn seed(mut self, seed: impl Into<String>) -> Self {
|
||||
self.seed = Some(seed.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Sizable for Avatar {
|
||||
fn with_size(mut self, size: impl Into<Size>) -> Self {
|
||||
self.size = size.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Styled for Avatar {
|
||||
fn style(&mut self) -> &mut StyleRefinement {
|
||||
&mut self.style
|
||||
}
|
||||
}
|
||||
|
||||
impl Selectable for Avatar {
|
||||
fn is_selected(&self) -> bool {
|
||||
self.selected
|
||||
}
|
||||
|
||||
fn selected(mut self, selected: bool) -> Self {
|
||||
self.selected = selected;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl InteractiveElement for Avatar {
|
||||
fn interactivity(&mut self) -> &mut Interactivity {
|
||||
self.base.interactivity()
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for Avatar {
|
||||
fn render(self, window: &mut Window, _cx: &mut App) -> impl IntoElement {
|
||||
let image_size = avatar_size(self.size).to_pixels(window.rem_size());
|
||||
|
||||
let content = match self.picture {
|
||||
Some(picture) => {
|
||||
let seed = self.seed;
|
||||
|
||||
img(picture)
|
||||
.size(image_size)
|
||||
.rounded_full()
|
||||
.object_fit(ObjectFit::Cover)
|
||||
.with_fallback(move || generated_avatar(seed.as_deref(), image_size))
|
||||
.into_any_element()
|
||||
}
|
||||
None => generated_avatar(self.seed.as_deref(), image_size),
|
||||
};
|
||||
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.size(image_size)
|
||||
.rounded_full()
|
||||
.overflow_hidden()
|
||||
.child(content)
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
//! Layout constants, ported from bezel. Numbers drive layout, colors are
|
||||
//! paint: these live as plain numbers and never depend on which color is
|
||||
//! painted.
|
||||
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
use gpui::{Pixels, px};
|
||||
|
||||
/// The base radius behind every radius accessor, as raw `f32` bits.
|
||||
static BASE: AtomicU32 = AtomicU32::new(BASE_RADIUS.to_bits());
|
||||
|
||||
/// The gap between siblings. Measured on macOS 26: `NSStackView().spacing`,
|
||||
/// visual format's `-`, and `constraint(equalToSystemSpacingAfter:multiplier: 1)`
|
||||
/// all report 8.
|
||||
pub const SPACE: f32 = 8.0;
|
||||
|
||||
/// The margin from content to its container's edge. Same measurement,
|
||||
/// visual format's `|-`.
|
||||
pub const CONTENT_MARGIN: f32 = 20.0;
|
||||
|
||||
/// From a column's edge to the glyph of the control at the end of a row,
|
||||
/// measured to the glyph rather than the button. Mirrored at the trailing end.
|
||||
pub const EDGE: f32 = 12.0;
|
||||
|
||||
/// Button, text field and select-trigger height. Measured:
|
||||
/// `NSButton`, `NSTextField` and `NSPopUpButton` all report 24 at
|
||||
/// `.regular` — the body line box with 4 above and below.
|
||||
pub const BUTTON_HEIGHT: f32 = 24.0;
|
||||
|
||||
/// The same controls at `.small`.
|
||||
pub const CONTROL_HEIGHT_SMALL: f32 = 20.0;
|
||||
|
||||
/// Every radius below is a ratio of this, so a brand radius moves the whole
|
||||
/// set together and keeps the concentric relationships intact.
|
||||
pub const BASE_RADIUS: f32 = 8.0;
|
||||
|
||||
/// Message bubble corner radius (2x).
|
||||
pub fn bubble_radius() -> f32 {
|
||||
radius(2.0)
|
||||
}
|
||||
|
||||
/// Floating-surface corner radius — popovers, menus, dialogs (1.5x).
|
||||
pub fn surface_radius() -> f32 {
|
||||
radius(1.5)
|
||||
}
|
||||
|
||||
/// Panel / card corner radius (1.25x).
|
||||
pub fn panel_radius() -> f32 {
|
||||
radius(1.25)
|
||||
}
|
||||
|
||||
/// Button, text field and select-trigger radius (1x).
|
||||
pub fn button_radius() -> f32 {
|
||||
radius(1.0)
|
||||
}
|
||||
|
||||
/// Small control radius (chips, tags, steppers) — a size down from
|
||||
/// [`button_radius`], for things that sit inside a control rather than being one
|
||||
/// (0.75x).
|
||||
pub fn control_radius() -> f32 {
|
||||
radius(0.75)
|
||||
}
|
||||
|
||||
/// A corner as a multiple of the branded base radius.
|
||||
///
|
||||
/// Read from a process-wide mirror rather than the theme global for the reason
|
||||
/// [`crate::paint::current_appearance`] is: the element builders that round a
|
||||
/// corner are free functions with no `cx` in scope, and a radius is one number
|
||||
/// for the whole app.
|
||||
fn radius(ratio: f32) -> f32 {
|
||||
f32::from_bits(BASE.load(Ordering::Relaxed)) * ratio
|
||||
}
|
||||
|
||||
/// The concentric child of a surface: a row inset by `inset` inside a
|
||||
/// container of radius `outer` keeps its corners parallel to the container's,
|
||||
/// rather than looking pasted onto it.
|
||||
///
|
||||
/// This is SwiftUI's `ContainerRelativeShape` rule done as arithmetic. gpui
|
||||
/// has no container shape to inherit at paint time, so the relationship is
|
||||
/// stated where the child is *defined* instead of resolved at runtime.
|
||||
pub const fn inset_radius(outer: f32, inset: f32) -> f32 {
|
||||
if outer > inset { outer - inset } else { 0.0 }
|
||||
}
|
||||
|
||||
/// Window border radius for platforms with client-side decorations.
|
||||
pub const CLIENT_SIDE_DECORATION_ROUNDING: Pixels = px(10.0);
|
||||
|
||||
/// Window shadow size for platforms with client-side decorations.
|
||||
pub const CLIENT_SIDE_DECORATION_SHADOW: Pixels = px(10.0);
|
||||
|
||||
/// Window border size for platforms with client-side decorations.
|
||||
pub const CLIENT_SIDE_DECORATION_BORDER: Pixels = px(1.0);
|
||||
|
||||
/// Space left of the sidebar header for the macOS traffic lights.
|
||||
pub const TRAFFIC_LIGHT_PADDING: f32 = 80.;
|
||||
|
||||
/// Workspace tabbar height.
|
||||
pub const TABBAR_HEIGHT: Pixels = px(36.0);
|
||||
|
||||
/// Default sidebar width.
|
||||
pub const SIDEBAR_WIDTH: Pixels = px(240.);
|
||||
+2
-12
@@ -1,15 +1,5 @@
|
||||
pub use layout::{
|
||||
BASE_RADIUS, BUTTON_HEIGHT, CLIENT_SIDE_DECORATION_BORDER, CLIENT_SIDE_DECORATION_ROUNDING,
|
||||
CLIENT_SIDE_DECORATION_SHADOW, CONTENT_MARGIN, CONTROL_HEIGHT_SMALL, EDGE, SIDEBAR_WIDTH,
|
||||
SPACE, TABBAR_HEIGHT, TRAFFIC_LIGHT_PADDING, bubble_radius, button_radius, control_radius,
|
||||
inset_radius, panel_radius, surface_radius,
|
||||
};
|
||||
pub use styled::{background, divider};
|
||||
pub use typography::{DEFAULT_BASE_TEXT_SIZE, Metrics, TextStyle, Typeset, base_text_size};
|
||||
pub use avatar::Avatar;
|
||||
pub use window_drag::title_bar_drag_handlers;
|
||||
|
||||
pub mod layout;
|
||||
pub mod motion;
|
||||
pub mod styled;
|
||||
pub mod typography;
|
||||
pub mod avatar;
|
||||
pub mod window_drag;
|
||||
|
||||
@@ -1,668 +0,0 @@
|
||||
//! The motion kit, ported from bezel (`crates/motion/src/lib.rs` and its
|
||||
//! `app.rs`, MIT License, Copyright (c) 2026 bezel contributors). TranslateY
|
||||
//! rides a relative `top` inset, which taffy applies after layout, so
|
||||
//! siblings never move.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use gpui::{
|
||||
Animation, AnimationElement, AnimationExt as _, App, Context, ElementId, EntityId, Global,
|
||||
Hsla, IntoElement, Rgba, SharedString, Styled, Window, px,
|
||||
};
|
||||
use web_time::Instant;
|
||||
|
||||
/// Redraw rate for anything driven by [`pulse_delta`].
|
||||
const PULSE_FPS: f32 = 30.0;
|
||||
|
||||
/// How long a view stays on the tick list after its last pulse paint.
|
||||
const PULSE_LEASE: Duration = Duration::from_millis(300);
|
||||
|
||||
/// Redraw rate for hover fades; [`HOVER_FADE`] paints in nine steps at it.
|
||||
const HOVER_FPS: f32 = 60.0;
|
||||
|
||||
/// Floor on how long the clock sleeps between wake-ups.
|
||||
const MIN_SLEEP: Duration = Duration::from_millis(1);
|
||||
|
||||
/// One view's claim on the clock.
|
||||
struct Lease {
|
||||
/// The fastest rate anything on this view has claimed.
|
||||
period: Duration,
|
||||
/// When this view is next owed a redraw.
|
||||
due: Instant,
|
||||
/// A notify is out and the render it provoked has not renewed yet.
|
||||
in_flight: bool,
|
||||
/// When the claim lapses if nothing renews it.
|
||||
until: Instant,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct PulseClock {
|
||||
epoch: Option<Instant>,
|
||||
leases: HashMap<EntityId, Lease>,
|
||||
running: bool,
|
||||
}
|
||||
|
||||
impl Global for PulseClock {}
|
||||
|
||||
impl PulseClock {
|
||||
fn next_wake(&self) -> Option<Instant> {
|
||||
self.leases
|
||||
.values()
|
||||
.map(|lease| lease.due.min(lease.until))
|
||||
.min()
|
||||
}
|
||||
}
|
||||
|
||||
/// A component's line back to the view that paints it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct Painter(EntityId);
|
||||
|
||||
impl Painter {
|
||||
/// The view this render belongs to; take it once where the state is built.
|
||||
pub fn of<T: 'static>(cx: &Context<T>) -> Self {
|
||||
Self(cx.entity_id())
|
||||
}
|
||||
|
||||
/// Redraw this view once.
|
||||
pub fn notify(self, cx: &mut App) {
|
||||
cx.notify(self.0);
|
||||
}
|
||||
|
||||
/// Claim `fps` redraws a second; renew from `render` so an unmounted
|
||||
/// element drops off.
|
||||
pub fn lease(self, fps: f32, until: Duration, cx: &mut App) {
|
||||
lease(self.0, fps, until, cx);
|
||||
}
|
||||
|
||||
/// Whether the clock is what asked for the render running now.
|
||||
pub fn woken(self, cx: &App) -> bool {
|
||||
cx.try_global::<PulseClock>()
|
||||
.and_then(|clock| clock.leases.get(&self.0))
|
||||
.is_some_and(|lease| lease.in_flight)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<EntityId> for Painter {
|
||||
fn from(entity_id: EntityId) -> Self {
|
||||
Self(entity_id)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Painter> for EntityId {
|
||||
fn from(painter: Painter) -> Self {
|
||||
painter.0
|
||||
}
|
||||
}
|
||||
|
||||
fn lease(view: EntityId, fps: f32, until: Duration, cx: &mut App) {
|
||||
if cx.pause_when_inactive() && cx.active_window().is_none() {
|
||||
return;
|
||||
}
|
||||
let now = cx.background_executor().now();
|
||||
let period = Duration::from_secs_f32(1.0 / fps.clamp(1.0, 240.0));
|
||||
let clock = cx.default_global::<PulseClock>();
|
||||
clock
|
||||
.leases
|
||||
.entry(view)
|
||||
.and_modify(|lease| {
|
||||
lease.period = lease.period.min(period);
|
||||
lease.due = lease.due.min(now + period);
|
||||
lease.in_flight = false;
|
||||
lease.until = lease.until.max(now + until);
|
||||
})
|
||||
.or_insert(Lease {
|
||||
period,
|
||||
due: now + period,
|
||||
in_flight: false,
|
||||
until: now + until,
|
||||
});
|
||||
if clock.running {
|
||||
return;
|
||||
}
|
||||
clock.running = true;
|
||||
cx.spawn(async move |cx| {
|
||||
loop {
|
||||
let sleep = cx.update(|cx| {
|
||||
let now = cx.background_executor().now();
|
||||
let clock = cx.default_global::<PulseClock>();
|
||||
clock
|
||||
.next_wake()
|
||||
.map(|wake| wake.saturating_duration_since(now).max(MIN_SLEEP))
|
||||
});
|
||||
let Some(sleep) = sleep else { break };
|
||||
cx.background_executor().timer(sleep).await;
|
||||
let parked = cx.update(|cx| {
|
||||
let now = cx.background_executor().now();
|
||||
tick_hover_fades();
|
||||
let clock = cx.default_global::<PulseClock>();
|
||||
clock.leases.retain(|_, lease| lease.until > now);
|
||||
if clock.leases.is_empty() {
|
||||
clock.running = false;
|
||||
return true;
|
||||
}
|
||||
let mut owed = Vec::new();
|
||||
for (view, lease) in clock.leases.iter_mut() {
|
||||
if lease.due > now {
|
||||
continue;
|
||||
}
|
||||
lease.due += lease.period;
|
||||
if lease.due <= now {
|
||||
lease.due = now + lease.period;
|
||||
}
|
||||
lease.in_flight = true;
|
||||
owed.push(*view);
|
||||
}
|
||||
for view in owed {
|
||||
cx.notify(view);
|
||||
}
|
||||
false
|
||||
});
|
||||
if parked {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Current phase `[0,1)` of a repeating spec; reduced motion returns a
|
||||
/// static 0 and schedules nothing.
|
||||
pub fn pulse_delta(spec: &MotionSpec, painter: Painter, cx: &mut App) -> f32 {
|
||||
if cx.reduced_motion() {
|
||||
return 0.0;
|
||||
}
|
||||
painter.lease(PULSE_FPS, PULSE_LEASE, cx);
|
||||
let now = cx.background_executor().now();
|
||||
let clock = cx.default_global::<PulseClock>();
|
||||
let epoch = *clock.epoch.get_or_insert(now);
|
||||
let period = spec.total().as_secs_f32();
|
||||
((now - epoch).as_secs_f32() / period).fract()
|
||||
}
|
||||
|
||||
/// A CSS `cubic-bezier(x1, y1, x2, y2)` timing function, evaluated exactly.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct CubicBezier {
|
||||
pub x1: f32,
|
||||
pub y1: f32,
|
||||
pub x2: f32,
|
||||
pub y2: f32,
|
||||
}
|
||||
|
||||
impl CubicBezier {
|
||||
pub const fn new(x1: f32, y1: f32, x2: f32, y2: f32) -> Self {
|
||||
Self { x1, y1, x2, y2 }
|
||||
}
|
||||
|
||||
fn coefficients(a: f32, b: f32) -> (f32, f32, f32) {
|
||||
let c = 3.0 * a;
|
||||
let bb = 3.0 * (b - a) - c;
|
||||
let aa = 1.0 - c - bb;
|
||||
(aa, bb, c)
|
||||
}
|
||||
|
||||
fn sample_x(&self, t: f32) -> f32 {
|
||||
let (a, b, c) = Self::coefficients(self.x1, self.x2);
|
||||
((a * t + b) * t + c) * t
|
||||
}
|
||||
|
||||
fn sample_y(&self, t: f32) -> f32 {
|
||||
let (a, b, c) = Self::coefficients(self.y1, self.y2);
|
||||
((a * t + b) * t + c) * t
|
||||
}
|
||||
|
||||
fn sample_x_derivative(&self, t: f32) -> f32 {
|
||||
let (a, b, c) = Self::coefficients(self.x1, self.x2);
|
||||
(3.0 * a * t + 2.0 * b) * t + c
|
||||
}
|
||||
|
||||
fn solve_t_for_x(&self, x: f32) -> f32 {
|
||||
let mut t = x;
|
||||
for _ in 0..8 {
|
||||
let err = self.sample_x(t) - x;
|
||||
if err.abs() < 1e-6 {
|
||||
return t;
|
||||
}
|
||||
let d = self.sample_x_derivative(t);
|
||||
if d.abs() < 1e-6 {
|
||||
break;
|
||||
}
|
||||
t -= err / d;
|
||||
}
|
||||
let (mut lo, mut hi) = (0.0_f32, 1.0_f32);
|
||||
for _ in 0..32 {
|
||||
let mid = (lo + hi) / 2.0;
|
||||
if self.sample_x(mid) < x {
|
||||
lo = mid
|
||||
} else {
|
||||
hi = mid
|
||||
}
|
||||
}
|
||||
(lo + hi) / 2.0
|
||||
}
|
||||
|
||||
/// Eased output for input progress `x ∈ [0,1]`, clamped hard — gpui's
|
||||
/// animation element asserts its delta is in `[0,1]` and aborts.
|
||||
pub fn eval(&self, x: f32) -> f32 {
|
||||
if x <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
if x >= 1.0 {
|
||||
return 1.0;
|
||||
}
|
||||
self.sample_y(self.solve_t_for_x(x)).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// This curve as a gpui easing closure.
|
||||
pub fn easing(self) -> impl Fn(f32) -> f32 + 'static {
|
||||
move |x| self.eval(x)
|
||||
}
|
||||
}
|
||||
|
||||
/// The signature entrance curve — CSS `cubic-bezier(0.16, 1, 0.3, 1)`.
|
||||
pub const EASE_OUT_EXPO: CubicBezier = CubicBezier::new(0.16, 1.0, 0.3, 1.0);
|
||||
/// CSS `ease-out` — width/height transitions.
|
||||
pub const EASE_OUT: CubicBezier = CubicBezier::new(0.0, 0.0, 0.58, 1.0);
|
||||
/// CSS `ease` — quick fades, menu/dialog pops.
|
||||
pub const EASE: CubicBezier = CubicBezier::new(0.25, 0.1, 0.25, 1.0);
|
||||
/// Sidebar resort glide — CSS `cubic-bezier(0.22, 1, 0.36, 1)`.
|
||||
pub const EASE_RESORT: CubicBezier = CubicBezier::new(0.22, 1.0, 0.36, 1.0);
|
||||
/// CSS `ease-in-out`.
|
||||
pub const EASE_IN_OUT: CubicBezier = CubicBezier::new(0.42, 0.0, 0.58, 1.0);
|
||||
/// Tailwind's default transition curve — CSS `cubic-bezier(0.4, 0, 0.2, 1)`.
|
||||
pub const EASE_TAILWIND: CubicBezier = CubicBezier::new(0.4, 0.0, 0.2, 1.0);
|
||||
|
||||
/// One catalog entry: duration + optional delay + curve; gpui has no native
|
||||
/// delay, so `delay_ms` holds progress at 0 until it elapses.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct MotionSpec {
|
||||
pub duration_ms: u64,
|
||||
pub delay_ms: u64,
|
||||
pub curve: CubicBezier,
|
||||
}
|
||||
|
||||
impl MotionSpec {
|
||||
pub const fn new(duration_ms: u64, curve: CubicBezier) -> Self {
|
||||
Self {
|
||||
duration_ms,
|
||||
delay_ms: 0,
|
||||
curve,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn with_delay(mut self, delay_ms: u64) -> Self {
|
||||
self.delay_ms = delay_ms;
|
||||
self
|
||||
}
|
||||
|
||||
/// Wall-clock span of the whole timeline (delay + duration).
|
||||
pub fn total(&self) -> Duration {
|
||||
Duration::from_millis(self.delay_ms + self.duration_ms)
|
||||
}
|
||||
|
||||
/// Eased progress (0..1) for a raw timeline delta (0..1 across `total`).
|
||||
pub fn progress(&self, raw_delta: f32) -> f32 {
|
||||
let total = (self.delay_ms + self.duration_ms) as f32;
|
||||
if total <= 0.0 || self.duration_ms == 0 {
|
||||
return 1.0;
|
||||
}
|
||||
let t =
|
||||
(raw_delta.clamp(0.0, 1.0) * total - self.delay_ms as f32) / self.duration_ms as f32;
|
||||
self.curve.eval(t.clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
/// A oneshot gpui [`Animation`] for this spec.
|
||||
pub fn animation(&self) -> Animation {
|
||||
let spec = *self;
|
||||
Animation::new(spec.total().mul_f32(speed_scale())).with_easing(move |d| spec.progress(d))
|
||||
}
|
||||
}
|
||||
|
||||
/// Entrances: 0.5s expo-out fade + 4px rise.
|
||||
pub const FADE_IN: MotionSpec = MotionSpec::new(500, EASE_OUT_EXPO);
|
||||
/// Quick fade: 0.15s.
|
||||
pub const FADE_QUICK: MotionSpec = MotionSpec::new(150, EASE);
|
||||
/// Popover-in: 0.14s.
|
||||
pub const MENU_IN: MotionSpec = MotionSpec::new(140, EASE);
|
||||
/// Popover-out: 0.1s — quicker than the entrance.
|
||||
pub const MENU_OUT: MotionSpec = MotionSpec::new(100, EASE);
|
||||
/// Dialog-in: 0.18s.
|
||||
pub const DIALOG_IN: MotionSpec = MotionSpec::new(180, EASE);
|
||||
/// Boot-splash exit: 0.5s fade + 6px lift after a 0.15s hold.
|
||||
pub const SPLASH_OUT: MotionSpec = MotionSpec::new(500, EASE).with_delay(150);
|
||||
/// Sidebar / pane width+height transitions: 200ms ease-out.
|
||||
pub const RESIZE: MotionSpec = MotionSpec::new(200, EASE_OUT);
|
||||
/// Tab drag-reorder sliding transforms: 150ms.
|
||||
pub const TAB_SLIDE: MotionSpec = MotionSpec::new(150, EASE_OUT);
|
||||
/// Per-pane collapse: 180ms height.
|
||||
pub const COLLAPSE: MotionSpec = MotionSpec::new(180, EASE_OUT);
|
||||
/// A gliding move the reader caused rather than one they are dragging.
|
||||
pub const LAYOUT: MotionSpec = MotionSpec::new(200, EASE_OUT);
|
||||
/// Chevron rotate: 200ms.
|
||||
pub const CHEVRON: MotionSpec = MotionSpec::new(200, EASE);
|
||||
/// Scroll-to-row glide: 500ms ease-in-out.
|
||||
pub const SCROLL_GLIDE: MotionSpec = MotionSpec::new(500, EASE_IN_OUT);
|
||||
/// CSS `transition-colors` default: 150ms over [`EASE_TAILWIND`].
|
||||
pub const HOVER_FADE: MotionSpec = MotionSpec::new(150, EASE_TAILWIND);
|
||||
/// Toast slide/fade, coop's own: 250ms over [`EASE_TAILWIND`].
|
||||
pub const NOTIFICATION_IN: MotionSpec = MotionSpec::new(250, EASE_TAILWIND);
|
||||
/// Pulse loader period: 2.4s.
|
||||
pub const PULSE: MotionSpec = MotionSpec::new(2400, EASE);
|
||||
/// Spinner wave period: 750ms.
|
||||
pub const GRADIENT_SPIN: MotionSpec = MotionSpec::new(750, EASE);
|
||||
|
||||
/// Standard entrance: opacity 0→1 + translateY 4→0 over [`FADE_IN`].
|
||||
pub fn fade_in<E>(id: impl Into<ElementId>, element: E) -> AnimationElement<E>
|
||||
where
|
||||
E: Styled + IntoElement + 'static,
|
||||
{
|
||||
element.with_animation(id, FADE_IN.animation(), |el, t| {
|
||||
el.relative().opacity(t).top(px(4.0 * (1.0 - t)))
|
||||
})
|
||||
}
|
||||
|
||||
/// Quick opacity-only fade over [`FADE_QUICK`].
|
||||
pub fn fade_quick<E>(id: impl Into<ElementId>, element: E) -> AnimationElement<E>
|
||||
where
|
||||
E: Styled + IntoElement + 'static,
|
||||
{
|
||||
element.with_animation(id, FADE_QUICK.animation(), |el, t| el.opacity(t))
|
||||
}
|
||||
|
||||
/// Popover entrance: fade + translateY −2→0 over [`MENU_IN`].
|
||||
pub fn menu_in<E>(id: impl Into<ElementId>, element: E) -> AnimationElement<E>
|
||||
where
|
||||
E: Styled + IntoElement + 'static,
|
||||
{
|
||||
element.with_animation(id, MENU_IN.animation(), |el, t| {
|
||||
el.relative()
|
||||
.opacity(0.3 + 0.7 * t)
|
||||
.top(px(-2.0 * (1.0 - t)))
|
||||
})
|
||||
}
|
||||
|
||||
/// Popover exit over [`MENU_OUT`]; the caller supplies progress because
|
||||
/// `with_animation` replays from 0 on remount.
|
||||
pub fn menu_out<E>(id: impl Into<ElementId>, t: f32, element: E) -> AnimationElement<E>
|
||||
where
|
||||
E: Styled + IntoElement + 'static,
|
||||
{
|
||||
element.with_animation(id, MENU_OUT.animation(), move |el, _| {
|
||||
el.relative().opacity(1.0 - t).top(px(-2.0 * t))
|
||||
})
|
||||
}
|
||||
|
||||
/// Dialog entrance over [`DIALOG_IN`] (fade + 2px rise).
|
||||
pub fn dialog_in<E>(id: impl Into<ElementId>, element: E) -> AnimationElement<E>
|
||||
where
|
||||
E: Styled + IntoElement + 'static,
|
||||
{
|
||||
element.with_animation(id, DIALOG_IN.animation(), |el, t| {
|
||||
el.relative().opacity(t).top(px(2.0 * (1.0 - t)))
|
||||
})
|
||||
}
|
||||
|
||||
/// Boot-splash exit: hold 150ms, then fade out + lift 6px over 500ms.
|
||||
pub fn splash_out<E>(id: impl Into<ElementId>, element: E) -> AnimationElement<E>
|
||||
where
|
||||
E: Styled + IntoElement + 'static,
|
||||
{
|
||||
element.with_animation(id, SPLASH_OUT.animation(), |el, t| {
|
||||
el.opacity(1.0 - t).top(px(-6.0 * t))
|
||||
})
|
||||
}
|
||||
|
||||
/// Linear interpolation.
|
||||
pub fn lerp(from: f32, to: f32, t: f32) -> f32 {
|
||||
from + (to - from) * t
|
||||
}
|
||||
|
||||
/// One element's hover fade progress over [`HOVER_FADE`].
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct FadeEntry {
|
||||
origin: f32,
|
||||
target: f32,
|
||||
started: Instant,
|
||||
seen: u64,
|
||||
}
|
||||
|
||||
impl FadeEntry {
|
||||
fn value(&self, now: Instant, duration: Duration) -> f32 {
|
||||
let elapsed = now.saturating_duration_since(self.started);
|
||||
if duration.is_zero() || elapsed >= duration {
|
||||
return self.target;
|
||||
}
|
||||
let raw = elapsed.as_secs_f32() / duration.as_secs_f32();
|
||||
lerp(self.origin, self.target, HOVER_FADE.curve.eval(raw))
|
||||
}
|
||||
|
||||
fn settled(&self, now: Instant, duration: Duration) -> bool {
|
||||
self.origin == self.target || now.saturating_duration_since(self.started) >= duration
|
||||
}
|
||||
}
|
||||
|
||||
/// Which fade: the view that paints it plus the element key — two views
|
||||
/// using the same string would trade washes.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct Fade {
|
||||
pub painter: Painter,
|
||||
pub key: SharedString,
|
||||
}
|
||||
|
||||
impl Fade {
|
||||
pub fn new(painter: Painter, key: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
painter,
|
||||
key: key.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-fade progress store.
|
||||
#[derive(Default)]
|
||||
pub struct HoverFades {
|
||||
pub entries: HashMap<Fade, FadeEntry>,
|
||||
frame: u64,
|
||||
}
|
||||
|
||||
impl HoverFades {
|
||||
pub fn duration() -> Duration {
|
||||
HOVER_FADE.total().mul_f32(speed_scale())
|
||||
}
|
||||
|
||||
/// Pointer entered (`hovered`) or left the element behind `fade`.
|
||||
pub fn set_at(&mut self, fade: &Fade, hovered: bool, reduced: bool, now: Instant) {
|
||||
let target = if hovered { 1.0 } else { 0.0 };
|
||||
let duration = Self::duration();
|
||||
let current = self
|
||||
.entries
|
||||
.get(fade)
|
||||
.map(|e| e.value(now, duration))
|
||||
.unwrap_or(0.0);
|
||||
if target == 0.0 && !self.entries.contains_key(fade) {
|
||||
return;
|
||||
}
|
||||
let origin = if reduced { target } else { current };
|
||||
let seen = self.frame;
|
||||
self.entries.insert(
|
||||
fade.clone(),
|
||||
FadeEntry {
|
||||
origin,
|
||||
target,
|
||||
started: now,
|
||||
seen,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Hover progress (0..1) for `fade` at `now`; stamps liveness.
|
||||
pub fn value_at(&mut self, fade: &Fade, now: Instant) -> f32 {
|
||||
let frame = self.frame;
|
||||
match self.entries.get_mut(fade) {
|
||||
Some(entry) => {
|
||||
entry.seen = frame;
|
||||
entry.value(now, Self::duration())
|
||||
}
|
||||
None => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Advance the frame counter and prune settled or unmounted entries.
|
||||
pub fn tick_at(&mut self, now: Instant) -> bool {
|
||||
self.frame += 1;
|
||||
let frame = self.frame;
|
||||
let duration = Self::duration();
|
||||
let mut active = false;
|
||||
self.entries.retain(|_, entry| {
|
||||
if entry.seen + 1 < frame {
|
||||
return false;
|
||||
}
|
||||
let settled = entry.settled(now, duration);
|
||||
if !settled {
|
||||
active = true;
|
||||
}
|
||||
!(settled && entry.target == 0.0)
|
||||
});
|
||||
active
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static HOVER_FADES: RefCell<HoverFades> = RefCell::new(HoverFades::default());
|
||||
}
|
||||
|
||||
/// Hover progress (0..1) for `fade` this frame.
|
||||
pub fn hover_t(fade: &Fade) -> f32 {
|
||||
HOVER_FADES.with(|fades| fades.borrow_mut().value_at(fade, Instant::now()))
|
||||
}
|
||||
|
||||
/// Record a hover flip for `fade`; prefer [`hover_listener`].
|
||||
pub fn set_hover(fade: &Fade, hovered: bool, reduced: bool) {
|
||||
HOVER_FADES.with(|fades| {
|
||||
fades
|
||||
.borrow_mut()
|
||||
.set_at(fade, hovered, reduced, Instant::now())
|
||||
});
|
||||
}
|
||||
|
||||
/// An `.on_hover` listener driving the fade.
|
||||
pub fn hover_listener(fade: Fade) -> impl Fn(&bool, &mut Window, &mut App) + 'static {
|
||||
move |hovered, _window, cx| {
|
||||
set_hover(&fade, *hovered, cx.reduced_motion());
|
||||
fade.painter.lease(HOVER_FPS, HoverFades::duration(), cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Once-per-tick bookkeeping for the fade store, driven by the clock.
|
||||
fn tick_hover_fades() {
|
||||
HOVER_FADES.with(|fades| {
|
||||
fades.borrow_mut().tick_at(Instant::now());
|
||||
});
|
||||
}
|
||||
|
||||
/// Blend two colors by `t` the way the browser transitions them: sRGB with
|
||||
/// premultiplied alpha, so a wash fading in never passes through grey.
|
||||
pub fn mix(from: Hsla, to: Hsla, t: f32) -> Hsla {
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
if t <= 0.0 {
|
||||
return from;
|
||||
}
|
||||
if t >= 1.0 {
|
||||
return to;
|
||||
}
|
||||
let (f, g) = (Rgba::from(from), Rgba::from(to));
|
||||
let a = lerp(f.a, g.a, t);
|
||||
if a <= f32::EPSILON {
|
||||
return Hsla::from(Rgba { a: 0.0, ..g });
|
||||
}
|
||||
Hsla::from(Rgba {
|
||||
r: lerp(f.r * f.a, g.r * g.a, t) / a,
|
||||
g: lerp(f.g * f.a, g.g * g.a, t) / a,
|
||||
b: lerp(f.b * f.a, g.b * g.a, t) / a,
|
||||
a,
|
||||
})
|
||||
}
|
||||
|
||||
/// The standard hover blend: rest → hover color at this fade's progress.
|
||||
pub fn hover_blend(fade: &Fade, rest: Hsla, hover: Hsla) -> Hsla {
|
||||
mix(rest, hover, hover_t(fade))
|
||||
}
|
||||
|
||||
/// Process-wide motion speed, as raw `f32` bits; an atomic mirror because the
|
||||
/// timelines are read from free functions with no `cx` in scope.
|
||||
static SPEED: AtomicU32 = AtomicU32::new(1.0f32.to_bits());
|
||||
|
||||
/// How far every timeline in the catalog is stretched. `1.0` is the designed
|
||||
/// speed.
|
||||
pub fn speed_scale() -> f32 {
|
||||
f32::from_bits(SPEED.load(Ordering::Relaxed))
|
||||
}
|
||||
|
||||
/// Stretch every catalog timeline by `scale`; clamped, and a non-finite
|
||||
/// `scale` resets to `1.0`.
|
||||
pub fn set_speed(scale: f32) {
|
||||
let scale = if scale.is_finite() {
|
||||
scale.clamp(0.01, 100.0)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
SPEED.store(scale.to_bits(), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Process-wide motion switches.
|
||||
#[derive(Clone, Copy)]
|
||||
struct MotionSettings {
|
||||
reduced_motion: bool,
|
||||
pause_when_inactive: bool,
|
||||
}
|
||||
|
||||
impl Global for MotionSettings {}
|
||||
|
||||
impl MotionSettings {
|
||||
/// Defaults: the OS flag rules reduce motion; the clock pauses when no
|
||||
/// window is active.
|
||||
fn get(cx: &App) -> Self {
|
||||
cx.try_global::<MotionSettings>().copied().unwrap_or(Self {
|
||||
reduced_motion: false,
|
||||
pause_when_inactive: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// App-level motion switches.
|
||||
pub trait AppExt {
|
||||
/// The OS reduce-motion flag ORed with the manual override.
|
||||
fn reduced_motion(&self) -> bool;
|
||||
|
||||
/// Set the manual reduce-motion override.
|
||||
fn set_reduced_motion(&mut self, reduced: bool);
|
||||
|
||||
/// Whether the leased clock claims frames while no window is active.
|
||||
fn pause_when_inactive(&self) -> bool;
|
||||
|
||||
fn set_pause_when_inactive(&mut self, pause: bool);
|
||||
}
|
||||
|
||||
impl AppExt for App {
|
||||
fn reduced_motion(&self) -> bool {
|
||||
self.reduce_motion() || MotionSettings::get(self).reduced_motion
|
||||
}
|
||||
|
||||
fn set_reduced_motion(&mut self, reduced: bool) {
|
||||
let mut settings = MotionSettings::get(self);
|
||||
settings.reduced_motion = reduced;
|
||||
self.set_global(settings);
|
||||
}
|
||||
|
||||
fn pause_when_inactive(&self) -> bool {
|
||||
MotionSettings::get(self).pause_when_inactive
|
||||
}
|
||||
|
||||
fn set_pause_when_inactive(&mut self, pause: bool) {
|
||||
let mut settings = MotionSettings::get(self);
|
||||
settings.pause_when_inactive = pause;
|
||||
self.set_global(settings);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
use gpui::{
|
||||
App, Div, ImageSource, ObjectFit, ParentElement as _, Styled as _, StyledImage as _, div, img,
|
||||
};
|
||||
use gpui_kit::component::ActiveTheme as _;
|
||||
|
||||
/// Returns a `Div` as divider.
|
||||
pub fn divider(cx: &App) -> Div {
|
||||
div().w_full().h_px().bg(cx.theme().border)
|
||||
}
|
||||
|
||||
/// Returns a full-bleed image with a wash for readability, to show behind panel content.
|
||||
pub fn background<I>(image: I, cx: &App) -> Div
|
||||
where
|
||||
I: Into<ImageSource>,
|
||||
{
|
||||
let wash = if cx.theme().mode.is_dark() { 0.5 } else { 0.2 };
|
||||
div()
|
||||
.absolute()
|
||||
.inset_0()
|
||||
.child(img(image).size_full().object_fit(ObjectFit::Cover))
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.inset_0()
|
||||
.bg(cx.theme().background.opacity(wash)),
|
||||
)
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
use gpui::{FontWeight, Styled, px};
|
||||
|
||||
/// The base the ladder starts at; bezel's measured body is 13, coop keeps 15.
|
||||
pub const DEFAULT_BASE_TEXT_SIZE: f32 = 15.0;
|
||||
|
||||
/// The body size every painted role is scaled against, as raw `f32` bits.
|
||||
static BASE: AtomicU32 = AtomicU32::new(DEFAULT_BASE_TEXT_SIZE.to_bits());
|
||||
|
||||
/// Point the ladder at a body size in points; the caller refreshes windows.
|
||||
pub fn set_base_text_size(points: f32) {
|
||||
BASE.store(points.max(1.0).to_bits(), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// The body size in points every painted role is scaled against.
|
||||
pub fn base_text_size() -> f32 {
|
||||
f32::from_bits(BASE.load(Ordering::Relaxed))
|
||||
}
|
||||
|
||||
/// A role in the type ladder — SwiftUI's `Font.TextStyle`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum TextStyle {
|
||||
LargeTitle,
|
||||
Title,
|
||||
Title2,
|
||||
Title3,
|
||||
Headline,
|
||||
Subheadline,
|
||||
Body,
|
||||
Callout,
|
||||
Footnote,
|
||||
Caption,
|
||||
Caption2,
|
||||
}
|
||||
|
||||
impl TextStyle {
|
||||
/// The role's measured size in points.
|
||||
pub const fn size(self) -> f32 {
|
||||
match self {
|
||||
Self::LargeTitle => 26.0,
|
||||
Self::Title => 22.0,
|
||||
Self::Title2 => 17.0,
|
||||
Self::Title3 => 15.0,
|
||||
Self::Headline | Self::Body => 13.0,
|
||||
Self::Callout => 12.0,
|
||||
Self::Subheadline => 11.0,
|
||||
Self::Footnote | Self::Caption | Self::Caption2 => 10.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn painted(self) -> f32 {
|
||||
self.size() * base_text_size() / Self::Body.size()
|
||||
}
|
||||
|
||||
pub const fn line_height(self) -> f32 {
|
||||
match self {
|
||||
Self::LargeTitle => 32.0,
|
||||
Self::Title => 26.0,
|
||||
Self::Title2 => 22.0,
|
||||
Self::Title3 => 20.0,
|
||||
Self::Headline | Self::Body => 16.0,
|
||||
Self::Callout => 15.0,
|
||||
Self::Subheadline => 14.0,
|
||||
Self::Footnote | Self::Caption | Self::Caption2 => 13.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn painted_line_height(self) -> f32 {
|
||||
self.line_height() * base_text_size() / Self::Body.size()
|
||||
}
|
||||
|
||||
pub const fn weight(self) -> FontWeight {
|
||||
match self {
|
||||
Self::Headline => FontWeight::BOLD,
|
||||
Self::Caption2 => FontWeight::MEDIUM,
|
||||
_ => FontWeight::NORMAL,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One role as it is set: a rung on the ladder, its leading, weight and scale.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Metrics {
|
||||
pub role: TextStyle,
|
||||
pub leading: f32,
|
||||
pub weight: FontWeight,
|
||||
pub scale: f32,
|
||||
}
|
||||
|
||||
impl Metrics {
|
||||
pub const fn new(role: TextStyle, leading: f32, weight: FontWeight) -> Self {
|
||||
Self {
|
||||
role,
|
||||
leading,
|
||||
weight,
|
||||
scale: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn scaled(self, scale: f32) -> Self {
|
||||
Self { scale, ..self }
|
||||
}
|
||||
|
||||
pub fn size(self) -> f32 {
|
||||
self.role.painted() * self.scale
|
||||
}
|
||||
|
||||
pub fn line_height(self) -> f32 {
|
||||
self.size() * self.leading
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TextStyle> for Metrics {
|
||||
/// The ladder's own setting for a role: its measured leading and weight.
|
||||
fn from(role: TextStyle) -> Self {
|
||||
Self::new(role, role.line_height() / role.size(), role.weight())
|
||||
}
|
||||
}
|
||||
|
||||
/// The ladder, on anything styled.
|
||||
pub trait Typeset: Styled + Sized {
|
||||
/// Size, line height and weight together, from [`TextStyle`].
|
||||
fn text_style(self, style: TextStyle) -> Self {
|
||||
self.text_size(px(style.painted()))
|
||||
.line_height(px(style.painted_line_height()))
|
||||
.font_weight(style.weight())
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: Styled> Typeset for E {}
|
||||
@@ -3,8 +3,11 @@ use gpui::{
|
||||
Window, WindowControlArea,
|
||||
};
|
||||
|
||||
/// Window-drag and double-click handlers for the app's integrated title bar
|
||||
/// row. Attach to the header element that acts as the drag region.
|
||||
struct DragState {
|
||||
should_move: bool,
|
||||
}
|
||||
|
||||
/// Window-drag and double-click handlers for the app's integrated title bar.
|
||||
pub fn title_bar_drag_handlers(
|
||||
this: Stateful<Div>,
|
||||
window: &mut Window,
|
||||
@@ -49,7 +52,3 @@ pub fn title_bar_drag_handlers(
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
struct DragState {
|
||||
should_move: bool,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user