| Bring vidya in cfd3e36 nandi 19d ago | 1 | //! Palette, metrics, egui Visuals/Style, and widget helpers. |
| 2 | |
| 3 | use egui::{ |
| 4 | pos2, |
| 5 | style::{ScrollStyle, WidgetVisuals}, |
| 6 | Button, Color32, CornerRadius, CursorIcon, FontId, Frame, Label, Margin, Response, RichText, |
| 7 | Sense, Shadow, Shape, Stroke, StrokeKind, Style, TextBuffer, TextEdit, TextWrapMode, Ui, Vec2, |
| 8 | Visuals, WidgetInfo, WidgetType, |
| 9 | }; |
| 10 | |
| 11 | use crate::install_text_styles; |
| 12 | |
| 13 | /// Light vs dark shell. |
| 14 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] |
| 15 | pub enum Mode { |
| 16 | #[default] |
| 17 | Dark, |
| 18 | Light, |
| 19 | } |
| 20 | |
| 21 | /// Semantic colors. |
| 22 | #[derive(Debug, Clone)] |
| 23 | pub struct Palette { |
| 24 | pub window_bg: Color32, |
| 25 | pub view_bg: Color32, |
| 26 | pub headerbar_bg: Color32, |
| 27 | pub card_bg: Color32, |
| 28 | pub popover_bg: Color32, |
| 29 | pub shade: Color32, |
| 30 | |
| 31 | pub accent: Color32, |
| 32 | pub accent_fg: Color32, |
| 33 | pub accent_hover: Color32, |
| 34 | pub accent_active: Color32, |
| 35 | |
| 36 | pub destructive: Color32, |
| 37 | pub success: Color32, |
| 38 | pub warning: Color32, |
| 39 | |
| 40 | pub text: Color32, |
| 41 | pub text_secondary: Color32, |
| 42 | pub text_disabled: Color32, |
| 43 | |
| 44 | pub border: Color32, |
| 45 | pub border_soft: Color32, |
| 46 | |
| 47 | pub button_bg: Color32, |
| 48 | pub button_hover: Color32, |
| 49 | pub button_active: Color32, |
| 50 | pub button_fg: Color32, |
| 51 | } |
| 52 | |
| 53 | impl Palette { |
| 54 | pub fn dark() -> Self { |
| 55 | Self { |
| 56 | window_bg: rgb(0x24, 0x24, 0x24), |
| 57 | view_bg: rgb(0x1e, 0x1e, 0x1e), |
| 58 | headerbar_bg: rgb(0x30, 0x30, 0x30), |
| 59 | card_bg: rgb(0x30, 0x30, 0x30), |
| 60 | popover_bg: rgb(0x38, 0x38, 0x38), |
| 61 | shade: Color32::from_black_alpha(80), |
| 62 | |
| 63 | accent: rgb(0x35, 0x84, 0xe4), |
| 64 | accent_fg: Color32::WHITE, |
| 65 | accent_hover: rgb(0x4a, 0x93, 0xe7), |
| 66 | accent_active: rgb(0x1c, 0x71, 0xd8), |
| 67 | |
| 68 | destructive: rgb(0xc0, 0x1c, 0x28), |
| 69 | success: rgb(0x2e, 0xc2, 0x7e), |
| 70 | warning: rgb(0xe5, 0xa5, 0x0a), |
| 71 | |
| 72 | text: rgb(0xff, 0xff, 0xff), |
| 73 | text_secondary: rgb(0x9a, 0x99, 0x96), |
| 74 | text_disabled: rgb(0x5e, 0x5c, 0x64), |
| 75 | |
| 76 | border: rgb(0x5e, 0x5c, 0x64), |
| 77 | border_soft: rgb(0x3d, 0x38, 0x46), |
| 78 | |
| 79 | button_bg: rgb(0x3d, 0x3d, 0x3d), |
| 80 | button_hover: rgb(0x4a, 0x4a, 0x4a), |
| 81 | button_active: rgb(0x35, 0x35, 0x35), |
| 82 | button_fg: rgb(0xff, 0xff, 0xff), |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | pub fn light() -> Self { |
| 87 | Self { |
| 88 | window_bg: rgb(0xfa, 0xfa, 0xfa), |
| 89 | view_bg: rgb(0xff, 0xff, 0xff), |
| 90 | headerbar_bg: rgb(0xff, 0xff, 0xff), |
| 91 | card_bg: rgb(0xff, 0xff, 0xff), |
| 92 | popover_bg: rgb(0xff, 0xff, 0xff), |
| 93 | shade: Color32::from_black_alpha(40), |
| 94 | |
| 95 | accent: rgb(0x35, 0x84, 0xe4), |
| 96 | accent_fg: Color32::WHITE, |
| 97 | accent_hover: rgb(0x4a, 0x93, 0xe7), |
| 98 | accent_active: rgb(0x1c, 0x71, 0xd8), |
| 99 | |
| 100 | destructive: rgb(0xc0, 0x1c, 0x28), |
| 101 | success: rgb(0x26, 0xa2, 0x69), |
| 102 | warning: rgb(0xe5, 0xa5, 0x0a), |
| 103 | |
| 104 | text: rgb(0x24, 0x1f, 0x31), |
| 105 | text_secondary: rgb(0x5e, 0x5c, 0x64), |
| 106 | text_disabled: rgb(0x9a, 0x99, 0x96), |
| 107 | |
| 108 | border: rgb(0xcd, 0xcd, 0xcd), |
| 109 | border_soft: rgb(0xe0, 0xe0, 0xe0), |
| 110 | |
| 111 | button_bg: rgb(0xe0, 0xe0, 0xe0), |
| 112 | button_hover: rgb(0xd0, 0xd0, 0xd0), |
| 113 | button_active: rgb(0xc0, 0xc0, 0xc0), |
| 114 | button_fg: rgb(0x24, 0x1f, 0x31), |
| 115 | } |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | /// Spacing scale (HIG-ish: 6 / 12 / 18 / 24). |
| 120 | #[derive(Debug, Clone)] |
| 121 | pub struct Spacing { |
| 122 | pub xs: f32, |
| 123 | pub sm: f32, |
| 124 | pub md: f32, |
| 125 | pub lg: f32, |
| 126 | pub xl: f32, |
| 127 | pub page: f32, |
| 128 | pub control_height: f32, |
| 129 | /// Inner horizontal padding for text fields (egui default is only 4). |
| 130 | pub field_pad_x: f32, |
| 131 | /// Inner vertical padding for text fields (egui default is only 2). |
| 132 | pub field_pad_y: f32, |
| 133 | /// Gap between a form label and its control. |
| 134 | pub field_gap: f32, |
| 135 | pub radius_sm: f32, |
| 136 | pub radius_md: f32, |
| 137 | pub radius_lg: f32, |
| 138 | } |
| 139 | |
| 140 | impl Default for Spacing { |
| 141 | fn default() -> Self { |
| 142 | Self { |
| 143 | xs: 4.0, |
| 144 | sm: 6.0, |
| 145 | md: 12.0, |
| 146 | lg: 18.0, |
| 147 | xl: 24.0, |
| 148 | page: 16.0, |
| 149 | control_height: 34.0, |
| 150 | field_pad_x: 12.0, |
| 151 | field_pad_y: 8.0, |
| 152 | field_gap: 12.0, |
| 153 | radius_sm: 6.0, |
| 154 | radius_md: 9.0, |
| 155 | radius_lg: 12.0, |
| 156 | } |
| 157 | } |
| 158 | } |
| 159 | |
| 160 | /// Type sizes (UI sans scale). |
| 161 | #[derive(Debug, Clone)] |
| 162 | pub struct TypeScale { |
| 163 | pub title: f32, |
| 164 | pub title_2: f32, |
| 165 | pub title_3: f32, |
| 166 | pub body: f32, |
| 167 | pub caption: f32, |
| 168 | } |
| 169 | |
| 170 | impl Default for TypeScale { |
| 171 | fn default() -> Self { |
| 172 | Self { |
| 173 | title: 20.0, |
| 174 | title_2: 16.0, |
| 175 | title_3: 14.0, |
| 176 | body: 14.0, |
| 177 | caption: 12.0, |
| 178 | } |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | /// Full theme bundle. |
| 183 | #[derive(Debug, Clone)] |
| 184 | pub struct Theme { |
| 185 | pub mode: Mode, |
| 186 | pub palette: Palette, |
| 187 | pub spacing: Spacing, |
| 188 | pub type_scale: TypeScale, |
| 189 | } |
| 190 | |
| 191 | impl Theme { |
| 192 | pub fn dark() -> Self { |
| 193 | Self { |
| 194 | mode: Mode::Dark, |
| 195 | palette: Palette::dark(), |
| 196 | spacing: Spacing::default(), |
| 197 | type_scale: TypeScale::default(), |
| 198 | } |
| 199 | } |
| 200 | |
| 201 | pub fn light() -> Self { |
| 202 | Self { |
| 203 | mode: Mode::Light, |
| 204 | palette: Palette::light(), |
| 205 | spacing: Spacing::default(), |
| 206 | type_scale: TypeScale::default(), |
| 207 | } |
| 208 | } |
| 209 | |
| 210 | pub fn visuals(&self) -> Visuals { |
| 211 | let p = &self.palette; |
| 212 | let mut v = match self.mode { |
| 213 | Mode::Dark => Visuals::dark(), |
| 214 | Mode::Light => Visuals::light(), |
| 215 | }; |
| 216 | |
| 217 | v.dark_mode = self.mode == Mode::Dark; |
| 218 | v.override_text_color = Some(p.text); |
| 219 | v.hyperlink_color = p.accent; |
| 220 | v.warn_fg_color = p.warning; |
| 221 | v.error_fg_color = p.destructive; |
| 222 | v.extreme_bg_color = p.view_bg; |
| 223 | v.faint_bg_color = p.card_bg; |
| 224 | v.code_bg_color = p.popover_bg; |
| 225 | v.window_fill = p.window_bg; |
| 226 | v.panel_fill = p.window_bg; |
| 227 | v.window_stroke = Stroke::new(1.0_f32, p.border_soft); |
| 228 | v.window_corner_radius = CornerRadius::same(self.spacing.radius_md as u8); |
| 229 | v.window_shadow = Shadow { |
| 230 | offset: [0, 4], |
| 231 | blur: 16, |
| 232 | spread: 0, |
| 233 | color: p.shade, |
| 234 | }; |
| 235 | v.popup_shadow = Shadow { |
| 236 | offset: [0, 2], |
| 237 | blur: 8, |
| 238 | spread: 0, |
| 239 | color: p.shade, |
| 240 | }; |
| 241 | v.selection.bg_fill = p.accent.gamma_multiply(0.35); |
| 242 | v.selection.stroke = Stroke::new(1.0_f32, p.accent); |
| 243 | v.widgets = widget_visuals(p, &self.spacing); |
| 244 | // Browser-style click affordance: buttons (and apps that opt in) show a |
| 245 | // pointing-hand cursor on hover. egui defaults this to None (native toolkit |
| 246 | // style); Vidya prefers the clickable cue on desktop. |
| 247 | v.interact_cursor = Some(CursorIcon::PointingHand); |
| 248 | v |
| 249 | } |
| 250 | |
| 251 | pub fn style(&self) -> Style { |
| 252 | let mut style = Style { |
| 253 | visuals: self.visuals(), |
| 254 | ..Style::default() |
| 255 | }; |
| 256 | install_text_styles(&mut style, &self.type_scale); |
| 257 | |
| 258 | let sp = &self.spacing; |
| 259 | style.spacing.item_spacing = Vec2::new(sp.sm, sp.sm); |
| 260 | style.spacing.button_padding = Vec2::new(sp.md, sp.sm); |
| 261 | style.spacing.indent = sp.lg; |
| 262 | style.spacing.window_margin = Margin::same(sp.page as i8); |
| 263 | style.spacing.menu_margin = Margin::same(sp.sm as i8); |
| 264 | style.spacing.interact_size = Vec2::new(sp.control_height * 2.5, sp.control_height); |
| 265 | // Native checkbox / radio icons (prefer `checkbox()` for the themed control). |
| 266 | style.spacing.icon_width = 18.0; |
| 267 | style.spacing.icon_width_inner = 12.0; |
| 268 | style.spacing.icon_spacing = sp.sm + 2.0; |
| 269 | // Solid gutters: floating bars sit on top of content (egui default). HIG-ish |
| 270 | // scroll areas reserve space so lists/text never sit under the thumb. |
| 271 | style.spacing.scroll = ScrollStyle::solid(); |
| 272 | // Prefer wrapping at the parent edge over extending (overflow) or hard |
| 273 | // truncate. Pair with layout helpers (`fit_width`, `card`, `hflow`) so |
| 274 | // rows actually receive a finite residual width. |
| 275 | style.wrap_mode = Some(TextWrapMode::Wrap); |
| 276 | // Default TextEdit desired width: fill parent residual (egui still |
| 277 | // clamps to available_width). Avoid the stock 280px “always this wide” |
| 278 | // intrinsic that pushes horizontal rows past the app view. |
| 279 | style.spacing.text_edit_width = 10_000.0; |
| 280 | style.interaction.tooltip_delay = 0.4; |
| 281 | style.animation_time = 0.12; |
| 282 | style |
| 283 | } |
| 284 | |
| 285 | pub fn header_frame(&self) -> Frame { |
| 286 | Frame::new() |
| 287 | .fill(self.palette.headerbar_bg) |
| 288 | .inner_margin(Margin::symmetric( |
| 289 | self.spacing.page as i8, |
| 290 | self.spacing.md as i8, |
| 291 | )) |
| 292 | .stroke(Stroke::new(1.0_f32, self.palette.border_soft)) |
| 293 | } |
| 294 | |
| 295 | pub fn card_frame(&self) -> Frame { |
| 296 | Frame::new() |
| 297 | .fill(self.palette.card_bg) |
| 298 | .corner_radius(self.spacing.radius_lg) |
| 299 | .inner_margin(Margin::same(self.spacing.md as i8)) |
| 300 | .stroke(Stroke::new(1.0_f32, self.palette.border_soft)) |
| 301 | } |
| 302 | |
| 303 | pub fn page_frame(&self) -> Frame { |
| 304 | Frame::new() |
| 305 | .fill(self.palette.window_bg) |
| 306 | .inner_margin(Margin::same(self.spacing.page as i8)) |
| 307 | } |
| 308 | |
| 309 | /// Inner padding for [`TextEdit`] / form fields (HIG-ish; roomier than egui’s 4×2 default). |
| 310 | pub fn text_edit_margin(&self) -> Margin { |
| 311 | Margin::symmetric( |
| 312 | self.spacing.field_pad_x as i8, |
| 313 | self.spacing.field_pad_y as i8, |
| 314 | ) |
| 315 | } |
| 316 | } |
| 317 | |
| 318 | fn widget_visuals(p: &Palette, sp: &Spacing) -> egui::style::Widgets { |
| 319 | let r = CornerRadius::same(sp.radius_md as u8); |
| 320 | let soft = Stroke::new(1.0_f32, p.border_soft); |
| 321 | |
| 322 | egui::style::Widgets { |
| 323 | noninteractive: WidgetVisuals { |
| 324 | bg_fill: p.card_bg, |
| 325 | weak_bg_fill: p.view_bg, |
| 326 | bg_stroke: soft, |
| 327 | corner_radius: r, |
| 328 | fg_stroke: Stroke::new(1.0_f32, p.text_secondary), |
| 329 | expansion: 0.0, |
| 330 | }, |
| 331 | inactive: WidgetVisuals { |
| 332 | bg_fill: p.button_bg, |
| 333 | weak_bg_fill: p.button_bg, |
| 334 | bg_stroke: Stroke::new(1.0_f32, p.border_soft), |
| 335 | corner_radius: r, |
| 336 | fg_stroke: Stroke::new(1.0_f32, p.button_fg), |
| 337 | expansion: 0.0, |
| 338 | }, |
| 339 | hovered: WidgetVisuals { |
| 340 | bg_fill: p.button_hover, |
| 341 | weak_bg_fill: p.button_hover, |
| 342 | bg_stroke: Stroke::new(1.0_f32, p.border), |
| 343 | corner_radius: r, |
| 344 | fg_stroke: Stroke::new(1.0_f32, p.text), |
| 345 | expansion: 0.0, |
| 346 | }, |
| 347 | active: WidgetVisuals { |
| 348 | bg_fill: p.button_active, |
| 349 | weak_bg_fill: p.button_active, |
| 350 | bg_stroke: Stroke::new(1.0_f32, p.accent), |
| 351 | corner_radius: r, |
| 352 | fg_stroke: Stroke::new(1.0_f32, p.text), |
| 353 | expansion: 0.0, |
| 354 | }, |
| 355 | open: WidgetVisuals { |
| 356 | bg_fill: p.popover_bg, |
| 357 | weak_bg_fill: p.popover_bg, |
| 358 | bg_stroke: Stroke::new(1.0_f32, p.border), |
| 359 | corner_radius: r, |
| 360 | fg_stroke: Stroke::new(1.0_f32, p.text), |
| 361 | expansion: 0.0, |
| 362 | }, |
| 363 | } |
| 364 | } |
| 365 | |
| 366 | /// Square tool button with a stroke/emoji [`crate::Icon`] (e.g. copy, plus). |
| 367 | /// |
| 368 | /// Prefer this over text labels in tight rows — text wraps under |
| 369 | /// [`TextWrapMode::Wrap`] and looks broken at small widths. |
| 370 | pub fn icon_button(ui: &mut Ui, theme: &Theme, icon: crate::Icon, tip: &str) -> Response { |
| 371 | use crate::paint_icon_in; |
| 372 | let p = &theme.palette; |
| 373 | let size = theme.spacing.control_height; |
| 374 | let (rect, mut response) = ui.allocate_exact_size(Vec2::splat(size), Sense::click()); |
| 375 | response = response.on_hover_text(tip); |
| 376 | if let Some(cursor) = ui.visuals().interact_cursor { |
| 377 | response = response.on_hover_cursor(cursor); |
| 378 | } |
| 379 | |
| 380 | if ui.is_rect_visible(rect) { |
| 381 | let hovered = response.hovered() && ui.is_enabled(); |
| 382 | let active = response.is_pointer_button_down_on(); |
| 383 | let fill = if active { |
| 384 | p.button_active |
| 385 | } else if hovered { |
| 386 | p.button_hover |
| 387 | } else { |
| 388 | p.button_bg |
| 389 | }; |
| 390 | let border = if hovered { |
| 391 | Stroke::new(1.0_f32, p.border) |
| 392 | } else { |
| 393 | Stroke::new(1.0_f32, p.border_soft) |
| 394 | }; |
| 395 | ui.painter().rect( |
| 396 | rect, |
| 397 | theme.spacing.radius_md, |
| 398 | fill, |
| 399 | border, |
| 400 | StrokeKind::Inside, |
| 401 | ); |
| 402 | let icon_size = size * 0.48; |
| 403 | let icon_rect = egui::Rect::from_center_size(rect.center(), Vec2::splat(icon_size)); |
| 404 | paint_icon_in(ui, icon_rect, icon, p.button_fg); |
| 405 | } |
| 406 | |
| 407 | response |
| 408 | } |
| 409 | |
| 410 | /// Primary / suggested action (accent filled). |
| 411 | pub fn primary_button(ui: &mut Ui, theme: &Theme, label: &str) -> Response { |
| 412 | let p = &theme.palette; |
| 413 | let text = RichText::new(label) |
| 414 | .size(theme.type_scale.body) |
| 415 | .color(p.accent_fg); |
| 416 | let btn = Button::new(text) |
| 417 | .fill(p.accent) |
| 418 | .stroke(Stroke::NONE) |
| 419 | .corner_radius(theme.spacing.radius_md) |
| 420 | .min_size(Vec2::new(0.0, theme.spacing.control_height)); |
| 421 | ui.add(btn) |
| 422 | } |
| 423 | |
| 424 | /// Regular flat button. |
| 425 | pub fn button(ui: &mut Ui, theme: &Theme, label: &str) -> Response { |
| 426 | let p = &theme.palette; |
| 427 | let text = RichText::new(label) |
| 428 | .size(theme.type_scale.body) |
| 429 | .color(p.button_fg); |
| 430 | let btn = Button::new(text) |
| 431 | .fill(p.button_bg) |
| 432 | .stroke(Stroke::new(1.0_f32, p.border_soft)) |
| 433 | .corner_radius(theme.spacing.radius_md) |
| 434 | .min_size(Vec2::new(0.0, theme.spacing.control_height)); |
| 435 | ui.add(btn) |
| 436 | } |
| 437 | |
| 438 | /// Destructive action. |
| 439 | pub fn destructive_button(ui: &mut Ui, theme: &Theme, label: &str) -> Response { |
| 440 | let p = &theme.palette; |
| 441 | let text = RichText::new(label) |
| 442 | .size(theme.type_scale.body) |
| 443 | .color(Color32::WHITE); |
| 444 | let btn = Button::new(text) |
| 445 | .fill(p.destructive) |
| 446 | .stroke(Stroke::NONE) |
| 447 | .corner_radius(theme.spacing.radius_md) |
| 448 | .min_size(Vec2::new(0.0, theme.spacing.control_height)); |
| 449 | ui.add(btn) |
| 450 | } |
| 451 | |
| 452 | /// Accent-filled checkbox with a drawn checkmark (stock egui looks janky here). |
| 453 | /// |
| 454 | /// Unchecked: soft fill + border. Checked: solid accent tile + white stroke check. |
| 455 | pub fn checkbox(ui: &mut Ui, theme: &Theme, checked: &mut bool, text: &str) -> Response { |
| 456 | let p = &theme.palette; |
| 457 | let sp = &theme.spacing; |
| 458 | |
| 459 | // Slightly larger than stock 16px so the check has room to breathe. |
| 460 | let box_size = 20.0_f32; |
| 461 | let gap = sp.sm + 4.0; |
| 462 | |
| 463 | let galley = ui.fonts(|f| { |
| 464 | f.layout_no_wrap( |
| 465 | text.to_owned(), |
| 466 | FontId::proportional(theme.type_scale.body), |
| 467 | p.text, |
| 468 | ) |
| 469 | }); |
| 470 | |
| 471 | let height = box_size |
| 472 | .max(galley.size().y + 4.0) |
| 473 | .max(sp.control_height * 0.85); |
| 474 | let width = box_size + gap + galley.size().x; |
| 475 | let (rect, mut response) = ui.allocate_exact_size(Vec2::new(width, height), Sense::click()); |
| 476 | |
| 477 | if response.clicked() { |
| 478 | *checked = !*checked; |
| 479 | response.mark_changed(); |
| 480 | } |
| 481 | response.widget_info(|| { |
| 482 | WidgetInfo::selected(WidgetType::Checkbox, ui.is_enabled(), *checked, text) |
| 483 | }); |
| 484 | // Custom widget — egui only applies `interact_cursor` on stock `Button`. |
| 485 | if let Some(cursor) = ui.visuals().interact_cursor { |
| 486 | response = response.on_hover_cursor(cursor); |
| 487 | } |
| 488 | |
| 489 | if ui.is_rect_visible(rect) { |
| 490 | let painter = ui.painter(); |
| 491 | let box_rect = egui::Rect::from_center_size( |
| 492 | pos2(rect.left() + box_size * 0.5, rect.center().y), |
| 493 | Vec2::splat(box_size), |
| 494 | ); |
| 495 | // Squarer than widget radius_md (which nearly circles a 20px tile). |
| 496 | let radius = CornerRadius::same((sp.radius_sm * 0.85) as u8); |
| 497 | |
| 498 | let hovered = response.hovered() && ui.is_enabled(); |
| 499 | let (fill, border) = if *checked { |
| 500 | let fill = if hovered { p.accent_hover } else { p.accent }; |
| 501 | (fill, Stroke::new(1.0_f32, fill)) |
| 502 | } else if hovered { |
| 503 | (p.button_hover, Stroke::new(1.5_f32, p.border)) |
| 504 | } else { |
| 505 | (p.button_bg, Stroke::new(1.25_f32, p.border_soft)) |
| 506 | }; |
| 507 | |
| 508 | painter.rect(box_rect, radius, fill, border, StrokeKind::Inside); |
| 509 | |
| 510 | if *checked { |
| 511 | // Refined check proportions (short leg → long arm), not stock’s steep V. |
| 512 | let s = box_size; |
| 513 | let o = box_rect.min; |
| 514 | let a = pos2(o.x + s * 0.22, o.y + s * 0.52); |
| 515 | let b = pos2(o.x + s * 0.42, o.y + s * 0.70); |
| 516 | let c = pos2(o.x + s * 0.78, o.y + s * 0.30); |
| 517 | // Slightly thicker tip stroke reads cleaner at small sizes. |
| 518 | painter.add(Shape::line( |
| 519 | vec![a, b, c], |
| 520 | Stroke::new(2.15_f32, p.accent_fg), |
| 521 | )); |
| 522 | } |
| 523 | |
| 524 | let text_pos = pos2( |
| 525 | rect.left() + box_size + gap, |
| 526 | rect.center().y - 0.5 * galley.size().y, |
| 527 | ); |
| 528 | painter.galley(text_pos, galley, p.text); |
| 529 | } |
| 530 | |
| 531 | response |
| 532 | } |
| 533 | |
| 534 | /// Live / offline status mark drawn with shapes. |
| 535 | /// |
| 536 | /// **Do not** use Unicode `●` / `○` in labels on Android: egui’s default fonts |
| 537 | /// often lack those glyphs and render a hollow box (“tofu”). This paints a real |
| 538 | /// circle instead — filled when `live`, stroked when offline. |
| 539 | pub fn status_dot(ui: &mut Ui, theme: &Theme, live: bool) -> Response { |
| 540 | let p = &theme.palette; |
| 541 | // Match body line height so the dot sits cleanly next to `body` text. |
| 542 | let line = theme.type_scale.body; |
| 543 | let diameter = (line * 0.55).clamp(7.0, 12.0); |
| 544 | let pad = 2.0; |
| 545 | let (rect, response) = |
| 546 | ui.allocate_exact_size(Vec2::new(diameter + pad * 2.0, line), Sense::hover()); |
| 547 | |
| 548 | if ui.is_rect_visible(rect) { |
| 549 | let center = pos2(rect.center().x, rect.center().y); |
| 550 | let radius = diameter * 0.5; |
| 551 | let color = if live { p.success } else { p.text_secondary }; |
| 552 | let painter = ui.painter(); |
| 553 | if live { |
| 554 | painter.circle_filled(center, radius, color); |
| 555 | } else { |
| 556 | painter.circle_stroke(center, radius, Stroke::new(1.5_f32, color)); |
| 557 | } |
| 558 | } |
| 559 | |
| 560 | response |
| 561 | } |
| 562 | |
| Let a label wrap where the layout wraps a20c5b9 nandi 19d ago | 563 | /// The text styles. |
| 564 | /// |
| 565 | /// None of them sets a wrap mode: a `Label` left alone takes the layout's, |
| 566 | /// which wraps in a column and extends in a row laid out without wrapping. |
| 567 | /// Forcing `.wrap()` instead made a label break its own text whenever a row |
| 568 | /// squeezed it — a status reading "joine/d", an unread count split down two |
| 569 | /// lines — for want of the width it asked for. |
| Bring vidya in cfd3e36 nandi 19d ago | 570 | pub fn title(ui: &mut Ui, theme: &Theme, text: &str) { |
| 571 | ui.add( |
| 572 | Label::new( |
| 573 | RichText::new(text) |
| 574 | .size(theme.type_scale.title) |
| 575 | .strong() |
| 576 | .color(theme.palette.text), |
| Let a label wrap where the layout wraps a20c5b9 nandi 19d ago | 577 | ), |
| Bring vidya in cfd3e36 nandi 19d ago | 578 | ); |
| 579 | } |
| 580 | |
| 581 | pub fn title_2(ui: &mut Ui, theme: &Theme, text: &str) { |
| 582 | ui.add( |
| 583 | Label::new( |
| 584 | RichText::new(text) |
| 585 | .size(theme.type_scale.title_2) |
| 586 | .strong() |
| 587 | .color(theme.palette.text), |
| Let a label wrap where the layout wraps a20c5b9 nandi 19d ago | 588 | ), |
| Bring vidya in cfd3e36 nandi 19d ago | 589 | ); |
| 590 | } |
| 591 | |
| 592 | pub fn body(ui: &mut Ui, theme: &Theme, text: &str) { |
| 593 | ui.add( |
| 594 | Label::new( |
| 595 | RichText::new(text) |
| 596 | .size(theme.type_scale.body) |
| 597 | .color(theme.palette.text), |
| Let a label wrap where the layout wraps a20c5b9 nandi 19d ago | 598 | ), |
| Bring vidya in cfd3e36 nandi 19d ago | 599 | ); |
| 600 | } |
| 601 | |
| 602 | pub fn dim_label(ui: &mut Ui, theme: &Theme, text: &str) { |
| 603 | ui.add( |
| 604 | Label::new( |
| 605 | RichText::new(text) |
| 606 | .size(theme.type_scale.caption) |
| 607 | .color(theme.palette.text_secondary), |
| Let a label wrap where the layout wraps a20c5b9 nandi 19d ago | 608 | ), |
| Bring vidya in cfd3e36 nandi 19d ago | 609 | ); |
| 610 | } |
| 611 | |
| 612 | /// Single-line text field with theme field padding. |
| 613 | /// |
| 614 | /// Desired width tracks the parent residual so the field fills its slot |
| 615 | /// instead of requesting a fixed 280px and clipping siblings off-screen. |
| 616 | pub fn text_field_singleline(ui: &mut Ui, theme: &Theme, text: &mut dyn TextBuffer) -> Response { |
| 617 | let w = ui.available_width().max(1.0); |
| 618 | ui.add( |
| 619 | TextEdit::singleline(text) |
| 620 | .margin(theme.text_edit_margin()) |
| 621 | .desired_width(w) |
| 622 | .min_size(Vec2::new(0.0, theme.spacing.control_height)), |
| 623 | ) |
| 624 | } |
| 625 | |
| 626 | /// Multi-line text field with theme field padding. |
| 627 | /// |
| 628 | /// Fills parent width (clamped by available space) so it stays inside the view. |
| 629 | pub fn text_field_multiline( |
| 630 | ui: &mut Ui, |
| 631 | theme: &Theme, |
| 632 | text: &mut dyn TextBuffer, |
| 633 | rows: usize, |
| 634 | ) -> Response { |
| 635 | let w = ui.available_width().max(1.0); |
| 636 | ui.add( |
| 637 | TextEdit::multiline(text) |
| 638 | .margin(theme.text_edit_margin()) |
| 639 | .desired_width(w) |
| 640 | .desired_rows(rows), |
| 641 | ) |
| 642 | } |
| 643 | |
| 644 | fn rgb(r: u8, g: u8, b: u8) -> Color32 { |
| 645 | Color32::from_rgb(r, g, b) |
| 646 | } |