badge.rs (10703B)
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 pub 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::click()); 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 // Adjust background color based on hover/click state 157 let bg_color = if response.is_pointer_button_down_on() { 158 bg_color.gamma_multiply(1.8) 159 } else if response.hovered() { 160 bg_color.gamma_multiply(1.4) 161 } else { 162 bg_color 163 }; 164 165 // Background 166 painter.rect_filled(rect, rounding, bg_color); 167 168 // Text (offset left if keybind present) 169 let text_offset_x = if self.keybind.is_some() { 170 -keybind_extra / 2.0 171 } else { 172 0.0 173 }; 174 let text_pos = rect.center() + Vec2::new(text_offset_x, 0.0) - galley.size() / 2.0; 175 painter.galley(text_pos, galley, text_color); 176 177 // Draw keybind box if present 178 if let Some(key) = self.keybind { 179 let box_center = egui::pos2( 180 rect.right() - padding.x - keybind_box_size / 2.0, 181 rect.center().y, 182 ); 183 let box_rect = 184 egui::Rect::from_center_size(box_center, Vec2::splat(keybind_box_size)); 185 186 // Keybind box background (slightly darker/lighter than badge bg) 187 let visuals = ui.visuals(); 188 let box_bg = visuals.widgets.noninteractive.bg_fill; 189 let box_stroke = text_color.gamma_multiply(0.5); 190 191 painter.rect_filled(box_rect, 3.0, box_bg); 192 painter.rect_stroke( 193 box_rect, 194 3.0, 195 egui::Stroke::new(1.0, box_stroke), 196 egui::StrokeKind::Inside, 197 ); 198 199 // Keybind text 200 painter.text( 201 box_center + Vec2::new(0.0, 1.0), 202 egui::Align2::CENTER_CENTER, 203 key, 204 egui::FontId::monospace(keybind_box_size * 0.65), 205 visuals.text_color(), 206 ); 207 } 208 } 209 210 response 211 } 212 } 213 214 /// A pill-shaped action button with integrated keybind hint 215 pub struct ActionButton<'a> { 216 text: &'a str, 217 bg_color: Color32, 218 text_color: Color32, 219 keybind: Option<&'a str>, 220 } 221 222 impl<'a> ActionButton<'a> { 223 /// Create a new action button with the given text and colors 224 pub fn new(text: &'a str, bg_color: Color32, text_color: Color32) -> Self { 225 Self { 226 text, 227 bg_color, 228 text_color, 229 keybind: None, 230 } 231 } 232 233 /// Add a keybind hint inside the button (e.g., "1" for key 1) 234 pub fn keybind(mut self, key: &'a str) -> Self { 235 self.keybind = Some(key); 236 self 237 } 238 239 /// Show the button and return the response 240 pub fn show(self, ui: &mut Ui) -> Response { 241 // Calculate text size for proper allocation 242 let font_id = egui::FontId::proportional(13.0); 243 let galley = 244 ui.painter() 245 .layout_no_wrap(self.text.to_string(), font_id.clone(), self.text_color); 246 247 // Calculate keybind box size if present 248 let keybind_box_size = 16.0; 249 let keybind_spacing = 6.0; 250 let keybind_extra = if self.keybind.is_some() { 251 keybind_box_size + keybind_spacing 252 } else { 253 0.0 254 }; 255 256 // Padding: horizontal 10px, vertical 4px 257 let padding = Vec2::new(10.0, 4.0); 258 let desired_size = 259 Vec2::new(galley.size().x + keybind_extra, galley.size().y) + padding * 2.0; 260 261 let (rect, response) = ui.allocate_exact_size(desired_size, egui::Sense::click()); 262 263 if ui.is_rect_visible(rect) { 264 let painter = ui.painter(); 265 266 // Adjust color based on hover/click state 267 let bg_color = if response.is_pointer_button_down_on() { 268 self.bg_color.gamma_multiply(0.8) 269 } else if response.hovered() { 270 self.bg_color.gamma_multiply(1.15) 271 } else { 272 self.bg_color 273 }; 274 275 // Full pill rounding (half of height) 276 let rounding = rect.height() / 2.0; 277 278 // Background 279 painter.rect_filled(rect, rounding, bg_color); 280 281 // Text (offset right if keybind present, since keybind goes on left) 282 let text_offset_x = if self.keybind.is_some() { 283 keybind_extra / 2.0 284 } else { 285 0.0 286 }; 287 let text_pos = rect.center() + Vec2::new(text_offset_x, 0.0) - galley.size() / 2.0; 288 painter.galley(text_pos, galley, self.text_color); 289 290 // Draw keybind hint on left side (white border, no fill) 291 if let Some(key) = self.keybind { 292 let box_center = egui::pos2( 293 rect.left() + padding.x + keybind_box_size / 2.0, 294 rect.center().y, 295 ); 296 let box_rect = 297 egui::Rect::from_center_size(box_center, Vec2::splat(keybind_box_size)); 298 299 // White border only 300 painter.rect_stroke( 301 box_rect, 302 3.0, 303 egui::Stroke::new(1.0, Color32::WHITE), 304 egui::StrokeKind::Inside, 305 ); 306 307 // Keybind text with vertical nudge for optical centering 308 painter.text( 309 box_center + Vec2::new(0.0, 1.0), 310 egui::Align2::CENTER_CENTER, 311 key, 312 egui::FontId::monospace(keybind_box_size * 0.7), 313 self.text_color, 314 ); 315 } 316 } 317 318 response 319 } 320 }