1use gpui::{
13 AbsoluteLength, DefiniteLength, Edges, EdgesRefinement, Fill, Hsla, Length, Pixels, SharedString,
14 StyleRefinement,
15};
16
17#[derive(Debug, Clone, PartialEq)]
19pub struct Declaration {
20 pub property: SharedString,
21 pub value: SharedString,
22 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
44pub 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
67pub 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
117fn 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
129fn 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
149pub 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
390fn 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#[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 pub width: f32,
416 pub height: f32,
417}
418
419impl BoxModel {
420 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
442pub 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}