introduce typography system and motion module

This commit is contained in:
2026-10-09 19:54:08 +07:00
parent 41e991dd30
commit df411bd044
53 changed files with 1116 additions and 457 deletions
+16 -10
View File
@@ -14,6 +14,7 @@ mod platform_kind;
mod scrollbar_mode;
mod syntax;
mod theme;
mod typography;
pub mod appearance;
@@ -38,6 +39,9 @@ pub use platform_kind::PlatformKind;
pub use scrollbar_mode::*;
pub use syntax::{HighlightKind, SyntaxPalette};
pub use theme::*;
pub use typography::{
DEFAULT_BASE_TEXT_SIZE, Metrics, TextStyle, Typeset, base_text_size, set_base_text_size,
};
/// Which appearance the app is painting. Bezel names this enum `Appearance`;
/// coop's `ThemeMode` predates the port and carries the same two variants.
@@ -78,6 +82,7 @@ pub trait AppExt {
fn set_brand(&mut self, brand: Brand);
fn set_palette(&mut self, build: PaletteBuilder);
fn set_appearance_mode(&mut self, mode: AppearanceMode);
fn set_base_text_size(&mut self, points: f32);
}
impl AppExt for App {
@@ -94,6 +99,11 @@ impl AppExt for App {
fn set_appearance_mode(&mut self, mode: AppearanceMode) {
appearance::set_mode(mode, self);
}
fn set_base_text_size(&mut self, points: f32) {
typography::set_base_text_size(points);
self.refresh_windows();
}
}
/// Re-install the palette for the current appearance.
@@ -123,12 +133,12 @@ pub fn sync_base(cx: &mut App) {
ScrollbarMode::Always => gpui_base::ScrollbarMode::Always,
};
let colors = theme.colors;
let font_family = theme.font_family.clone();
let font_sans = theme.font_sans.clone();
let base = gpui_base::Theme::global_mut(cx);
base.appearance = appearance;
base.scrollbar = base.scrollbar.clone().with_mode(scrollbar_mode);
base.tokens.typography.sans = font_family;
base.tokens.typography.sans = font_sans;
let tokens = &mut base.tokens.colors;
tokens.background = colors.bg;
@@ -166,10 +176,8 @@ impl ActiveTheme for App {
pub struct Theme {
pub colors: ThemeColors,
pub mode: ThemeMode,
pub font_family: SharedString,
pub font_body: SharedString,
pub font_sans: SharedString,
pub font_mono: SharedString,
pub font_size: Pixels,
pub shadow: bool,
pub scrollbar_mode: ScrollbarMode,
pub notification: NotificationSettings,
@@ -272,16 +280,14 @@ impl Default for Theme {
let platform = PlatformKind::platform();
let mode = ThemeMode::default();
// Define the font family based on the platform.
let font_family = match platform {
// The system UI font, by platform.
let font_sans = match platform {
PlatformKind::Linux => "Inter",
_ => ".SystemUIFont",
};
Self {
font_size: px(15.),
font_family: font_family.into(),
font_body: font_family.into(),
font_sans: font_sans.into(),
font_mono: system_mono().into(),
shadow: true,
scrollbar_mode: ScrollbarMode::default(),
+131
View File
@@ -0,0 +1,131 @@
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 {}
-125
View File
@@ -1,125 +0,0 @@
//! Palette contrast tests, mirroring bezel's `tests/theme.rs`: each light text
//! token must land within ~0.5 of its dark counterpart's contrast ratio
//! against its own background, and the primary text must keep the ratio the
//! palette was tuned for.
use gpui::Hsla;
use theme::{Brand, ThemeColors, ThemeMode, Tint, contrast_ratio, flatten};
// The shipped palette's measured floors (AppKit-style ink, per bezel):
// text/bg is 14.4:1 dark and 15.1:1 light, muted lands near 6.3 dark and
// 4.0 light, and faint sits below 3:1 in both appearances by design.
const TEXT_RATIO: f32 = 13.9;
const MUTED_RATIO: f32 = 3.9;
const FAINT_RATIO: f32 = 1.8;
const TEXT_PARITY: f32 = 0.7;
fn ratio(fg: Hsla, bg: Hsla) -> f32 {
contrast_ratio(flatten(fg, bg), bg)
}
#[test]
fn text_keeps_its_contrast() {
for appearance in [ThemeMode::Dark, ThemeMode::Light] {
let colors = ThemeColors::for_appearance(appearance);
assert!(
ratio(colors.text, colors.bg) >= TEXT_RATIO,
"{appearance:?}: text on bg = {}",
ratio(colors.text, colors.bg)
);
assert!(
ratio(colors.text, colors.surface) >= TEXT_RATIO,
"{appearance:?}: text on surface"
);
assert!(
ratio(colors.text_muted, colors.bg) >= MUTED_RATIO,
"{appearance:?}: muted on bg = {}",
ratio(colors.text_muted, colors.bg)
);
assert!(
ratio(colors.text_faint, colors.bg) >= FAINT_RATIO,
"{appearance:?}: faint on bg = {}",
ratio(colors.text_faint, colors.bg)
);
}
}
#[test]
fn light_matches_dark_contrast() {
let dark = ThemeColors::for_appearance(ThemeMode::Dark);
let light = ThemeColors::for_appearance(ThemeMode::Light);
// Primary text keeps near-parity across appearances. Muted and faint do
// not (the light values sit further from the background by design, see
// Ink::APPKIT); their floors are asserted in `text_keeps_its_contrast`.
let pairs = [
("text/bg", dark.text, dark.bg, light.text, light.bg),
(
"text/surface",
dark.text,
dark.surface,
light.text,
light.surface,
),
];
for (name, dark_fg, dark_bg, light_fg, light_bg) in pairs {
let dark_ratio = ratio(dark_fg, dark_bg);
let light_ratio = ratio(light_fg, light_bg);
let delta = (dark_ratio - light_ratio).abs();
assert!(
delta <= TEXT_PARITY,
"{name}: light deviates by {delta} (dark {dark_ratio:.3}, light {light_ratio:.3})"
);
}
}
#[test]
fn default_brand_is_a_no_op() {
let brand = Brand::default();
assert!(brand.is_default());
for appearance in [ThemeMode::Dark, ThemeMode::Light] {
let base = ThemeColors::for_appearance(appearance);
let branded = brand.branded(appearance);
assert_eq!(
base, branded,
"{appearance:?}: default brand must not move the palette"
);
}
}
#[test]
fn brand_tint_rotates_greys_and_keeps_semantics() {
let brand = Brand {
tint: Tint::new(257.417, 0.046),
accent: Tint::new(276.935, 0.182),
..Brand::default()
};
let base = ThemeColors::for_appearance(ThemeMode::Dark);
let branded = brand.branded(ThemeMode::Dark);
assert_ne!(branded.bg, base.bg, "bg takes the tint");
assert_ne!(branded.accent, base.accent, "accent takes the accent hue");
assert_eq!(
branded.danger, base.danger,
"danger is semantic and keeps its hue"
);
assert_eq!(
branded.warning, base.warning,
"warning is semantic and keeps its hue"
);
}
#[test]
fn warning_wash_carries_its_label() {
// warning_muted is a translucent amber wash, so the label pairing is
// measured against the plate flattened onto the content plane.
for appearance in [ThemeMode::Dark, ThemeMode::Light] {
let colors = ThemeColors::for_appearance(appearance);
let plate = flatten(colors.warning_muted, colors.bg);
assert!(
ratio(colors.warning, plate) >= 3.0,
"{appearance:?}: warning on warning_muted = {}",
ratio(colors.warning, plate)
);
}
}