Skip to repository content510 lines · 21.8 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T05:45:28.245Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
inject_css.rs
1//! Builds a theme-aware CSS stylesheet and appends it into the SVG's `<style>`
2//! element. All selectors are scoped to the SVG's `id` to prevent leaking.
3//!
4//! ```xml
5//! <!-- before -->
6//! <style>.node rect { fill: white; }</style>
7//!
8//! <!-- after -->
9//! <style>.node rect { fill: white; }
10//! #mermaid-1 .node rect { fill: #89b4fa !important; }
11//! /* ... theme rules ... */
12//! </style>
13//! ```
14
15use std::collections::VecDeque;
16use std::fmt::Write;
17
18use anyhow::Result;
19use quick_xml::events::{BytesText, Event};
20
21use crate::MermaidTheme;
22
23/// Morally equivalent to `format!(".section-{i}")`, but without allocating
24const MINDMAP_SECTION_SELECTORS: [&str; 11] = [
25 ".section-0",
26 ".section-1",
27 ".section-2",
28 ".section-3",
29 ".section-4",
30 ".section-5",
31 ".section-6",
32 ".section-7",
33 ".section-8",
34 ".section-9",
35 ".section-10",
36];
37
38struct InjectCss<'a, I> {
39 inner: I,
40 injected_css: String,
41 in_style: bool,
42 injected: bool,
43 pending: VecDeque<Event<'a>>,
44}
45
46impl<'a, I: Iterator<Item = Result<Event<'a>>>> Iterator for InjectCss<'a, I> {
47 type Item = Result<Event<'a>>;
48
49 fn next(&mut self) -> Option<Self::Item> {
50 if let Some(event) = self.pending.pop_front() {
51 return Some(Ok(event));
52 }
53
54 let event = match self.inner.next()? {
55 Ok(ev) => ev,
56 Err(e) => return Some(Err(e)),
57 };
58
59 match &event {
60 Event::Start(e) if e.name().as_ref() == b"style" => {
61 self.in_style = true;
62 return Some(Ok(event));
63 }
64 Event::End(e) if e.name().as_ref() == b"style" => {
65 self.in_style = false;
66 if !self.injected {
67 self.injected = true;
68 self.pending
69 .push_back(Event::Text(BytesText::from_escaped(std::mem::take(
70 &mut self.injected_css,
71 ))));
72 self.pending.push_back(event);
73 return self.pending.pop_front().map(Ok);
74 }
75 return Some(Ok(event));
76 }
77 Event::Text(text) if self.in_style => {
78 self.injected = true;
79 let existing = match std::str::from_utf8(text.as_ref()) {
80 Ok(s) => s,
81 Err(e) => return Some(Err(e.into())),
82 };
83 let mut combined = String::with_capacity(existing.len() + self.injected_css.len());
84 combined.push_str(existing);
85 combined.push_str(&self.injected_css);
86 return Some(Ok(Event::Text(BytesText::from_escaped(combined))));
87 }
88 _ => {}
89 }
90
91 Some(Ok(event))
92 }
93}
94
95pub(super) fn process<'a>(
96 events: impl Iterator<Item = Result<Event<'a>>>,
97 theme: &MermaidTheme,
98 svg_id: &str,
99) -> impl Iterator<Item = Result<Event<'a>>> {
100 let injected_css = build_injected_css(theme, svg_id);
101 InjectCss {
102 inner: events,
103 injected_css,
104 in_style: false,
105 injected: false,
106 pending: VecDeque::new(),
107 }
108}
109
110fn mindmap_section_css(theme: &MermaidTheme) -> String {
111 let colors: [String; 8] = theme.git_branch_colors.map(crate::css_color);
112 let fills: [String; 8] = theme.git_branch_colors.map(|c| {
113 crate::css_color(blend_over_background(
114 c,
115 theme.background,
116 ACCENT_FILL_OPACITY,
117 ))
118 });
119 let text = crate::css_color(theme.text_color);
120 let mut css = String::with_capacity(5_400);
121
122 let emit = |css: &mut String, selector: &str, color: &str, fill: &str, txt: &str| {
123 let section_index = selector
124 .trim_start_matches(".section-root.section-")
125 .trim_start_matches(".section-");
126 write!(
127 css,
128 "{selector} rect, {selector} path, {selector} circle, {selector} polygon \
129 {{ fill: {fill} !important; stroke: {color} !important; }}\n\
130 {selector} text, {selector} span, \
131 text{selector}, tspan{selector} \
132 {{ fill: {txt} !important; color: {txt} !important; }}\n\
133 {selector} foreignObject div, {selector} foreignObject span, {selector} foreignObject p \
134 {{ color: {txt} !important; }}\n\
135 .section-edge{section_index} {{ stroke: {color} !important; }}\n",
136 )
137 .expect("write to String cannot fail");
138 };
139
140 emit(
141 &mut css,
142 ".section-root.section--1",
143 &colors[0],
144 &fills[0],
145 &text,
146 );
147 emit(&mut css, ".section--1", &colors[1], &fills[1], &text);
148 for (i, selector) in MINDMAP_SECTION_SELECTORS.iter().enumerate() {
149 let ci = 2 + (i % 6);
150 emit(&mut css, selector, &colors[ci], &fills[ci], &text);
151 }
152 css
153}
154
155fn git_branch_css(theme: &MermaidTheme) -> String {
156 let text = crate::css_color(theme.text_color);
157 let mut css = String::with_capacity(8 * 200);
158 for i in 0..8 {
159 let c = crate::css_color(theme.git_branch_colors[i]);
160 let label_fill = crate::css_color(blend_over_background(
161 theme.git_branch_colors[i],
162 theme.background,
163 ACCENT_FILL_OPACITY,
164 ));
165 write!(
166 css,
167 ".commit{i} {{ stroke: {c}; fill: {c}; }}\n\
168 .arrow{i} {{ stroke: {c}; }}\n\
169 .label{i} {{ fill: {label_fill}; stroke: {c}; }}\n\
170 .branch-label{i} {{ fill: {text}; }}\n"
171 )
172 .expect("write to String cannot fail");
173 }
174 css
175}
176
177fn adjust_lightness(color: &mut gpui::Hsla, dark_mode: bool) {
178 if dark_mode {
179 color.l = (color.l * 0.7).max(0.0);
180 } else {
181 color.l = (color.l * 1.3).min(1.0);
182 }
183}
184
185const ACCENT_FILL_OPACITY: f32 = 0.15;
186
187fn blend_over_background(
188 foreground: gpui::Hsla,
189 background: gpui::Hsla,
190 opacity: f32,
191) -> gpui::Hsla {
192 let fg = gpui::Rgba::from(foreground);
193 let bg = gpui::Rgba::from(background);
194 let blended = gpui::Rgba {
195 r: fg.r * opacity + bg.r * (1.0 - opacity),
196 g: fg.g * opacity + bg.g * (1.0 - opacity),
197 b: fg.b * opacity + bg.b * (1.0 - opacity),
198 a: 1.0,
199 };
200 gpui::Hsla::from(blended)
201}
202
203fn accent_css(theme: &MermaidTheme) -> String {
204 let mut css = String::with_capacity(theme.accent_colors.len() * 420);
205 let text = crate::css_color(theme.text_color);
206
207 for (i, accent) in theme.accent_colors.iter().enumerate() {
208 let stroke = crate::css_color(accent.foreground);
209 let fill = crate::css_color(blend_over_background(
210 accent.background,
211 theme.background,
212 ACCENT_FILL_OPACITY,
213 ));
214 let class = format!(".zed-accent-{i}");
215 write!(
216 css,
217 "{class} rect, {class} path, {class} circle, {class} polygon, {class} ellipse, \
218 rect{class}, path{class}, circle{class}, polygon{class}, ellipse{class} \
219 {{ fill: {fill} !important; stroke: {stroke} !important; }}\n\
220 {class} text, {class} tspan, text{class}, tspan{class} \
221 {{ fill: {text} !important; }}\n",
222 )
223 .expect("write to String cannot fail");
224 }
225 css
226}
227
228fn chart_color_css(theme: &MermaidTheme) -> String {
229 // Each block is around 230 bytes, add some headroom
230 let mut css = String::with_capacity(8 * 250);
231 for i in 0..8 {
232 let color = crate::css_color(theme.git_branch_colors[i]);
233 let class = format!(".zed-chart-{i}");
234 write!(
235 css,
236 "path.pieCircle{class} {{ fill: {color} !important; }}\n\
237 .plot rect{class}, .legend rect{class} {{ fill: {color} !important; stroke: {color} !important; }}\n\
238 .plot path{class} {{ stroke: {color} !important; }}\n"
239 )
240 .expect("write to String cannot fail");
241 }
242 css
243}
244
245fn timeline_css(theme: &MermaidTheme) -> String {
246 let mut css = String::with_capacity(8 * 300);
247 let text = crate::css_color(theme.text_color);
248 for i in 0..8 {
249 let c = crate::css_color(theme.git_branch_colors[i]);
250 let fill = crate::css_color(blend_over_background(
251 theme.git_branch_colors[i],
252 theme.background,
253 ACCENT_FILL_OPACITY,
254 ));
255 write!(
256 css,
257 "rect.task-type-{i}, rect.section-type-{i} {{ fill: {fill} !important; stroke: {c} !important; }}\n"
258 ).expect("write to String cannot fail");
259 }
260 for i in 0..4 {
261 let c = crate::css_color(theme.git_branch_colors[i % 8]);
262 let fill = crate::css_color(blend_over_background(
263 theme.git_branch_colors[i % 8],
264 theme.background,
265 ACCENT_FILL_OPACITY,
266 ));
267 write!(
268 css,
269 ".section{i} {{ fill: {fill} !important; }}\n\
270 .task{i} {{ fill: {fill} !important; stroke: {c} !important; }}\n\
271 .taskText{i} {{ fill: {text} !important; }}\n\
272 .taskTextOutside{i} {{ fill: {text} !important; }}\n"
273 )
274 .expect("write to String cannot fail");
275 }
276 css
277}
278
279fn should_scope_css_line(trimmed: &str) -> bool {
280 !trimmed.is_empty()
281 && (trimmed.starts_with('.')
282 || trimmed.starts_with("foreignObject")
283 || trimmed.starts_with("g.")
284 || trimmed.starts_with("text")
285 || trimmed.starts_with("tspan")
286 || trimmed.starts_with("rect.")
287 || trimmed.starts_with("path.")
288 || trimmed.starts_with("defs")
289 || trimmed.starts_with('#'))
290}
291
292fn scoped_selector_count(raw_css: &str) -> usize {
293 raw_css.lines().fold(0, |count, line| {
294 let trimmed = line.trim();
295 if !should_scope_css_line(trimmed) {
296 return count;
297 }
298 let Some((selectors, _)) = trimmed.split_once('{') else {
299 return count;
300 };
301 count.saturating_add(selectors.split(',').count())
302 })
303}
304
305fn scope_css(raw_css: &str, svg_id: &str) -> String {
306 let scoped_selector_prefix_len = svg_id.len().saturating_add(2);
307 let result_capacity = raw_css
308 .len()
309 .saturating_add(scoped_selector_count(raw_css).saturating_mul(scoped_selector_prefix_len));
310 let mut result = String::with_capacity(result_capacity);
311 for line in raw_css.lines() {
312 let trimmed = line.trim();
313
314 if should_scope_css_line(trimmed) {
315 if let Some(brace) = trimmed.find('{') {
316 let (selectors, rest) = trimmed.split_at(brace);
317 let mut first = true;
318 for selector in selectors.split(',') {
319 if !first {
320 result.push_str(", ");
321 }
322 first = false;
323 write!(result, "#{svg_id} {}", selector.trim())
324 .expect("write to String cannot fail");
325 }
326 writeln!(result, "{rest}").expect("write to String cannot fail");
327 continue;
328 }
329 }
330 writeln!(result, "{line}").expect("write to String cannot fail");
331 }
332 result
333}
334
335fn build_injected_css(theme: &MermaidTheme, svg_id: &str) -> String {
336 let font = &theme.font_family;
337 let text = crate::css_color(theme.text_color);
338 let line = crate::css_color(theme.line_color);
339 let primary = crate::css_color(theme.primary_color);
340 let border = crate::css_color(theme.primary_border_color);
341 let secondary = crate::css_color(theme.secondary_color);
342 let tertiary = crate::css_color(theme.tertiary_color);
343 let background = crate::css_color(theme.background);
344 let edge_label_bg = crate::css_color(theme.edge_label_background);
345 let actor_bg = crate::css_color(theme.actor_background);
346 let actor_border = crate::css_color(theme.actor_border);
347 let error_bg = {
348 let mut c = theme.error_color;
349 adjust_lightness(&mut c, theme.dark_mode);
350 c
351 };
352 let error = crate::css_color(error_bg);
353 let error_text = crate::css_color(crate::postprocess::util::text_color_for_background(
354 error_bg,
355 ));
356 let warning_bg = {
357 let mut c = theme.warning_color;
358 adjust_lightness(&mut c, theme.dark_mode);
359 c
360 };
361 let warning = crate::css_color(warning_bg);
362 let warning_text = crate::css_color(crate::postprocess::util::text_color_for_background(
363 warning_bg,
364 ));
365 let note_bg = crate::css_color(theme.note_background);
366 let note_border = crate::css_color(theme.note_border);
367 let er_odd = crate::css_color(theme.er_attr_bg_odd);
368 let er_even = crate::css_color(theme.er_attr_bg_even);
369
370 let actor_text = &text;
371 let note_text = &text;
372
373 let raw_css = format!(
374 r#"
375 text, tspan, foreignObject div, foreignObject span, foreignObject p {{ font-family: {font} !important; }}
376 foreignObject div, foreignObject span, foreignObject p {{ font-size: 16px; color: {text}; }}
377 foreignObject p {{ margin: 0; }}
378 foreignObject {{ overflow: visible; }}
379 foreignObject div {{ max-width: none !important; }}
380 .label-group foreignObject {{ font-weight: bold; }}
381 .node rect, .node path {{ fill: {primary}; stroke: {border}; }}
382 .node polygon {{ fill: {primary}; stroke: {border}; }}
383 .label-container path {{ fill: {primary}; stroke: {border}; }}
384 {mindmap_css}
385 .mindmap-node line, .timeline-node line {{ stroke: transparent !important; }}
386 g.stateGroup rect {{ fill: {primary} !important; stroke: {border} !important; }}
387 g.stateGroup text {{ fill: {text} !important; }}
388 g.stateGroup .state-title {{ fill: {text} !important; }}
389 .stateGroup .composit {{ fill: {background} !important; }}
390 .stateGroup .alt-composit {{ fill: {tertiary} !important; }}
391 .state-note {{ stroke: {note_border} !important; fill: {note_bg} !important; }}
392 .state-note text {{ fill: {note_text} !important; }}
393 .stateLabel .box {{ fill: {primary} !important; }}
394 .stateLabel text {{ fill: {text} !important; }}
395 .node circle.state-start {{ fill: {line} !important; stroke: {line} !important; }}
396 .node .fork-join {{ fill: {line} !important; stroke: {line} !important; }}
397 .node circle.state-end {{ fill: {border} !important; stroke: {background} !important; }}
398 .end-state-inner {{ fill: {background} !important; }}
399 .statediagram-cluster rect {{ fill: {primary} !important; stroke: {border} !important; }}
400 .statediagram-cluster.statediagram-cluster .inner {{ fill: {background} !important; }}
401 .statediagram-cluster.statediagram-cluster-alt .inner {{ fill: {tertiary} !important; }}
402 .statediagram-state rect.divider {{ fill: {tertiary} !important; }}
403 .statediagram-note rect {{ fill: {note_bg} !important; stroke: {note_border} !important; }}
404 .statediagram-note text {{ fill: {note_text} !important; }}
405 .statediagramTitleText {{ fill: {text} !important; }}
406 .transition {{ stroke: {line} !important; }}
407 .cluster-label, .nodeLabel {{ color: {text} !important; }}
408 defs #statediagram-barbEnd {{ fill: {line} !important; stroke: {line} !important; }}
409 #statediagram-barbEnd {{ fill: {line} !important; }}
410 .edgeLabel .label rect {{ fill: {edge_label_bg} !important; opacity: 1 !important; }}
411 .edgeLabel rect {{ fill: {edge_label_bg} !important; opacity: 1 !important; background-color: {edge_label_bg} !important; }}
412 .edgeLabel .label text {{ fill: {text} !important; }}
413 .edgeLabel p {{ background-color: {primary} !important; }}
414 .edgeLabel {{ background-color: {primary} !important; }}
415 .actor {{ stroke: {actor_border}; fill: {actor_bg}; }}
416 text.actor {{ text-anchor: middle; }}
417 text.actor>tspan {{ fill: {actor_text} !important; stroke: none; }}
418 .labelText, .labelText>tspan {{ fill: {actor_text} !important; }}
419 .actor-line {{ stroke: {actor_border} !important; }}
420 .messageLine0 {{ stroke: {text} !important; }}
421 .messageLine1 {{ stroke: {text} !important; }}
422 #arrowhead path {{ fill: {text} !important; stroke: {text} !important; }}
423 #crosshead path {{ fill: {text} !important; stroke: {text} !important; }}
424 .messageText {{ fill: {text} !important; }}
425 .loopText, .loopText>tspan {{ fill: {text} !important; }}
426 .loopLine {{ stroke: {actor_border} !important; fill: {actor_border} !important; }}
427 .note {{ stroke: {note_border} !important; fill: {note_bg} !important; }}
428 .noteText, .noteText>tspan {{ fill: {note_text} !important; }}
429 .activation0, .activation1, .activation2 {{ fill: {secondary} !important; stroke: {border} !important; }}
430 .labelBox {{ stroke: {actor_border} !important; fill: {actor_bg} !important; }}
431 .actor-man line {{ stroke: {actor_border} !important; fill: {actor_bg} !important; }}
432 .actor-man circle {{ stroke: {actor_border} !important; fill: {actor_bg} !important; }}
433 .pieTitleText {{ fill: {text} !important; }}
434 .slice {{ fill: {text} !important; }}
435 .legend text {{ fill: {text} !important; }}
436 .pieOuterCircle {{ stroke: {border} !important; }}
437 .pieCircle {{ stroke: {border} !important; }}
438 {timeline_css}
439 text.journey-section, text.task {{ fill: {text} !important; }}
440 .relationshipLabelBox {{ fill: {tertiary} !important; opacity: 0.7; background-color: {tertiary} !important; }}
441 .labelBkg {{ background-color: {tertiary} !important; }}
442 .edgeLabel .label {{ fill: {text} !important; }}
443 .label {{ color: {text} !important; }}
444 .relationshipLine {{ stroke: {line} !important; fill: none !important; }}
445 .entityBox {{ fill: {primary}; stroke: {border}; }}
446 .node .row-rect-odd path {{ fill: {er_odd} !important; }}
447 .node .row-rect-even path {{ fill: {er_even} !important; }}
448 .edge-thickness-normal {{ stroke-width: 1px; }}
449 .relation {{ stroke: {line}; stroke-width: 1; fill: none; }}
450 .edgePaths path {{ fill: none; }}
451 .marker {{ fill: {line} !important; stroke: {line} !important; }}
452 .marker.er {{ fill: none !important; stroke: {line} !important; }}
453 .composition {{ fill: {line} !important; stroke: {line} !important; stroke-width: 1; }}
454 .extension {{ fill: transparent !important; stroke: {line} !important; stroke-width: 1; }}
455 .aggregation {{ fill: transparent !important; stroke: {line} !important; stroke-width: 1; }}
456 .dependency {{ fill: {line} !important; stroke: {line} !important; stroke-width: 1; }}
457 .lollipop {{ fill: {primary} !important; stroke: {line} !important; stroke-width: 1; }}
458 .sectionTitle0, .sectionTitle1, .sectionTitle2, .sectionTitle3 {{ fill: {text} !important; }}
459 .sectionTitle {{ font-family: {font} !important; }}
460 .taskTextOutsideRight {{ fill: {text} !important; font-family: {font} !important; }}
461 .taskTextOutsideLeft {{ fill: {text} !important; }}
462 .active0, .active1, .active2, .active3 {{ fill: {secondary} !important; stroke: {border} !important; }}
463 .activeText0, .activeText1, .activeText2, .activeText3 {{ fill: {text} !important; }}
464 .done0, .done1, .done2, .done3 {{ stroke: {border} !important; fill: {secondary} !important; stroke-width: 2; }}
465 .doneText0, .doneText1, .doneText2, .doneText3 {{ fill: {text} !important; }}
466 .crit0, .crit1, .crit2, .crit3 {{ fill: {error} !important; stroke: {error} !important; }}
467 .critText0, .critText1, .critText2, .critText3 {{ fill: {error_text} !important; }}
468 .activeCrit0, .activeCrit1, .activeCrit2, .activeCrit3 {{ fill: {warning} !important; stroke: {warning} !important; }}
469 .activeCritText0, .activeCritText1, .activeCritText2, .activeCritText3 {{ fill: {warning_text} !important; }}
470 .doneCrit0, .doneCrit1, .doneCrit2, .doneCrit3 {{ fill: {error} !important; stroke: {border} !important; stroke-width: 2; }}
471 .doneCritText0, .doneCritText1, .doneCritText2, .doneCritText3 {{ fill: {error_text} !important; }}
472 .titleText {{ fill: {text} !important; font-family: {font} !important; }}
473 .grid .tick text {{ fill: {text} !important; font-family: {font} !important; }}
474 .grid .tick {{ stroke: {border} !important; }}
475 {git_branch_css}
476 .commit-merge {{ stroke: {primary}; fill: {primary}; }}
477 .commit-reverse {{ stroke: {primary}; fill: {primary}; stroke-width: 3; }}
478 .commit-highlight-inner {{ stroke: {primary}; fill: {primary}; }}
479 .tag-label {{ font-size: 10px; fill: {text}; }}
480 .tag-label-bkg {{ fill: {primary}; stroke: {border}; }}
481 .tag-hole {{ fill: {line}; }}
482 .commit-label {{ fill: {text}; }}
483 .commit-label-bkg {{ fill: {edge_label_bg}; }}
484 .commit-id, .commit-msg, .branch-label {{ fill: {text}; color: {text}; font-family: {font}; }}
485 {accent_css}
486 .data-point text {{ fill: {text} !important; }}
487 {chart_color_css}
488 "#,
489 mindmap_css = mindmap_section_css(theme),
490 git_branch_css = git_branch_css(theme),
491 accent_css = accent_css(theme),
492 chart_color_css = chart_color_css(theme),
493 timeline_css = timeline_css(theme),
494 );
495
496 scope_css(&raw_css, svg_id)
497}
498
499#[cfg(test)]
500mod tests {
501 use super::*;
502
503 #[test]
504 fn scope_css_prefixes_selectors() {
505 let input = " .foo { color: red; }\n";
506 let result = scope_css(input, "my-svg");
507 assert!(result.contains("#my-svg .foo"), "got: {result}");
508 }
509}
510