introduce typography system and motion module
This commit is contained in:
+16
-10
@@ -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(),
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user