Skip to main content

guise/devtools/
styles.rs

1//! Turning a gpui [`StyleRefinement`] into something that reads like CSS.
2//!
3//! Safari's Styles sidebar is a list of declarations and its Computed sidebar
4//! is a box-model diagram; both want `property: value` pairs, and gpui stores
5//! style as a refinement of typed `Option` fields. This module is the
6//! translation, kept pure so it can be tested without a window.
7//!
8//! Only *set* fields are emitted. That is the useful behaviour and also the
9//! honest one: a refinement records what a component actually asked for, and
10//! inventing defaults for the rest would show style the element does not have.
11
12use gpui::{
13  AbsoluteLength, DefiniteLength, Edges, EdgesRefinement, Fill, Hsla, Length, Pixels, SharedString,
14  StyleRefinement,
15};
16
17/// One `property: value` line in the Styles sidebar.
18#[derive(Debug, Clone, PartialEq)]
19pub struct Declaration {
20  pub property: SharedString,
21  pub value: SharedString,
22  /// Set when the value names a color, so the row can paint a swatch.
23  pub color: Option<Hsla>,
24}
25
26impl Declaration {
27  fn new(property: &'static str, value: impl Into<SharedString>) -> Self {
28    Declaration {
29      property: SharedString::new_static(property),
30      value: value.into(),
31      color: None,
32    }
33  }
34
35  fn colored(property: &'static str, color: Hsla) -> Self {
36    Declaration {
37      property: SharedString::new_static(property),
38      value: hex(color).into(),
39      color: Some(color),
40    }
41  }
42}
43
44/// `#rrggbb`, or `#rrggbbaa` when the color is not fully opaque — the notation
45/// Safari's color swatches label themselves with.
46pub fn hex(color: Hsla) -> String {
47  let rgba = gpui::Rgba::from(color);
48  let byte = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
49  if rgba.a >= 1.0 {
50    format!(
51      "#{:02x}{:02x}{:02x}",
52      byte(rgba.r),
53      byte(rgba.g),
54      byte(rgba.b)
55    )
56  } else {
57    format!(
58      "#{:02x}{:02x}{:02x}{:02x}",
59      byte(rgba.r),
60      byte(rgba.g),
61      byte(rgba.b),
62      byte(rgba.a)
63    )
64  }
65}
66
67/// Recover the color behind a [`Fill`].
68///
69/// `Background::solid` is crate-private in gpui and `Fill::color` hands back
70/// the same opaque `Background`, so its `Debug` output is the only public view
71/// of the value. Parsing it is unlovely, but it is contained here, it is
72/// covered by tests, and the failure mode is a missing swatch rather than a
73/// wrong one.
74pub fn fill_color(fill: &Fill) -> Option<Hsla> {
75  let background = fill.color()?;
76  let text = format!("{background:?}");
77  if !text.starts_with("Solid(") {
78    return None;
79  }
80  let floats: Vec<f32> = text
81    .split(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-'))
82    .filter(|part| !part.is_empty())
83    .filter_map(|part| part.parse::<f32>().ok())
84    .collect();
85  match floats.as_slice() {
86    [h, s, l, a, ..] => Some(Hsla {
87      h: *h,
88      s: *s,
89      l: *l,
90      a: *a,
91    }),
92    _ => None,
93  }
94}
95
96fn absolute(length: AbsoluteLength) -> String {
97  match length {
98    AbsoluteLength::Pixels(px) => format!("{}px", trim(f32::from(px))),
99    AbsoluteLength::Rems(rems) => format!("{}rem", trim(rems.0)),
100  }
101}
102
103fn definite(length: DefiniteLength) -> String {
104  match length {
105    DefiniteLength::Absolute(absolute_length) => absolute(absolute_length),
106    DefiniteLength::Fraction(fraction) => format!("{}%", trim(fraction * 100.0)),
107  }
108}
109
110fn length(length: Length) -> String {
111  match length {
112    Length::Definite(definite_length) => definite(definite_length),
113    Length::Auto => "auto".to_string(),
114  }
115}
116
117/// Drop the trailing `.0` so `12px` does not read as `12.0px`.
118fn trim(value: f32) -> String {
119  if (value - value.round()).abs() < f32::EPSILON {
120    format!("{}", value.round() as i64)
121  } else {
122    format!("{value:.2}")
123      .trim_end_matches('0')
124      .trim_end_matches('.')
125      .to_string()
126  }
127}
128
129/// Emit `prefix`, `prefix-top`, … for a set of edges, collapsing to the
130/// shorthand when every side agrees — the same rule a CSS formatter uses.
131fn edges<T: Copy + PartialEq>(
132  out: &mut Vec<Declaration>,
133  shorthand: &'static str,
134  sides: [(&'static str, Option<T>); 4],
135  format: impl Fn(T) -> String,
136) {
137  let values: Vec<T> = sides.iter().filter_map(|(_, value)| *value).collect();
138  if values.len() == 4 && values.windows(2).all(|pair| pair[0] == pair[1]) {
139    out.push(Declaration::new(shorthand, format(values[0])));
140    return;
141  }
142  for (property, value) in sides {
143    if let Some(value) = value {
144      out.push(Declaration::new(property, format(value)));
145    }
146  }
147}
148
149/// Every declaration the refinement actually sets, in CSS authoring order:
150/// layout, then box, then flex, then visual, then text.
151pub fn declarations(style: &StyleRefinement) -> Vec<Declaration> {
152  let mut out = Vec::new();
153
154  if let Some(display) = style.display {
155    out.push(Declaration::new(
156      "display",
157      format!("{display:?}").to_lowercase(),
158    ));
159  }
160  if let Some(position) = style.position {
161    out.push(Declaration::new(
162      "position",
163      format!("{position:?}").to_lowercase(),
164    ));
165  }
166  if let Some(visibility) = style.visibility {
167    out.push(Declaration::new(
168      "visibility",
169      format!("{visibility:?}").to_lowercase(),
170    ));
171  }
172  if let Some(overflow) = style.overflow.x {
173    out.push(Declaration::new(
174      "overflow-x",
175      format!("{overflow:?}").to_lowercase(),
176    ));
177  }
178  if let Some(overflow) = style.overflow.y {
179    out.push(Declaration::new(
180      "overflow-y",
181      format!("{overflow:?}").to_lowercase(),
182    ));
183  }
184
185  edges(
186    &mut out,
187    "inset",
188    [
189      ("top", style.inset.top),
190      ("right", style.inset.right),
191      ("bottom", style.inset.bottom),
192      ("left", style.inset.left),
193    ],
194    length,
195  );
196
197  if let Some(width) = style.size.width {
198    out.push(Declaration::new("width", length(width)));
199  }
200  if let Some(height) = style.size.height {
201    out.push(Declaration::new("height", length(height)));
202  }
203  if let Some(width) = style.min_size.width {
204    out.push(Declaration::new("min-width", length(width)));
205  }
206  if let Some(height) = style.min_size.height {
207    out.push(Declaration::new("min-height", length(height)));
208  }
209  if let Some(width) = style.max_size.width {
210    out.push(Declaration::new("max-width", length(width)));
211  }
212  if let Some(height) = style.max_size.height {
213    out.push(Declaration::new("max-height", length(height)));
214  }
215  if let Some(ratio) = style.aspect_ratio {
216    out.push(Declaration::new("aspect-ratio", trim(ratio)));
217  }
218
219  edges(
220    &mut out,
221    "margin",
222    [
223      ("margin-top", style.margin.top),
224      ("margin-right", style.margin.right),
225      ("margin-bottom", style.margin.bottom),
226      ("margin-left", style.margin.left),
227    ],
228    length,
229  );
230  edges(
231    &mut out,
232    "padding",
233    [
234      ("padding-top", style.padding.top),
235      ("padding-right", style.padding.right),
236      ("padding-bottom", style.padding.bottom),
237      ("padding-left", style.padding.left),
238    ],
239    definite,
240  );
241  edges(
242    &mut out,
243    "border-width",
244    [
245      ("border-top-width", style.border_widths.top),
246      ("border-right-width", style.border_widths.right),
247      ("border-bottom-width", style.border_widths.bottom),
248      ("border-left-width", style.border_widths.left),
249    ],
250    absolute,
251  );
252
253  if let Some(direction) = style.flex_direction {
254    out.push(Declaration::new(
255      "flex-direction",
256      match format!("{direction:?}").as_str() {
257        "Row" => "row".to_string(),
258        "Column" => "column".to_string(),
259        "RowReverse" => "row-reverse".to_string(),
260        "ColumnReverse" => "column-reverse".to_string(),
261        other => other.to_lowercase(),
262      },
263    ));
264  }
265  if let Some(wrap) = style.flex_wrap {
266    out.push(Declaration::new("flex-wrap", kebab(&format!("{wrap:?}"))));
267  }
268  if let Some(align) = style.align_items {
269    out.push(Declaration::new(
270      "align-items",
271      kebab(&format!("{align:?}")),
272    ));
273  }
274  if let Some(align) = style.align_self {
275    out.push(Declaration::new("align-self", kebab(&format!("{align:?}"))));
276  }
277  if let Some(align) = style.align_content {
278    out.push(Declaration::new(
279      "align-content",
280      kebab(&format!("{align:?}")),
281    ));
282  }
283  if let Some(justify) = style.justify_content {
284    out.push(Declaration::new(
285      "justify-content",
286      kebab(&format!("{justify:?}")),
287    ));
288  }
289  if let Some(basis) = style.flex_basis {
290    out.push(Declaration::new("flex-basis", length(basis)));
291  }
292  if let Some(grow) = style.flex_grow {
293    out.push(Declaration::new("flex-grow", trim(grow)));
294  }
295  if let Some(shrink) = style.flex_shrink {
296    out.push(Declaration::new("flex-shrink", trim(shrink)));
297  }
298  if let Some(gap) = style.gap.width {
299    out.push(Declaration::new("column-gap", definite(gap)));
300  }
301  if let Some(gap) = style.gap.height {
302    out.push(Declaration::new("row-gap", definite(gap)));
303  }
304
305  if let Some(fill) = &style.background {
306    match fill_color(fill) {
307      Some(color) => out.push(Declaration::colored("background-color", color)),
308      None => out.push(Declaration::new("background", format!("{fill:?}"))),
309    }
310  }
311  if let Some(color) = style.border_color {
312    out.push(Declaration::colored("border-color", color));
313  }
314  if let Some(style_) = style.border_style {
315    out.push(Declaration::new(
316      "border-style",
317      format!("{style_:?}").to_lowercase(),
318    ));
319  }
320
321  let radii = [
322    ("border-top-left-radius", style.corner_radii.top_left),
323    ("border-top-right-radius", style.corner_radii.top_right),
324    (
325      "border-bottom-right-radius",
326      style.corner_radii.bottom_right,
327    ),
328    ("border-bottom-left-radius", style.corner_radii.bottom_left),
329  ];
330  edges(&mut out, "border-radius", radii, absolute);
331
332  if let Some(opacity) = style.opacity {
333    out.push(Declaration::new("opacity", trim(opacity)));
334  }
335  if !style
336    .box_shadow
337    .as_ref()
338    .is_none_or(|shadows| shadows.is_empty())
339  {
340    let count = style.box_shadow.as_ref().map_or(0, |shadows| shadows.len());
341    out.push(Declaration::new(
342      "box-shadow",
343      if count == 1 {
344        "1 shadow".to_string()
345      } else {
346        format!("{count} shadows")
347      },
348    ));
349  }
350
351  if let Some(text) = &style.text {
352    if let Some(color) = text.color {
353      out.push(Declaration::colored("color", color));
354    }
355    if let Some(family) = &text.font_family {
356      out.push(Declaration::new("font-family", family.clone()));
357    }
358    if let Some(size) = text.font_size {
359      out.push(Declaration::new("font-size", absolute(size)));
360    }
361    if let Some(weight) = text.font_weight {
362      out.push(Declaration::new("font-weight", trim(weight.0)));
363    }
364    if let Some(font_style) = text.font_style {
365      out.push(Declaration::new(
366        "font-style",
367        format!("{font_style:?}").to_lowercase(),
368      ));
369    }
370    if let Some(height) = text.line_height {
371      out.push(Declaration::new("line-height", definite(height)));
372    }
373    if let Some(align) = text.text_align {
374      out.push(Declaration::new("text-align", kebab(&format!("{align:?}"))));
375    }
376    if let Some(white_space) = text.white_space {
377      out.push(Declaration::new(
378        "white-space",
379        kebab(&format!("{white_space:?}")),
380      ));
381    }
382    if let Some(color) = text.background_color {
383      out.push(Declaration::colored("background-color (text)", color));
384    }
385  }
386
387  out
388}
389
390/// `SpaceBetween` -> `space-between`.
391fn kebab(variant: &str) -> String {
392  let mut out = String::with_capacity(variant.len() + 4);
393  for (index, ch) in variant.chars().enumerate() {
394    if ch.is_uppercase() {
395      if index > 0 {
396        out.push('-');
397      }
398      out.extend(ch.to_lowercase());
399    } else {
400      out.push(ch);
401    }
402  }
403  out
404}
405
406/// The four nested boxes of the Computed sidebar's diagram. Every value is in
407/// pixels, already resolved against the rem size, so the diagram can label
408/// itself with numbers rather than units.
409#[derive(Debug, Clone, Copy, PartialEq, Default)]
410pub struct BoxModel {
411  pub margin: Edges<f32>,
412  pub border: Edges<f32>,
413  pub padding: Edges<f32>,
414  /// The laid-out size, which is the border box gpui measured.
415  pub width: f32,
416  pub height: f32,
417}
418
419impl BoxModel {
420  /// The content box: the laid-out size less border and padding.
421  pub fn content(&self) -> (f32, f32) {
422    let width =
423      self.width - self.border.left - self.border.right - self.padding.left - self.padding.right;
424    let height =
425      self.height - self.border.top - self.border.bottom - self.padding.top - self.padding.bottom;
426    (width.max(0.0), height.max(0.0))
427  }
428}
429
430fn edge_pixels<T: Copy + std::fmt::Debug + Default + PartialEq>(
431  edges: &EdgesRefinement<T>,
432  resolve: impl Fn(T) -> f32,
433) -> Edges<f32> {
434  Edges {
435    top: edges.top.map(&resolve).unwrap_or(0.0),
436    right: edges.right.map(&resolve).unwrap_or(0.0),
437    bottom: edges.bottom.map(&resolve).unwrap_or(0.0),
438    left: edges.left.map(&resolve).unwrap_or(0.0),
439  }
440}
441
442/// Resolve the box model for an element, given the bounds it was laid out at.
443///
444/// `rem_size` is what turns rem-based style into the pixel numbers the diagram
445/// prints; percentage values have no parent to resolve against here and read
446/// as zero, exactly as Safari shows them when it cannot compute one.
447pub fn box_model(style: &StyleRefinement, size: gpui::Size<Pixels>, rem_size: Pixels) -> BoxModel {
448  let absolute_px = |value: AbsoluteLength| f32::from(value.to_pixels(rem_size));
449  let definite_px = |value: DefiniteLength| match value {
450    DefiniteLength::Absolute(absolute) => absolute_px(absolute),
451    DefiniteLength::Fraction(_) => 0.0,
452  };
453  let length_px = |value: Length| match value {
454    Length::Definite(definite) => definite_px(definite),
455    Length::Auto => 0.0,
456  };
457
458  BoxModel {
459    margin: edge_pixels(&style.margin, length_px),
460    border: edge_pixels(&style.border_widths, absolute_px),
461    padding: edge_pixels(&style.padding, definite_px),
462    width: f32::from(size.width),
463    height: f32::from(size.height),
464  }
465}
466
467#[cfg(test)]
468mod tests {
469  use super::*;
470  use gpui::{px, rems, Styled};
471
472  fn style_of(build: impl FnOnce(gpui::Div) -> gpui::Div) -> StyleRefinement {
473    let mut div = build(gpui::div());
474    div.style().clone()
475  }
476
477  fn find<'a>(declarations: &'a [Declaration], property: &str) -> Option<&'a Declaration> {
478    declarations
479      .iter()
480      .find(|d| d.property.as_ref() == property)
481  }
482
483  #[test]
484  fn only_set_fields_are_emitted() {
485    let declarations = declarations(&style_of(|d| d.w(px(120.0))));
486    assert_eq!(declarations.len(), 1);
487    assert_eq!(declarations[0].property.as_ref(), "width");
488    assert_eq!(declarations[0].value.as_ref(), "120px");
489  }
490
491  #[test]
492  fn uniform_edges_collapse_to_the_shorthand() {
493    let declarations = declarations(&style_of(|d| d.p(px(8.0))));
494    assert_eq!(
495      find(&declarations, "padding").unwrap().value.as_ref(),
496      "8px"
497    );
498    assert!(find(&declarations, "padding-top").is_none());
499  }
500
501  #[test]
502  fn mixed_edges_stay_long_hand() {
503    let declarations = declarations(&style_of(|d| d.pt(px(4.0)).pb(px(10.0))));
504    assert!(find(&declarations, "padding").is_none());
505    assert_eq!(
506      find(&declarations, "padding-top").unwrap().value.as_ref(),
507      "4px"
508    );
509    assert_eq!(
510      find(&declarations, "padding-bottom")
511        .unwrap()
512        .value
513        .as_ref(),
514      "10px"
515    );
516  }
517
518  #[test]
519  fn rems_keep_their_unit() {
520    let declarations = declarations(&style_of(|d| d.w(rems(2.0))));
521    assert_eq!(find(&declarations, "width").unwrap().value.as_ref(), "2rem");
522  }
523
524  #[test]
525  fn fractions_render_as_percentages() {
526    let declarations = declarations(&style_of(|d| d.w_1_2()));
527    assert_eq!(find(&declarations, "width").unwrap().value.as_ref(), "50%");
528  }
529
530  #[test]
531  fn variants_are_kebab_cased() {
532    let declarations = declarations(&style_of(|d| d.flex().justify_between().items_center()));
533    assert_eq!(
534      find(&declarations, "justify-content")
535        .unwrap()
536        .value
537        .as_ref(),
538      "space-between"
539    );
540    assert_eq!(
541      find(&declarations, "align-items").unwrap().value.as_ref(),
542      "center"
543    );
544    assert_eq!(
545      find(&declarations, "display").unwrap().value.as_ref(),
546      "flex"
547    );
548  }
549
550  #[test]
551  fn a_background_yields_a_swatch_color() {
552    let blue = gpui::hsla(0.6, 0.5, 0.5, 1.0);
553    let declarations = declarations(&style_of(|d| d.bg(blue)));
554    let background = find(&declarations, "background-color").unwrap();
555
556    let color = background
557      .color
558      .expect("a solid fill should recover its color");
559    assert!((color.h - blue.h).abs() < 0.01);
560    assert!((color.s - blue.s).abs() < 0.01);
561    assert!((color.l - blue.l).abs() < 0.01);
562    assert!((color.a - blue.a).abs() < 0.01);
563  }
564
565  #[test]
566  fn a_translucent_color_keeps_its_alpha_byte() {
567    assert_eq!(hex(gpui::hsla(0.0, 0.0, 0.0, 1.0)), "#000000");
568    assert_eq!(hex(gpui::hsla(0.0, 0.0, 1.0, 1.0)), "#ffffff");
569    assert_eq!(hex(gpui::hsla(0.0, 0.0, 0.0, 0.5)), "#00000080");
570  }
571
572  #[test]
573  fn the_box_model_resolves_rems_against_the_rem_size() {
574    let style = style_of(|d| d.p(rems(1.0)).border_2().m(px(6.0)));
575    let model = box_model(&style, gpui::size(px(100.0), px(50.0)), px(16.0));
576
577    assert_eq!(model.padding.top, 16.0);
578    assert_eq!(model.border.left, 2.0);
579    assert_eq!(model.margin.bottom, 6.0);
580    assert_eq!(model.content(), (100.0 - 32.0 - 4.0, 50.0 - 32.0 - 4.0));
581  }
582
583  #[test]
584  fn a_content_box_never_goes_negative() {
585    let style = style_of(|d| d.p(px(80.0)));
586    let model = box_model(&style, gpui::size(px(20.0), px(20.0)), px(16.0));
587    assert_eq!(model.content(), (0.0, 0.0));
588  }
589
590  #[test]
591  fn pixel_values_lose_their_trailing_zero() {
592    assert_eq!(trim(12.0), "12");
593    assert_eq!(trim(12.5), "12.5");
594    assert_eq!(trim(0.25), "0.25");
595  }
596}