Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T04:45:23.809Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

inject_css.rs

510 lines · 21.8 KB · rust
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
Served at tenant.openagents/omega Member data and write actions are omitted.