badge.rs (10369B)
1 use egui::{Color32, Response, Ui, Vec2}; 2 3 /// Badge variants that determine the color scheme 4 #[derive(Clone, Copy, Default)] 5 #[allow(dead_code)] 6 pub enum BadgeVariant { 7 /// Default muted style 8 #[default] 9 Default, 10 /// Informational blue 11 Info, 12 /// Success green 13 Success, 14 /// Warning amber/yellow 15 Warning, 16 /// Error/danger red 17 Destructive, 18 } 19 20 impl BadgeVariant { 21 /// Get background and text colors for this variant 22 fn colors(&self, ui: &Ui) -> (Color32, Color32) { 23 let is_dark = ui.visuals().dark_mode; 24 25 match self { 26 BadgeVariant::Default => { 27 let bg = if is_dark { 28 Color32::from_rgba_unmultiplied(255, 255, 255, 20) 29 } else { 30 Color32::from_rgba_unmultiplied(0, 0, 0, 15) 31 }; 32 let fg = ui.visuals().text_color(); 33 (bg, fg) 34 } 35 BadgeVariant::Info => { 36 // Blue tones 37 let bg = if is_dark { 38 Color32::from_rgba_unmultiplied(59, 130, 246, 30) 39 } else { 40 Color32::from_rgba_unmultiplied(59, 130, 246, 25) 41 }; 42 let fg = if is_dark { 43 Color32::from_rgb(147, 197, 253) // blue-300 44 } else { 45 Color32::from_rgb(29, 78, 216) // blue-700 46 }; 47 (bg, fg) 48 } 49 BadgeVariant::Success => { 50 // Green tones 51 let bg = if is_dark { 52 Color32::from_rgba_unmultiplied(34, 197, 94, 30) 53 } else { 54 Color32::from_rgba_unmultiplied(34, 197, 94, 25) 55 }; 56 let fg = if is_dark { 57 Color32::from_rgb(134, 239, 172) // green-300 58 } else { 59 Color32::from_rgb(21, 128, 61) // green-700 60 }; 61 (bg, fg) 62 } 63 BadgeVariant::Warning => { 64 // Amber/yellow tones 65 let bg = if is_dark { 66 Color32::from_rgba_unmultiplied(245, 158, 11, 30) 67 } else { 68 Color32::from_rgba_unmultiplied(245, 158, 11, 25) 69 }; 70 let fg = if is_dark { 71 Color32::from_rgb(252, 211, 77) // amber-300 72 } else { 73 Color32::from_rgb(180, 83, 9) // amber-700 74 }; 75 (bg, fg) 76 } 77 BadgeVariant::Destructive => { 78 // Red tones 79 let bg = if is_dark { 80 Color32::from_rgba_unmultiplied(239, 68, 68, 30) 81 } else { 82 Color32::from_rgba_unmultiplied(239, 68, 68, 25) 83 }; 84 let fg = if is_dark { 85 Color32::from_rgb(252, 165, 165) // red-300 86 } else { 87 Color32::from_rgb(185, 28, 28) // red-700 88 }; 89 (bg, fg) 90 } 91 } 92 } 93 } 94 95 /// A pill-shaped status badge widget (shadcn-style) 96 pub struct StatusBadge<'a> { 97 text: &'a str, 98 variant: BadgeVariant, 99 keybind: Option<&'a str>, 100 } 101 102 impl<'a> StatusBadge<'a> { 103 /// Create a new status badge with the given text 104 pub fn new(text: &'a str) -> Self { 105 Self { 106 text, 107 variant: BadgeVariant::Default, 108 keybind: None, 109 } 110 } 111 112 /// Set the badge variant 113 pub fn variant(mut self, variant: BadgeVariant) -> Self { 114 self.variant = variant; 115 self 116 } 117 118 /// Add a keybind hint inside the badge (e.g., "P" for Ctrl+P) 119 pub fn keybind(mut self, key: &'a str) -> Self { 120 self.keybind = Some(key); 121 self 122 } 123 124 /// Show the badge and return the response 125 pub fn show(self, ui: &mut Ui) -> Response { 126 let (bg_color, text_color) = self.variant.colors(ui); 127 128 // Calculate text size for proper allocation 129 let font_id = egui::FontId::proportional(11.0); 130 let galley = 131 ui.painter() 132 .layout_no_wrap(self.text.to_string(), font_id.clone(), text_color); 133 134 // Calculate keybind box size if present 135 let keybind_box_size = 14.0; 136 let keybind_spacing = 5.0; 137 let keybind_extra = if self.keybind.is_some() { 138 keybind_box_size + keybind_spacing 139 } else { 140 0.0 141 }; 142 143 // Padding: horizontal 8px, vertical 2px 144 let padding = Vec2::new(8.0, 3.0); 145 let desired_size = 146 Vec2::new(galley.size().x + keybind_extra, galley.size().y) + padding * 2.0; 147 148 let (rect, response) = ui.allocate_exact_size(desired_size, egui::Sense::hover()); 149 150 if ui.is_rect_visible(rect) { 151 let painter = ui.painter(); 152 153 // Full pill rounding (half of height) 154 let rounding = rect.height() / 2.0; 155 156 // Background 157 painter.rect_filled(rect, rounding, bg_color); 158 159 // Text (offset left if keybind present) 160 let text_offset_x = if self.keybind.is_some() { 161 -keybind_extra / 2.0 162 } else { 163 0.0 164 }; 165 let text_pos = rect.center() + Vec2::new(text_offset_x, 0.0) - galley.size() / 2.0; 166 painter.galley(text_pos, galley, text_color); 167 168 // Draw keybind box if present 169 if let Some(key) = self.keybind { 170 let box_center = egui::pos2( 171 rect.right() - padding.x - keybind_box_size / 2.0, 172 rect.center().y, 173 ); 174 let box_rect = 175 egui::Rect::from_center_size(box_center, Vec2::splat(keybind_box_size)); 176 177 // Keybind box background (slightly darker/lighter than badge bg) 178 let visuals = ui.visuals(); 179 let box_bg = visuals.widgets.noninteractive.bg_fill; 180 let box_stroke = text_color.gamma_multiply(0.5); 181 182 painter.rect_filled(box_rect, 3.0, box_bg); 183 painter.rect_stroke( 184 box_rect, 185 3.0, 186 egui::Stroke::new(1.0, box_stroke), 187 egui::StrokeKind::Inside, 188 ); 189 190 // Keybind text 191 painter.text( 192 box_center + Vec2::new(0.0, 1.0), 193 egui::Align2::CENTER_CENTER, 194 key, 195 egui::FontId::monospace(keybind_box_size * 0.65), 196 visuals.text_color(), 197 ); 198 } 199 } 200 201 response 202 } 203 } 204 205 /// A pill-shaped action button with integrated keybind hint 206 pub struct ActionButton<'a> { 207 text: &'a str, 208 bg_color: Color32, 209 text_color: Color32, 210 keybind: Option<&'a str>, 211 } 212 213 impl<'a> ActionButton<'a> { 214 /// Create a new action button with the given text and colors 215 pub fn new(text: &'a str, bg_color: Color32, text_color: Color32) -> Self { 216 Self { 217 text, 218 bg_color, 219 text_color, 220 keybind: None, 221 } 222 } 223 224 /// Add a keybind hint inside the button (e.g., "1" for key 1) 225 pub fn keybind(mut self, key: &'a str) -> Self { 226 self.keybind = Some(key); 227 self 228 } 229 230 /// Show the button and return the response 231 pub fn show(self, ui: &mut Ui) -> Response { 232 // Calculate text size for proper allocation 233 let font_id = egui::FontId::proportional(13.0); 234 let galley = 235 ui.painter() 236 .layout_no_wrap(self.text.to_string(), font_id.clone(), self.text_color); 237 238 // Calculate keybind box size if present 239 let keybind_box_size = 16.0; 240 let keybind_spacing = 6.0; 241 let keybind_extra = if self.keybind.is_some() { 242 keybind_box_size + keybind_spacing 243 } else { 244 0.0 245 }; 246 247 // Padding: horizontal 10px, vertical 4px 248 let padding = Vec2::new(10.0, 4.0); 249 let desired_size = 250 Vec2::new(galley.size().x + keybind_extra, galley.size().y) + padding * 2.0; 251 252 let (rect, response) = ui.allocate_exact_size(desired_size, egui::Sense::click()); 253 254 if ui.is_rect_visible(rect) { 255 let painter = ui.painter(); 256 257 // Adjust color based on hover/click state 258 let bg_color = if response.is_pointer_button_down_on() { 259 self.bg_color.gamma_multiply(0.8) 260 } else if response.hovered() { 261 self.bg_color.gamma_multiply(1.15) 262 } else { 263 self.bg_color 264 }; 265 266 // Full pill rounding (half of height) 267 let rounding = rect.height() / 2.0; 268 269 // Background 270 painter.rect_filled(rect, rounding, bg_color); 271 272 // Text (offset right if keybind present, since keybind goes on left) 273 let text_offset_x = if self.keybind.is_some() { 274 keybind_extra / 2.0 275 } else { 276 0.0 277 }; 278 let text_pos = rect.center() + Vec2::new(text_offset_x, 0.0) - galley.size() / 2.0; 279 painter.galley(text_pos, galley, self.text_color); 280 281 // Draw keybind hint on left side (white border, no fill) 282 if let Some(key) = self.keybind { 283 let box_center = egui::pos2( 284 rect.left() + padding.x + keybind_box_size / 2.0, 285 rect.center().y, 286 ); 287 let box_rect = 288 egui::Rect::from_center_size(box_center, Vec2::splat(keybind_box_size)); 289 290 // White border only 291 painter.rect_stroke( 292 box_rect, 293 3.0, 294 egui::Stroke::new(1.0, Color32::WHITE), 295 egui::StrokeKind::Inside, 296 ); 297 298 // Keybind text with vertical nudge for optical centering 299 painter.text( 300 box_center + Vec2::new(0.0, 1.0), 301 egui::Align2::CENTER_CENTER, 302 key, 303 egui::FontId::monospace(keybind_box_size * 0.7), 304 self.text_color, 305 ); 306 } 307 } 308 309 response 310 } 311 }