Skip to main content

pfx_editor/
ui.rs

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 &section.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}