1use std::path::Path;
2
3use egui::{Color32, CornerRadius, Key, Pos2, Rect, Rgba, RichText, Sense, Stroke, Ui, Vec2};
4use pfx_editor_style::Theme;
5use pfx_editor_style::widgets::{Header, KeyCap, Number, Pill, Tone};
6use pfx_editor_viewport::{Look, Mode};
7
8use crate::check::{SceneCheck, worded};
9use crate::editor::{Editor, Group, Play, Request};
10use crate::inspect::{Change, Field, NONE, Row};
11use crate::log::Level;
12use crate::outline::{Item, Node};
13use crate::theme::{code, label, small, title};
14
15pub const OUTLINER_WIDTH: f32 = 230.0;
16pub const INSPECTOR_WIDTH: f32 = 300.0;
17pub const LOG_HEIGHT: f32 = 120.0;
18pub const TOOLBAR_HEIGHT: f32 = 30.0;
19
20fn linear_colour(rgb: [f32; 3]) -> Color32 {
21 Color32::from(Rgba::from_rgb(
22 rgb[0].clamp(0.0, 1.0),
23 rgb[1].clamp(0.0, 1.0),
24 rgb[2].clamp(0.0, 1.0),
25 ))
26}
27
28impl Editor {
29 pub fn ui(&mut self, ui: &mut egui::Ui) {
30 self.layout.outline.clear();
31 self.layout.rows.clear();
32 self.layout.buttons.clear();
33 self.layout.source = None;
34 self.layout.version = None;
35 let ctx = ui.ctx().clone();
36 let theme = Theme::of(&ctx);
37 self.level.shift = ctx.input(|input| input.modifiers.shift);
38 self.keys(&ctx);
39 self.snap_gizmo();
40 egui::Panel::top("pfx edit toolbar")
41 .resizable(false)
42 .exact_size(TOOLBAR_HEIGHT)
43 .show(ui, |ui| self.toolbar(ui));
44 egui::Panel::bottom("pfx edit log")
45 .resizable(true)
46 .default_size(LOG_HEIGHT)
47 .show(ui, |ui| self.log_panel(ui));
48 egui::Panel::left("pfx edit outliner")
49 .resizable(true)
50 .default_size(OUTLINER_WIDTH)
51 .show(ui, |ui| self.outliner(ui));
52 egui::Panel::right("pfx edit inspector")
53 .resizable(true)
54 .default_size(INSPECTOR_WIDTH)
55 .show(ui, |ui| self.inspector(ui));
56 egui::CentralPanel::default()
57 .frame(egui::Frame::NONE.fill(theme.bg))
58 .show(ui, |ui| self.viewport(ui));
59 }
60
61 fn keys(&mut self, ctx: &egui::Context) {
62 let viewport = self.viewport.id();
63 let typing = ctx.memory(|memory| memory.focused().is_some_and(|id| id != viewport));
64 if typing {
65 return;
66 }
67 let inside = ctx.memory(|memory| memory.has_focus(viewport))
68 || ctx.input(|input| {
69 input
70 .pointer
71 .hover_pos()
72 .zip(self.viewport.image())
73 .is_some_and(|(at, image)| image.contains(at))
74 });
75 let pressed = |key: Key| ctx.input(|input| input.key_pressed(key));
76 let presses = |key: Key| {
77 ctx.input(|input| {
78 input
79 .events
80 .iter()
81 .filter(|event| {
82 matches!(event, egui::Event::Key { key: found, pressed: true, .. } if *found == key)
83 })
84 .count()
85 })
86 };
87 let modifiers = ctx.input(|input| input.modifiers);
88 if modifiers.command && pressed(Key::Z) {
89 if modifiers.shift {
90 self.redo();
91 } else {
92 self.undo();
93 }
94 return;
95 }
96 if modifiers.command && pressed(Key::Y) {
97 self.redo();
98 return;
99 }
100 if modifiers.command || modifiers.alt {
101 return;
102 }
103 self.level_keys(ctx, inside);
104 if modifiers.shift {
105 return;
106 }
107 if pressed(Key::C) && self.editable() {
108 self.toggle_view();
109 }
110 if pressed(Key::T) {
111 self.trace = !self.trace;
112 }
113 if pressed(Key::F) && !inside {
114 self.focus();
115 }
116 for _ in 0..presses(Key::J) {
117 self.step_selection(1);
118 }
119 for _ in 0..presses(Key::K) {
120 self.step_selection(-1);
121 }
122 if pressed(Key::Escape) && self.viewport.gizmo().dragging().is_none() {
123 self.select(None);
124 }
125 if pressed(Key::M) {
126 self.muted = !self.muted;
127 }
128 if pressed(Key::Enter)
129 && self.editable()
130 && let Some(Item::Scene(scene)) = self.selection.clone()
131 {
132 self.open_scene(scene);
133 }
134 if pressed(Key::F5) {
135 self.play_pressed();
136 }
137 if pressed(Key::F7) && self.play == Play::Playing {
138 self.request(Request::Pause);
139 }
140 if pressed(Key::F6) && self.play == Play::Paused {
141 self.request(Request::Step);
142 }
143 if pressed(Key::F8) && self.play != Play::Stopped {
144 self.request(Request::Stop);
145 }
146 if pressed(Key::F9) && self.play != Play::Stopped {
147 self.request(Request::Record);
148 }
149 if pressed(Key::F10) && self.play != Play::Stopped {
150 self.request(Request::Keep);
151 }
152 }
153
154 pub(crate) fn button(
155 &mut self,
156 ui: &mut Ui,
157 name: &'static str,
158 key: &str,
159 label: &str,
160 lit: bool,
161 ) -> bool {
162 let theme = Theme::of(ui.ctx());
163 let response = ui
164 .horizontal(|ui| {
165 ui.spacing_mut().item_spacing.x = 3.0;
166 let cap = ui.add(KeyCap::new(key).lit(lit).button());
167 let text = ui.add(
168 egui::Label::new(small(
169 &theme,
170 label,
171 if lit { theme.text } else { theme.text2 },
172 ))
173 .sense(Sense::click()),
174 );
175 cap.union(text)
176 })
177 .inner;
178 self.layout.buttons.insert(name, response.rect);
179 response.clicked()
180 }
181
182 fn toolbar(&mut self, ui: &mut Ui) {
183 let theme = Theme::of(ui.ctx());
184 ui.horizontal_centered(|ui| {
185 ui.label(title(&theme, "pfx edit", theme.size_body, theme.text));
186 ui.label(small(&theme, self.name(), theme.muted));
187 ui.separator();
188 let held = self.viewport.gizmo().mode;
189 for (name, key, mode) in [
190 ("move", "G", Mode::Move),
191 ("rotate", "R", Mode::Rotate),
192 ("scale", "S", Mode::Scale),
193 ] {
194 if self.button(ui, name, key, name, held == mode) {
195 self.viewport.gizmo_mut().mode = mode;
196 }
197 }
198 let space = self.viewport.gizmo().space;
199 if self.button(ui, "space", "L", space.word(), false) {
200 self.viewport.gizmo_mut().space = space.other();
201 }
202 ui.separator();
203 let scene = self.viewport.look() == Look::Scene;
204 let view = if scene {
205 "scene camera"
206 } else {
207 "editor camera"
208 };
209 if self.button(ui, "view", "C", view, scene) && self.editable() {
210 self.toggle_view();
211 }
212 if self.button(ui, "trace", "T", "trace", self.trace) {
213 self.trace = !self.trace;
214 }
215 ui.separator();
216 let play = match self.play {
217 Play::Stopped => "play",
218 Play::Playing => "pause",
219 Play::Paused => "resume",
220 };
221 if self.button(ui, "play", "F5", play, self.play == Play::Playing) {
222 self.play_pressed();
223 }
224 if self.button(ui, "step", "F6", "step", false) && self.play == Play::Paused {
225 self.request(Request::Step);
226 }
227 if self.button(ui, "stop", "F8", "stop", false) && self.play != Play::Stopped {
228 self.request(Request::Stop);
229 }
230 if self.sound_available {
231 let mute = if self.muted { "muted" } else { "mute" };
232 if self.button(ui, "mute", "M", mute, self.muted) {
233 self.muted = !self.muted;
234 }
235 }
236 ui.separator();
237 let undo = self
238 .history
239 .undo_label()
240 .map(|label| format!("undo {label}"))
241 .unwrap_or_else(|| "undo".to_string());
242 if self.button(ui, "undo", "^Z", &undo, false) {
243 self.undo();
244 }
245 if self.button(ui, "redo", "^Y", "redo", false) {
246 self.redo();
247 }
248 if self.play != Play::Stopped && !self.live.is_empty() {
249 ui.separator();
250 let keep = format!("keep {}", self.live.len());
251 if self.button(ui, "keep", "F10", &keep, true) {
252 self.request(Request::Keep);
253 }
254 }
255 });
256 }
257
258 fn outline_node(&mut self, ui: &mut Ui, node: &Node, depth: usize) {
259 let selected = self.selection.as_ref() == Some(&node.item);
260 let theme = Theme::of(ui.ctx());
261 let response = ui
262 .horizontal(|ui| {
263 ui.add_space(depth as f32 * 12.0);
264 ui.selectable_label(
265 selected,
266 RichText::new(&node.label).font(theme.font(theme.size_small)),
267 )
268 })
269 .inner;
270 self.layout.outline.insert(node.item.clone(), response.rect);
271 if let Item::Asset(asset) = &node.item
272 && crate::level::placeable(asset)
273 {
274 let response = response.interact(Sense::click_and_drag());
275 response.dnd_set_drag_payload(crate::ui_level::Placing(asset.clone()));
276 }
277 let shift = ui.input(|input| input.modifiers.shift);
278 if response.clicked()
279 && shift
280 && let Item::Object(name) = &node.item
281 {
282 self.pick_more(name);
283 } else if response.clicked() {
284 self.select(Some(node.item.clone()));
285 }
286 if response.double_clicked() {
287 self.select(Some(node.item.clone()));
288 match &node.item {
289 Item::Scene(scene) => self.open_scene(scene.clone()),
290 _ => self.focus(),
291 }
292 }
293 for child in &node.children {
294 self.outline_node(ui, child, depth + 1);
295 }
296 }
297
298 fn outliner(&mut self, ui: &mut Ui) {
299 let theme = Theme::of(ui.ctx());
300 ui.add(Header::new("1", "outliner").focused(self.selection.is_some()));
301 let outline = self.outline.clone();
302 egui::ScrollArea::vertical()
303 .id_salt("outliner scroll")
304 .auto_shrink(false)
305 .show(ui, |ui| {
306 for section in &outline.sections {
307 let open = !matches!(section.title, "meshes" | "files" | "content");
308 let shown =
309 egui::CollapsingHeader::new(label(&theme, section.title, theme.muted))
310 .id_salt(("outline", section.title))
311 .default_open(open)
312 .show(ui, |ui| {
313 for node in §ion.nodes {
314 self.outline_node(ui, node, 0);
315 }
316 });
317 self.layout
318 .buttons
319 .insert(section.title, shown.header_response.rect);
320 }
321 });
322 }
323
324 fn inspector(&mut self, ui: &mut Ui) {
325 let heading = match &self.selection {
326 Some(item) => format!("{} {}", item.kind(), item.name()),
327 None => "inspector".to_string(),
328 };
329 let theme = Theme::of(ui.ctx());
330 ui.add(Header::new("2", &heading).focused(self.selection.is_some()));
331 if self.level_panel(ui) {
332 return;
333 }
334 let Some(item) = self.selection.clone() else {
335 ui.label(small(
336 &theme,
337 "select an object, a light or a material",
338 theme.muted,
339 ));
340 return;
341 };
342 if let Item::File(file) = &item {
343 self.source_panel(ui, file);
344 return;
345 }
346 if let Item::Scene(_) | Item::Folder(_) | Item::Asset(_) = &item {
347 self.project_panel(ui, &item);
348 return;
349 }
350 if self.rows.is_empty() {
351 let note = match item {
352 Item::Haze => "no [haze] in this scene",
353 Item::Finish => "no [finish] in this scene",
354 _ => "nothing to edit",
355 };
356 ui.label(small(&theme, note, theme.muted));
357 return;
358 }
359 if let Some(target) = item.target()
360 && let Some(file) = self.defining_file(&target)
361 {
362 ui.label(code(&theme, file, theme.dim));
363 }
364 let rows = self.rows.clone();
365 let mut changes: Vec<(Change, Group)> = Vec::new();
366 egui::ScrollArea::vertical()
367 .id_salt("inspector scroll")
368 .auto_shrink(false)
369 .show(ui, |ui| {
370 ui.scope(|ui| {
371 egui::Grid::new("inspector rows")
372 .num_columns(2)
373 .spacing([8.0, 4.0])
374 .show(ui, |ui| {
375 for row in &rows {
376 if row.pending {
377 ui.label(label(&theme, &format!("{} •", row.label), theme.hot));
378 } else {
379 ui.label(label(&theme, &row.label, theme.text2));
380 }
381 let (field, group, rect) = self.field(ui, row);
382 self.layout.rows.insert(row.label.clone(), rect);
383 if let Some(field) = field
384 && let Some(change) = row.change(field)
385 {
386 changes.push((change, group));
387 } else if group == Group::End {
388 self.end_group();
389 }
390 ui.end_row();
391 }
392 });
393 });
394 });
395 for (change, group) in changes {
396 self.change(change, group);
397 }
398 }
399
400 fn project_panel(&mut self, ui: &mut Ui, item: &Item) {
401 let theme = Theme::of(ui.ctx());
402 let (Item::Scene(path) | Item::Folder(path) | Item::Asset(path)) = item else {
403 return;
404 };
405 let shown = match &self.project {
406 Some(project) => project.relative(path),
407 None => item.name(),
408 };
409 ui.label(code(&theme, shown, theme.dim));
410 let note = match item {
411 Item::Scene(scene) if scene == self.path() => "the scene open now".to_string(),
412 Item::Scene(_) => "Enter or a double-click opens it".to_string(),
413 Item::Folder(folder) => {
414 let count = self
415 .project
416 .as_ref()
417 .and_then(|project| {
418 project
419 .folders()
420 .into_iter()
421 .find(|found| &found.path == folder)
422 })
423 .map_or(0, |found| found.files.len());
424 format!("{count} content files")
425 }
426 _ => match std::fs::metadata(path) {
427 Ok(meta) => format!("{} bytes", meta.len()),
428 Err(error) => error.to_string(),
429 },
430 };
431 ui.label(small(&theme, note, theme.muted));
432 if let Item::Scene(scene) = item
433 && scene != self.path()
434 && self.button(ui, "open", "Enter", "open", false)
435 && self.editable()
436 {
437 self.open_scene(scene.clone());
438 }
439 }
440
441 fn defining_file(&self, target: &pfx_load::scene::Target) -> Option<String> {
442 use pfx_load::scene::Target;
443 let key = match target {
444 Target::Sun => "sun",
445 Target::Sky => "sky",
446 Target::Haze => "haze",
447 Target::Camera => "camera",
448 Target::Finish => "finish",
449 _ => return None,
450 };
451 self.raw
452 .file(key)
453 .map(|file| crate::outline::relative(self.path(), file))
454 }
455
456 fn numbers(
457 &mut self,
458 ui: &mut Ui,
459 values: &mut [f32],
460 row: &Row,
461 width: f32,
462 ) -> (bool, Group, Rect) {
463 let mut changed = false;
464 let mut group = Group::Keep;
465 let mut rect: Option<Rect> = None;
466 for value in values.iter_mut() {
467 let response = ui.add(
468 Number::new(value)
469 .unit(row.unit)
470 .speed(row.speed)
471 .decimals(3)
472 .width(width),
473 );
474 if response.drag_started() {
475 group = Group::Begin;
476 }
477 if response.drag_stopped() {
478 group = Group::End;
479 }
480 changed |= response.changed();
481 rect = Some(rect.map_or(response.rect, |rect| rect.union(response.rect)));
482 }
483 (changed, group, rect.unwrap_or(Rect::NOTHING))
484 }
485
486 fn field(&mut self, ui: &mut Ui, row: &Row) -> (Option<Field>, Group, Rect) {
487 let theme = Theme::of(ui.ctx());
488 match &row.field {
489 Field::Number(value) => {
490 let mut values = [*value];
491 let (changed, group, rect) = ui
492 .horizontal(|ui| self.numbers(ui, &mut values, row, 90.0))
493 .inner;
494 (changed.then_some(Field::Number(values[0])), group, rect)
495 }
496 Field::Pair(value) => {
497 let mut values = *value;
498 let (changed, group, rect) = ui
499 .horizontal(|ui| self.numbers(ui, &mut values, row, 62.0))
500 .inner;
501 (changed.then_some(Field::Pair(values)), group, rect)
502 }
503 Field::Vector(value) => {
504 let mut values = *value;
505 let (changed, group, rect) = ui
506 .horizontal(|ui| self.numbers(ui, &mut values, row, 62.0))
507 .inner;
508 (changed.then_some(Field::Vector(values)), group, rect)
509 }
510 Field::Color(value) => {
511 let mut values = *value;
512 let (changed, group, rect) = ui
513 .horizontal(|ui| {
514 let (swatch, _) =
515 ui.allocate_exact_size(Vec2::splat(theme.row_height), Sense::hover());
516 ui.painter().rect(
517 swatch,
518 CornerRadius::same(theme.radius),
519 linear_colour(values),
520 Stroke::new(theme.stroke, theme.line),
521 egui::StrokeKind::Inside,
522 );
523 let (changed, group, rect) = self.numbers(ui, &mut values, row, 54.0);
524 (changed, group, rect.union(swatch))
525 })
526 .inner;
527 (changed.then_some(Field::Color(values)), group, rect)
528 }
529 Field::Toggle(value) => {
530 let mut on = *value;
531 let response = ui.checkbox(&mut on, "");
532 (
533 response.changed().then_some(Field::Toggle(on)),
534 Group::Keep,
535 response.rect,
536 )
537 }
538 Field::Choice { value, options } => {
539 let mut picked = value.clone();
540 let shown = |text: &str| {
541 if text == NONE {
542 "(none)".to_string()
543 } else {
544 text.to_string()
545 }
546 };
547 let response = egui::ComboBox::from_id_salt(("choice", &row.label))
548 .selected_text(RichText::new(shown(value)).font(theme.font(theme.size_small)))
549 .width(160.0)
550 .show_ui(ui, |ui| {
551 for option in options {
552 ui.selectable_value(&mut picked, option.clone(), shown(option));
553 }
554 })
555 .response;
556 let field = (&picked != value).then(|| Field::Choice {
557 value: picked,
558 options: options.clone(),
559 });
560 (field, Group::Keep, response.rect)
561 }
562 Field::Text(value) => {
563 let key = row.label.clone();
564 let mut buffer = self
565 .texts
566 .get(&key)
567 .cloned()
568 .unwrap_or_else(|| value.clone());
569 let response = ui.add(
570 egui::TextEdit::singleline(&mut buffer)
571 .font(theme.font(theme.size_code))
572 .desired_width(180.0),
573 );
574 let done = response.lost_focus() && &buffer != value;
575 self.texts.insert(key, buffer.clone());
576 (
577 done.then_some(Field::Text(buffer)),
578 Group::Keep,
579 response.rect,
580 )
581 }
582 Field::Planes(planes) => {
583 let mut planes = planes.clone();
584 let mut changed = false;
585 let mut group = Group::Keep;
586 let response = ui.vertical(|ui| {
587 let mut remove = None;
588 for (index, plane) in planes.iter_mut().enumerate() {
589 ui.horizontal(|ui| {
590 let (moved, step, _) = self.numbers(ui, plane, row, 44.0);
591 changed |= moved;
592 if step != Group::Keep {
593 group = step;
594 }
595 if ui.small_button("−").clicked() {
596 remove = Some(index);
597 }
598 });
599 }
600 if let Some(index) = remove {
601 planes.remove(index);
602 changed = true;
603 }
604 if planes.len() < 2 && ui.small_button("+ plane").clicked() {
605 planes.push([0.0, 1.0, 0.0, 0.0]);
606 changed = true;
607 }
608 });
609 (
610 changed.then_some(Field::Planes(planes)),
611 group,
612 response.response.rect,
613 )
614 }
615 }
616 }
617
618 fn log_panel(&mut self, ui: &mut Ui) {
619 let theme = Theme::of(ui.ctx());
620 ui.horizontal(|ui| {
621 ui.add(Header::new("4", "log").width(120.0));
622 let errors = self.log.errors();
623 if errors > 0 {
624 ui.add(Pill::new(&format!("{errors} errors"), Tone::Danger));
625 }
626 if let Some(sound) = &self.sound {
627 ui.label(code(&theme, format!("sound {sound}"), theme.dim));
628 }
629 if let Some(status) = &self.pgpu {
630 ui.label(code(&theme, format!("pgpu {status}"), theme.dim));
631 }
632 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
633 let version = ui.label(code(&theme, &self.app, theme.dim));
634 self.layout.version = Some(version.rect);
635 });
636 });
637 let root = self.path().to_path_buf();
638 egui::ScrollArea::vertical()
639 .id_salt("log scroll")
640 .auto_shrink(false)
641 .stick_to_bottom(true)
642 .show(ui, |ui| {
643 for entry in &self.log.entries {
644 let colour = match entry.level {
645 Level::Info => theme.text2,
646 Level::Warn => theme.warning,
647 Level::Error => theme.error,
648 };
649 let text = match entry.place(&root) {
650 Some(place) => format!("{place}: {}", entry.text),
651 None => entry.text.clone(),
652 };
653 ui.label(code(&theme, text, colour));
654 }
655 });
656 }
657
658 fn source_panel(&mut self, ui: &mut Ui, file: &Path) {
659 let theme = Theme::of(ui.ctx());
660 let root = self.path().to_path_buf();
661 let editable = self.editable();
662 let others: Vec<String> = self
663 .problems
664 .iter()
665 .filter(|problem| problem.file != file)
666 .map(|problem| worded(problem, &root))
667 .collect();
668 for line in &others {
669 ui.label(code(&theme, line, theme.warning));
670 }
671 self.source(file).set_editable(editable);
672 let check = SceneCheck::new(self.edit.project());
673 let Some(source) = self.sources.get_mut(file) else {
674 return;
675 };
676 let top = ui.cursor().min;
677 let changed = source.show(
678 ui,
679 &mut self.files,
680 &mut self.history,
681 &check,
682 &mut |_, _| {},
683 );
684 let used = Rect::from_min_max(top, ui.min_rect().max);
685 self.layout.source = Some(used);
686 self.typed(changed);
687 }
688
689 fn viewport(&mut self, ui: &mut Ui) {
690 let theme = Theme::of(ui.ctx());
691 let rect = if self.play == Play::Stopped {
692 if std::mem::take(&mut self.outside) {
693 self.viewport.check();
694 }
695 self.viewport.show(ui, &mut self.files, &mut self.history);
696 self.events();
697 let rect = self.viewport.image().unwrap_or(Rect::NOTHING);
698 self.level_viewport(ui, rect);
699 if self.trace
700 && self.trace_samples > 0
701 && let Some(texture) = self.texture
702 {
703 ui.painter_at(rect).image(
704 texture,
705 rect,
706 Rect::from_min_max(Pos2::ZERO, Pos2::new(1.0, 1.0)),
707 Color32::WHITE,
708 );
709 }
710 rect
711 } else {
712 let (rect, _) = ui.allocate_exact_size(ui.available_size(), Sense::click_and_drag());
713 let painter = ui.painter_at(rect);
714 match self.texture {
715 Some(texture) => painter.image(
716 texture,
717 rect,
718 Rect::from_min_max(Pos2::ZERO, Pos2::new(1.0, 1.0)),
719 Color32::WHITE,
720 ),
721 None => painter.rect_filled(rect, CornerRadius::ZERO, theme.bg),
722 };
723 rect
724 };
725 self.layout.viewport = Some(rect);
726 self.screen = rect;
727 let painter = ui.painter_at(rect);
728 let mut badges: Vec<(String, Tone)> = Vec::new();
729 let camera = match (self.play, self.viewport.look()) {
730 (Play::Playing | Play::Paused, _) => "game camera",
731 (Play::Stopped, Look::Orbit(_)) => "editor camera",
732 (Play::Stopped, Look::Scene) => "scene camera",
733 };
734 badges.push((camera.to_string(), Tone::Neutral));
735 if self.trace {
736 badges.push((format!("trace {} samples", self.trace_samples), Tone::Busy));
737 }
738 match self.play {
739 Play::Playing => badges.push(("playing".to_string(), Tone::Ok)),
740 Play::Paused => badges.push(("paused".to_string(), Tone::Busy)),
741 Play::Stopped => {}
742 }
743 let mut right = rect.right() - 8.0;
744 for (text, tone) in badges.into_iter().rev() {
745 let ink = match tone {
746 Tone::Ok => theme.hot,
747 Tone::Busy => theme.hot_dim,
748 Tone::Neutral => theme.text2,
749 Tone::Danger => theme.error,
750 };
751 let job = theme.labels.job(&text, theme.font(theme.size_code), ink);
752 let galley = painter.layout_job(job);
753 let size = galley.size() + Vec2::new(14.0, 4.0);
754 let badge = Rect::from_min_size(
755 Pos2::new(right - size.x, rect.bottom() - 8.0 - size.y),
756 size,
757 );
758 painter.rect(
759 badge,
760 CornerRadius::same(theme.radius),
761 theme.surface,
762 Stroke::new(theme.stroke, theme.line),
763 egui::StrokeKind::Inside,
764 );
765 painter.galley(badge.center() - galley.size() / 2.0, galley, ink);
766 right -= size.x + 6.0;
767 }
768 }
769}