| 1 | //! Nothing-style controls: buttons, segmented controls and bars, bit chips, |
| 2 | //! pin dots, stat rows and empty states. |
| 3 | |
| 4 | use eframe::egui::{ |
| 5 | self, Align, Align2, Color32, CornerRadius, CursorIcon, FontId, Layout, Painter, Rect, |
| 6 | Response, RichText, Sense, Stroke, StrokeKind, TextWrapMode, Ui, WidgetText, pos2, vec2, |
| 7 | }; |
| 8 | |
| 9 | use crate::icons::{self, Icon}; |
| 10 | use crate::theme::{self, ACCENT, LABEL, LABEL_TRACKING, palette}; |
| 11 | |
| 12 | fn galley(ui: &Ui, text: RichText) -> std::sync::Arc<egui::Galley> { |
| 13 | WidgetText::from(text).into_galley( |
| 14 | ui, |
| 15 | Some(TextWrapMode::Extend), |
| 16 | f32::INFINITY, |
| 17 | egui::TextStyle::Small, |
| 18 | ) |
| 19 | } |
| 20 | |
| 21 | /// Square toolbar button with a monoline icon. `on` shows it latched |
| 22 | /// (inverted), e.g. while the debug session is active. |
| 23 | pub fn icon_button(ui: &mut Ui, icon: Icon, tip: &str, enabled: bool, on: bool) -> Response { |
| 24 | let p = palette(ui.ctx()); |
| 25 | let size = vec2(30.0, 28.0); |
| 26 | let sense = if enabled { |
| 27 | Sense::click() |
| 28 | } else { |
| 29 | Sense::hover() |
| 30 | }; |
| 31 | let (rect, resp) = ui.allocate_exact_size(size, sense); |
| 32 | let resp = resp.on_hover_text(tip); |
| 33 | if ui.is_rect_visible(rect) { |
| 34 | let painter = ui.painter(); |
| 35 | let radius = CornerRadius::same(4); |
| 36 | let fg = if !enabled { |
| 37 | p.text_disabled |
| 38 | } else if on { |
| 39 | p.black |
| 40 | } else if resp.hovered() { |
| 41 | p.text_display |
| 42 | } else { |
| 43 | p.text_primary |
| 44 | }; |
| 45 | if on && enabled { |
| 46 | painter.rect_filled(rect, radius, p.text_display); |
| 47 | } else if resp.hovered() { |
| 48 | painter.rect_stroke( |
| 49 | rect, |
| 50 | radius, |
| 51 | Stroke::new(1.0, p.border_visible), |
| 52 | StrokeKind::Inside, |
| 53 | ); |
| 54 | } |
| 55 | icons::paint(painter, icons::icon_rect(rect, 16.0), icon, fg); |
| 56 | } |
| 57 | if enabled { |
| 58 | resp.on_hover_cursor(CursorIcon::PointingHand) |
| 59 | } else { |
| 60 | resp |
| 61 | } |
| 62 | } |
| 63 | |
| 64 | #[derive(Clone, Copy, PartialEq, Eq)] |
| 65 | pub enum PillKind { |
| 66 | /// White pill with black text: the one action to take. |
| 67 | Primary, |
| 68 | /// Outlined pill. |
| 69 | Secondary, |
| 70 | /// Text only. |
| 71 | Ghost, |
| 72 | } |
| 73 | |
| 74 | /// Pill button: Space Mono, ALL CAPS, tracked. |
| 75 | pub fn pill(ui: &mut Ui, text: &str, kind: PillKind) -> Response { |
| 76 | let p = palette(ui.ctx()); |
| 77 | let label = RichText::new(text.to_uppercase()) |
| 78 | .font(FontId::monospace(12.0)) |
| 79 | .extra_letter_spacing(0.7); |
| 80 | let g = galley(ui, label); |
| 81 | let size = vec2(g.size().x + 36.0, 32.0); |
| 82 | let (rect, resp) = ui.allocate_exact_size(size, Sense::click()); |
| 83 | if ui.is_rect_visible(rect) { |
| 84 | let painter = ui.painter(); |
| 85 | let hovered = resp.hovered(); |
| 86 | let (fill, stroke, fg) = match kind { |
| 87 | PillKind::Primary => (p.text_display, Stroke::NONE, p.black), |
| 88 | PillKind::Secondary => ( |
| 89 | Color32::TRANSPARENT, |
| 90 | Stroke::new( |
| 91 | 1.0, |
| 92 | if hovered { |
| 93 | p.text_primary |
| 94 | } else { |
| 95 | p.border_visible |
| 96 | }, |
| 97 | ), |
| 98 | p.text_primary, |
| 99 | ), |
| 100 | PillKind::Ghost => ( |
| 101 | Color32::TRANSPARENT, |
| 102 | Stroke::NONE, |
| 103 | if hovered { |
| 104 | p.text_display |
| 105 | } else { |
| 106 | p.text_secondary |
| 107 | }, |
| 108 | ), |
| 109 | }; |
| 110 | painter.rect( |
| 111 | rect, |
| 112 | CornerRadius::same(255), |
| 113 | fill, |
| 114 | stroke, |
| 115 | StrokeKind::Inside, |
| 116 | ); |
| 117 | let pos = Align2::CENTER_CENTER |
| 118 | .align_size_within_rect(g.size(), rect) |
| 119 | .min; |
| 120 | painter.galley(pos, g, fg); |
| 121 | } |
| 122 | resp.on_hover_cursor(CursorIcon::PointingHand) |
| 123 | } |
| 124 | |
| 125 | /// Segmented control: the active segment is inverted. Returns true if the |
| 126 | /// value changed. |
| 127 | pub fn segmented<T: PartialEq + Copy>(ui: &mut Ui, value: &mut T, options: &[(T, &str)]) -> bool { |
| 128 | let p = palette(ui.ctx()); |
| 129 | let galleys: Vec<_> = options |
| 130 | .iter() |
| 131 | .map(|(_, l)| galley(ui, p.label(l))) |
| 132 | .collect(); |
| 133 | let pad = 10.0; |
| 134 | let widths: Vec<f32> = galleys.iter().map(|g| g.size().x + 2.0 * pad).collect(); |
| 135 | let size = vec2(widths.iter().sum(), 24.0); |
| 136 | let (rect, _) = ui.allocate_exact_size(size, Sense::hover()); |
| 137 | let radius = CornerRadius::same(4); |
| 138 | let mut changed = false; |
| 139 | let mut x = rect.left(); |
| 140 | for (i, ((v, label), g)) in options.iter().zip(galleys).enumerate() { |
| 141 | let seg = Rect::from_min_size(pos2(x, rect.top()), vec2(widths[i], rect.height())); |
| 142 | x += widths[i]; |
| 143 | let resp = ui |
| 144 | .interact(seg, ui.auto_id_with(("seg", label, i)), Sense::click()) |
| 145 | .on_hover_cursor(CursorIcon::PointingHand); |
| 146 | let active = *value == *v; |
| 147 | if resp.clicked() && !active { |
| 148 | *value = *v; |
| 149 | changed = true; |
| 150 | } |
| 151 | let fg = if active { |
| 152 | ui.painter().rect_filled(seg, radius, p.text_display); |
| 153 | p.black |
| 154 | } else if resp.hovered() { |
| 155 | p.text_display |
| 156 | } else { |
| 157 | p.text_secondary |
| 158 | }; |
| 159 | let pos = Align2::CENTER_CENTER |
| 160 | .align_size_within_rect(g.size(), seg) |
| 161 | .min; |
| 162 | ui.painter().galley(pos, g, fg); |
| 163 | } |
| 164 | ui.painter().rect_stroke( |
| 165 | rect, |
| 166 | radius, |
| 167 | Stroke::new(1.0, p.border_visible), |
| 168 | StrokeKind::Inside, |
| 169 | ); |
| 170 | changed |
| 171 | } |
| 172 | |
| 173 | /// Segmented bar: `filled` of `segments` square blocks with 2 px gaps. |
| 174 | /// Returns the index of a clicked segment. |
| 175 | pub fn segment_bar( |
| 176 | ui: &mut Ui, |
| 177 | width: f32, |
| 178 | height: f32, |
| 179 | segments: usize, |
| 180 | filled: usize, |
| 181 | color: Color32, |
| 182 | clickable: bool, |
| 183 | ) -> Option<usize> { |
| 184 | let p = palette(ui.ctx()); |
| 185 | let sense = if clickable { |
| 186 | Sense::click() |
| 187 | } else { |
| 188 | Sense::hover() |
| 189 | }; |
| 190 | let (rect, resp) = ui.allocate_exact_size(vec2(width, height), sense); |
| 191 | let gap = 2.0; |
| 192 | let seg_w = (width - gap * (segments as f32 - 1.0)) / segments as f32; |
| 193 | let hover_idx = resp |
| 194 | .hover_pos() |
| 195 | .filter(|_| clickable) |
| 196 | .map(|pos| (((pos.x - rect.left()) / (seg_w + gap)) as usize).min(segments - 1)); |
| 197 | let empty = if ui.visuals().dark_mode { |
| 198 | p.border |
| 199 | } else { |
| 200 | Color32::from_gray(0xE0) |
| 201 | }; |
| 202 | for i in 0..segments { |
| 203 | let x = rect.left() + i as f32 * (seg_w + gap); |
| 204 | let r = Rect::from_min_size(pos2(x, rect.top()), vec2(seg_w, height)); |
| 205 | let c = if i < filled { |
| 206 | color |
| 207 | } else if hover_idx.is_some_and(|h| i <= h) { |
| 208 | p.text_disabled |
| 209 | } else { |
| 210 | empty |
| 211 | }; |
| 212 | ui.painter().rect_filled(r, 0.0, c); |
| 213 | } |
| 214 | if clickable && resp.clicked() { |
| 215 | resp.on_hover_cursor(CursorIcon::PointingHand); |
| 216 | return hover_idx; |
| 217 | } |
| 218 | if clickable { |
| 219 | resp.on_hover_cursor(CursorIcon::PointingHand); |
| 220 | } |
| 221 | None |
| 222 | } |
| 223 | |
| 224 | /// Bit flag as a technical tag: lit when set. Clicking toggles it. |
| 225 | pub fn bit_chip(ui: &mut Ui, name: &str, on: bool) -> Response { |
| 226 | let p = palette(ui.ctx()); |
| 227 | let g = galley( |
| 228 | ui, |
| 229 | RichText::new(name) |
| 230 | .font(FontId::monospace(LABEL)) |
| 231 | .extra_letter_spacing(LABEL_TRACKING * 0.5), |
| 232 | ); |
| 233 | let size = vec2(g.size().x + 12.0, 20.0); |
| 234 | let (rect, resp) = ui.allocate_exact_size(size, Sense::click()); |
| 235 | if ui.is_rect_visible(rect) { |
| 236 | let hovered = resp.hovered(); |
| 237 | let (stroke, fg) = if on { |
| 238 | (p.text_display, p.text_display) |
| 239 | } else if hovered { |
| 240 | (p.text_secondary, p.text_secondary) |
| 241 | } else { |
| 242 | (p.border_visible, p.text_disabled) |
| 243 | }; |
| 244 | ui.painter().rect_stroke( |
| 245 | rect, |
| 246 | CornerRadius::same(4), |
| 247 | Stroke::new(1.0, stroke), |
| 248 | StrokeKind::Inside, |
| 249 | ); |
| 250 | let pos = Align2::CENTER_CENTER |
| 251 | .align_size_within_rect(g.size(), rect) |
| 252 | .min; |
| 253 | ui.painter().galley(pos, g, fg); |
| 254 | } |
| 255 | resp.on_hover_cursor(CursorIcon::PointingHand) |
| 256 | } |
| 257 | |
| 258 | #[derive(Clone, Copy, PartialEq, Eq)] |
| 259 | pub enum Dot { |
| 260 | High, |
| 261 | Low, |
| 262 | /// Held low from outside the chip, like a pressed button. |
| 263 | PulledLow, |
| 264 | } |
| 265 | |
| 266 | /// A port pin as an LED-like dot. |
| 267 | pub fn pin_dot(ui: &mut Ui, state: Dot) -> Response { |
| 268 | let p = palette(ui.ctx()); |
| 269 | let (rect, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click()); |
| 270 | if ui.is_rect_visible(rect) { |
| 271 | let c = rect.center(); |
| 272 | let r = 5.0; |
| 273 | match state { |
| 274 | Dot::High => { |
| 275 | ui.painter().circle_filled(c, r, p.text_display); |
| 276 | } |
| 277 | Dot::PulledLow => { |
| 278 | ui.painter().circle_filled(c, r, ACCENT); |
| 279 | } |
| 280 | Dot::Low => { |
| 281 | ui.painter() |
| 282 | .circle_stroke(c, r - 0.5, Stroke::new(1.5, p.text_disabled)); |
| 283 | } |
| 284 | } |
| 285 | if resp.hovered() { |
| 286 | ui.painter() |
| 287 | .circle_stroke(c, r + 3.0, Stroke::new(1.0, p.border_visible)); |
| 288 | } |
| 289 | } |
| 290 | resp.on_hover_cursor(CursorIcon::PointingHand) |
| 291 | } |
| 292 | |
| 293 | /// Label on the left, value on the right of the available width. |
| 294 | pub fn stat_row(ui: &mut Ui, label: &str, value: RichText) { |
| 295 | let p = palette(ui.ctx()); |
| 296 | ui.horizontal(|ui| { |
| 297 | ui.label(p.label(label)); |
| 298 | ui.with_layout(Layout::right_to_left(Align::Center), |ui| { |
| 299 | ui.label(value); |
| 300 | }); |
| 301 | }); |
| 302 | } |
| 303 | |
| 304 | /// Uniform dot grid, the dot-matrix motif for empty backgrounds. |
| 305 | pub fn dot_grid(painter: &Painter, rect: Rect, color: Color32, pitch: f32) { |
| 306 | let x0 = (rect.left() / pitch).ceil() * pitch; |
| 307 | let y0 = (rect.top() / pitch).ceil() * pitch; |
| 308 | let mut y = y0; |
| 309 | while y < rect.bottom() { |
| 310 | let mut x = x0; |
| 311 | while x < rect.right() { |
| 312 | painter.circle_filled(pos2(x, y), 0.9, color); |
| 313 | x += pitch; |
| 314 | } |
| 315 | y += pitch; |
| 316 | } |
| 317 | } |
| 318 | |
| 319 | /// Centred two-line empty state on a faint dot grid. |
| 320 | pub fn empty_state(ui: &mut Ui, headline: &str, hint: &str) { |
| 321 | let p = palette(ui.ctx()); |
| 322 | let rect = ui.available_rect_before_wrap(); |
| 323 | let grid = if ui.visuals().dark_mode { |
| 324 | p.border |
| 325 | } else { |
| 326 | p.border_visible.gamma_multiply(0.6) |
| 327 | }; |
| 328 | dot_grid(ui.painter(), rect, grid, 16.0); |
| 329 | ui.scope_builder( |
| 330 | egui::UiBuilder::new() |
| 331 | .max_rect(rect) |
| 332 | .layout(Layout::top_down(Align::Center)), |
| 333 | |ui| { |
| 334 | ui.add_space((rect.height() / 2.0 - 24.0).max(8.0)); |
| 335 | ui.label(p.label(headline).color(p.text_secondary)); |
| 336 | ui.add_space(6.0); |
| 337 | ui.label( |
| 338 | RichText::new(hint) |
| 339 | .size(theme::BODY - 1.0) |
| 340 | .color(p.text_disabled), |
| 341 | ); |
| 342 | }, |
| 343 | ); |
| 344 | } |
| 345 | |
| 346 | /// ALL CAPS section heading with breathing room above. |
| 347 | pub fn section(ui: &mut Ui, title: &str) { |
| 348 | let p = palette(ui.ctx()); |
| 349 | ui.add_space(12.0); |
| 350 | ui.label(p.label(title).color(p.text_disabled)); |
| 351 | ui.add_space(2.0); |
| 352 | } |
| 353 | |
| 354 | /// Underlined single-line input in Space Mono. |
| 355 | pub fn underline_input(ui: &mut Ui, text: &mut String, hint: &str, width: f32) -> Response { |
| 356 | let p = palette(ui.ctx()); |
| 357 | let out = egui::TextEdit::singleline(text) |
| 358 | .font(FontId::monospace(13.0)) |
| 359 | .hint_text( |
| 360 | RichText::new(hint) |
| 361 | .font(FontId::monospace(13.0)) |
| 362 | .color(p.text_disabled), |
| 363 | ) |
| 364 | .frame(egui::Frame::NONE) |
| 365 | .margin(egui::Margin::symmetric(0, 3)) |
| 366 | .desired_width(width) |
| 367 | .show(ui); |
| 368 | let resp = out.response.response; |
| 369 | let color = if resp.has_focus() { |
| 370 | p.text_primary |
| 371 | } else { |
| 372 | p.border_visible |
| 373 | }; |
| 374 | let r = resp.rect; |
| 375 | ui.painter() |
| 376 | .hline(r.x_range(), r.bottom(), Stroke::new(1.0, color)); |
| 377 | resp |
| 378 | } |