Skip to main content

liora_components/
signal_meter.rs

1use crate::gpui_compat::PixelsExt;
2use gpui::{
3    App, Background, BorderStyle, Bounds, Component, Corners, Edges, Hsla, IntoElement, Pixels,
4    RenderOnce, Window, point, prelude::*, px, quad, size,
5};
6use liora_core::Config;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
9pub enum SignalMeterKind {
10    #[default]
11    Mobile,
12    Wifi,
13}
14
15#[derive(Clone, Copy, Debug, PartialEq)]
16pub struct SignalLevelColor {
17    pub level: usize,
18    pub color: Hsla,
19}
20
21impl SignalLevelColor {
22    pub fn new(level: usize, color: Hsla) -> Self {
23        Self { level, color }
24    }
25}
26
27#[derive(Clone)]
28pub struct SignalMeter {
29    level: usize,
30    max_level: usize,
31    kind: SignalMeterKind,
32    active_color: Option<Hsla>,
33    inactive_color: Option<Hsla>,
34    level_colors: Vec<Hsla>,
35    threshold_colors: Vec<SignalLevelColor>,
36    bar_width: Pixels,
37    gap: Pixels,
38    height: Pixels,
39}
40
41impl SignalMeter {
42    pub fn new(level: usize) -> Self {
43        Self {
44            level,
45            max_level: 4,
46            kind: SignalMeterKind::Mobile,
47            active_color: None,
48            inactive_color: None,
49            level_colors: Vec::new(),
50            threshold_colors: Vec::new(),
51            bar_width: px(6.0),
52            gap: px(4.0),
53            height: px(32.0),
54        }
55    }
56    pub fn max_level(mut self, max_level: usize) -> Self {
57        self.max_level = max_level.max(1);
58        self.level = self.level.min(self.max_level);
59        self
60    }
61
62    pub fn total_signals(self, total: usize) -> Self {
63        self.max_level(total)
64    }
65
66    pub fn signal_count(self, count: usize) -> Self {
67        self.max_level(count)
68    }
69    pub fn wifi(mut self) -> Self {
70        self.kind = SignalMeterKind::Wifi;
71        self
72    }
73    pub fn mobile(mut self) -> Self {
74        self.kind = SignalMeterKind::Mobile;
75        self
76    }
77    pub fn active_color(mut self, color: Hsla) -> Self {
78        self.active_color = Some(color);
79        self
80    }
81    pub fn inactive_color(mut self, color: Hsla) -> Self {
82        self.inactive_color = Some(color);
83        self
84    }
85
86    pub fn level_colors(mut self, colors: impl IntoIterator<Item = Hsla>) -> Self {
87        self.level_colors = colors.into_iter().collect();
88        self
89    }
90
91    pub fn signal_colors(self, colors: impl IntoIterator<Item = Hsla>) -> Self {
92        self.level_colors(colors)
93    }
94
95    pub fn threshold_colors(mut self, colors: impl IntoIterator<Item = SignalLevelColor>) -> Self {
96        self.threshold_colors = colors.into_iter().collect();
97        self.threshold_colors
98            .sort_by_key(|threshold| threshold.level);
99        self
100    }
101
102    pub fn level_threshold_colors(
103        self,
104        colors: impl IntoIterator<Item = SignalLevelColor>,
105    ) -> Self {
106        self.threshold_colors(colors)
107    }
108
109    pub fn level_color(mut self, level: usize, color: Hsla) -> Self {
110        self.threshold_colors
111            .push(SignalLevelColor::new(level, color));
112        self.threshold_colors
113            .sort_by_key(|threshold| threshold.level);
114        self
115    }
116    pub fn bar_width(mut self, width: impl Into<Pixels>) -> Self {
117        self.bar_width = width.into().max(px(2.0));
118        self
119    }
120    pub fn gap(mut self, gap: impl Into<Pixels>) -> Self {
121        self.gap = gap.into().max(px(0.0));
122        self
123    }
124    pub fn height(mut self, height: impl Into<Pixels>) -> Self {
125        self.height = height.into().max(px(12.0));
126        self
127    }
128}
129
130impl RenderOnce for SignalMeter {
131    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
132        let theme = cx.global::<Config>().theme.clone();
133        let active = self.active_color.unwrap_or(theme.success.base);
134        let inactive = self
135            .inactive_color
136            .unwrap_or(theme.neutral.border.opacity(0.55));
137        let total_width = self.bar_width * self.max_level as f32
138            + self.gap * self.max_level.saturating_sub(1) as f32;
139        let max_level = self.max_level;
140        let level = self.level.min(max_level);
141        let kind = self.kind;
142        let bar_width = self.bar_width;
143        let gap = self.gap;
144        let height = self.height;
145        let level_colors = self.level_colors.clone();
146        let threshold_color = self
147            .threshold_colors
148            .iter()
149            .filter(|threshold| level >= threshold.level)
150            .map(|threshold| threshold.color)
151            .last();
152        gpui::canvas(
153            |_, _, _| (),
154            move |bounds, _, window, _| {
155                for index in 0..max_level {
156                    let ratio = (index + 1) as f32 / max_level as f32;
157                    let bar_h = match kind {
158                        SignalMeterKind::Mobile => height.as_f32() * (0.28 + ratio * 0.72),
159                        SignalMeterKind::Wifi => height.as_f32() * ratio,
160                    };
161                    let x = bounds.left() + (bar_width + gap) * index as f32;
162                    let y = bounds.bottom() - px(bar_h);
163                    let color = if index < level {
164                        threshold_color
165                            .or_else(|| level_colors.get(index).copied())
166                            .unwrap_or(active)
167                    } else {
168                        inactive
169                    };
170                    let rect = Bounds::new(point(x, y), size(bar_width, px(bar_h)));
171                    window.paint_quad(quad(
172                        rect,
173                        Corners::all(bar_width / 2.0).clamp_radii_for_quad_size(rect.size),
174                        Background::from(color),
175                        Edges::all(px(0.0)),
176                        gpui::transparent_black(),
177                        BorderStyle::Solid,
178                    ));
179                }
180            },
181        )
182        .w(total_width)
183        .h(self.height)
184    }
185}
186
187impl IntoElement for SignalMeter {
188    type Element = Component<Self>;
189    fn into_element(self) -> Self::Element {
190        Component::new(self)
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    #[test]
198    fn signal_meter_clamps_levels() {
199        let meter = SignalMeter::new(9)
200            .max_level(5)
201            .total_signals(6)
202            .wifi()
203            .bar_width(px(8.0))
204            .gap(px(3.0))
205            .height(px(24.0))
206            .level_colors([gpui::red(), gpui::yellow(), gpui::green()])
207            .threshold_colors([
208                SignalLevelColor::new(2, gpui::red()),
209                SignalLevelColor::new(3, gpui::yellow()),
210                SignalLevelColor::new(5, gpui::green()),
211            ]);
212        assert_eq!(meter.level, 5);
213        assert_eq!(meter.max_level, 6);
214        assert_eq!(meter.kind, SignalMeterKind::Wifi);
215        assert_eq!(meter.bar_width, px(8.0));
216        assert_eq!(meter.level_colors.len(), 3);
217        assert_eq!(meter.threshold_colors.len(), 3);
218    }
219
220    #[test]
221    fn signal_meter_threshold_colors_sort_by_level() {
222        let meter = SignalMeter::new(4)
223            .total_signals(5)
224            .level_color(5, gpui::green())
225            .level_color(2, gpui::red())
226            .level_color(3, gpui::yellow());
227        let levels = meter
228            .threshold_colors
229            .iter()
230            .map(|threshold| threshold.level)
231            .collect::<Vec<_>>();
232        assert_eq!(levels, vec![2, 3, 5]);
233    }
234}