Skip to main content

logicaffeine_web/ui/components/
status_bar.rs

1//! The Studio's status bar, in VS Code's manner: a strip along the bottom edge, under a line,
2//! saying how many problems the language service found, where the editor's caret is and what
3//! is selected, and which language the editor holds.
4
5use dioxus::prelude::*;
6
7use super::problems::{Problem, Severity};
8
9/// What the Studio's editor tells the status bar.
10#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
11pub struct EditorStatus {
12    pub caret: Caret,
13    pub problems: ProblemCount,
14}
15
16/// How many errors and warnings the language service found.
17#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
18pub struct ProblemCount {
19    pub errors: usize,
20    pub warnings: usize,
21}
22
23impl ProblemCount {
24    /// The errors and warnings among `problems`; hints and notes are not counted.
25    pub fn of(problems: &[Problem]) -> ProblemCount {
26        let count = |severity: Severity| problems.iter().filter(|p| p.severity == severity).count();
27        ProblemCount { errors: count(Severity::Error), warnings: count(Severity::Warning) }
28    }
29}
30
31/// Where the caret sits — its line and column, both counted from 1, the column in characters —
32/// and how many characters the selection spans.
33#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34pub struct Caret {
35    pub line: usize,
36    pub column: usize,
37    pub selected: usize,
38}
39
40impl Default for Caret {
41    fn default() -> Self {
42        Caret { line: 1, column: 1, selected: 0 }
43    }
44}
45
46impl Caret {
47    /// The caret in `text` for a selection from `start` to `end`, offsets in UTF-16 code units
48    /// as the browser counts them. The caret is the selection's moving end: `start` when the
49    /// selection was made backwards, `end` otherwise.
50    pub fn of(text: &str, start: usize, end: usize, backward: bool) -> Caret {
51        let at = if backward { start } else { end };
52        let mut caret = Caret::default();
53        let mut offset = 0;
54        for c in text.chars() {
55            if offset >= at.max(end) {
56                break;
57            }
58            if offset < at {
59                if c == '\n' {
60                    caret.line += 1;
61                    caret.column = 1;
62                } else {
63                    caret.column += 1;
64                }
65            }
66            if offset >= start && offset < end {
67                caret.selected += 1;
68            }
69            offset += c.len_utf16();
70        }
71        caret
72    }
73
74    /// "Ln 3, Col 4", with "(2 selected)" after it while text is selected.
75    pub fn label(&self) -> String {
76        let place = format!("Ln {}, Col {}", self.line, self.column);
77        if self.selected == 0 {
78            place
79        } else {
80            format!("{place} ({} selected)", self.selected)
81        }
82    }
83}
84
85pub const STATUS_BAR_STYLE: &str = r#"
86.studio-statusbar {
87    flex: none;
88    box-sizing: border-box;
89    height: 25px;
90    display: flex;
91    align-items: center;
92    justify-content: space-between;
93    gap: 16px;
94    padding: 0 12px;
95    border-top: 2px solid var(--studio-resizer);
96    background: var(--studio-panel-bg);
97    color: var(--studio-text-secondary);
98    font: 12px/1 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
99    white-space: nowrap;
100    overflow: hidden;
101    user-select: none;
102}
103.statusbar-side {
104    display: flex;
105    align-items: center;
106    gap: 16px;
107    min-width: 0;
108}
109.statusbar-item {
110    display: inline-flex;
111    align-items: center;
112    gap: 4px;
113}
114.statusbar-count {
115    display: inline-flex;
116    align-items: center;
117    gap: 3px;
118}
119.statusbar-count svg {
120    width: 13px;
121    height: 13px;
122    flex: none;
123}
124.statusbar-count.errors svg { color: #f14c4c; }
125.statusbar-count.warnings svg { color: #cca700; }
126"#;
127
128/// VS Code's error mark: a circle with a cross.
129const ERROR_MARK: &str = r#"<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" aria-hidden="true"><circle cx="8" cy="8" r="6.3"/><path d="M5.6 5.6l4.8 4.8M10.4 5.6l-4.8 4.8"/></svg>"#;
130/// VS Code's warning mark: a triangle with an exclamation.
131const WARNING_MARK: &str = r#"<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" aria-hidden="true"><path d="M8 2.2l6.3 11.3H1.7z"/><path d="M8 6.4v3.4M8 11.6v.2"/></svg>"#;
132
133/// The strip itself. It reads `status` on its own, so a caret move redraws the strip and
134/// nothing else. `indent` is the editor's indentation step in spaces. `problems` is false for an
135/// editor no language service reads, where a count of none would claim a check that never ran.
136#[component]
137pub fn StatusBar(status: Signal<EditorStatus>, language: String, indent: usize, problems: bool) -> Element {
138    let status = status();
139    let ProblemCount { errors, warnings } = status.problems;
140    let position = status.caret.label();
141    rsx! {
142        style { "{STATUS_BAR_STYLE}" }
143        footer { class: "studio-statusbar", role: "status",
144            div { class: "statusbar-side",
145                if problems {
146                    span {
147                        class: "statusbar-item",
148                        "data-status": "problems",
149                        "data-errors": "{errors}",
150                        "data-warnings": "{warnings}",
151                        title: "{errors} errors, {warnings} warnings",
152                        span { class: "statusbar-count errors", span { dangerous_inner_html: ERROR_MARK } "{errors}" }
153                        span { class: "statusbar-count warnings", span { dangerous_inner_html: WARNING_MARK } "{warnings}" }
154                    }
155                }
156            }
157            div { class: "statusbar-side",
158                span { class: "statusbar-item", "data-status": "position", "{position}" }
159                span { class: "statusbar-item", "data-status": "indent", "Spaces: {indent}" }
160                span { class: "statusbar-item", "data-status": "language", "{language}" }
161            }
162        }
163    }
164}
165
166#[cfg(test)]
167#[path = "../../../../../crates/logicaffeine_tests/unit/logicaffeine_web/ui/components/status_bar/tests.rs"]
168mod tests;