|
| 1 | +//! History panel — view and jump through the undo/redo stack. |
| 2 | +
|
| 3 | +use bevy::prelude::*; |
| 4 | +use bevy_egui::egui::{self, Color32, CornerRadius, CursorIcon, RichText, Sense}; |
| 5 | +use egui_phosphor::regular; |
| 6 | +use renzora_editor_framework::{ |
| 7 | + empty_state, AppEditorExt, EditorCommands, EditorPanel, PanelLocation, |
| 8 | +}; |
| 9 | +use renzora_theme::{Theme, ThemeManager}; |
| 10 | +use renzora_undo::UndoStacks; |
| 11 | + |
| 12 | +enum Action { |
| 13 | + Undo(usize), |
| 14 | + Redo(usize), |
| 15 | +} |
| 16 | + |
| 17 | +pub struct HistoryPanel; |
| 18 | + |
| 19 | +impl Default for HistoryPanel { |
| 20 | + fn default() -> Self { |
| 21 | + Self |
| 22 | + } |
| 23 | +} |
| 24 | + |
| 25 | +impl EditorPanel for HistoryPanel { |
| 26 | + fn id(&self) -> &str { |
| 27 | + "history" |
| 28 | + } |
| 29 | + |
| 30 | + fn title(&self) -> &str { |
| 31 | + "History" |
| 32 | + } |
| 33 | + |
| 34 | + fn icon(&self) -> Option<&str> { |
| 35 | + Some(regular::CLOCK_COUNTER_CLOCKWISE) |
| 36 | + } |
| 37 | + |
| 38 | + fn ui(&self, ui: &mut egui::Ui, world: &World) { |
| 39 | + let theme = match world.get_resource::<ThemeManager>() { |
| 40 | + Some(tm) => tm.active_theme.clone(), |
| 41 | + None => return, |
| 42 | + }; |
| 43 | + |
| 44 | + let Some(stacks) = world.get_resource::<UndoStacks>() else { |
| 45 | + return; |
| 46 | + }; |
| 47 | + let (undo, redo) = stacks.labels(&stacks.active); |
| 48 | + let cmds = world.get_resource::<EditorCommands>(); |
| 49 | + |
| 50 | + if undo.is_empty() && redo.is_empty() { |
| 51 | + empty_state( |
| 52 | + ui, |
| 53 | + regular::CLOCK_COUNTER_CLOCKWISE, |
| 54 | + "No History", |
| 55 | + "Actions you perform will appear here.", |
| 56 | + &theme, |
| 57 | + ); |
| 58 | + return; |
| 59 | + } |
| 60 | + |
| 61 | + let mut requested: Option<Action> = None; |
| 62 | + let n_undo = undo.len(); |
| 63 | + |
| 64 | + egui::ScrollArea::vertical() |
| 65 | + .id_salt("history_scroll") |
| 66 | + .auto_shrink([false, false]) |
| 67 | + .show(ui, |ui| { |
| 68 | + ui.spacing_mut().item_spacing.y = 0.0; |
| 69 | + ui.add_space(4.0); |
| 70 | + |
| 71 | + section_header(ui, &theme, "Undo Stack"); |
| 72 | + if n_undo <= 1 { |
| 73 | + empty_section_hint(ui, &theme, "No earlier states."); |
| 74 | + } else { |
| 75 | + // Render undo[0..n_undo-1] — exclude the most recent which IS the current state. |
| 76 | + for (i, label) in undo.iter().take(n_undo - 1).enumerate() { |
| 77 | + if history_row( |
| 78 | + ui, |
| 79 | + &theme, |
| 80 | + regular::ARROW_BEND_UP_LEFT, |
| 81 | + label, |
| 82 | + RowKind::Past, |
| 83 | + ) { |
| 84 | + // Undo enough so this entry becomes the new current. |
| 85 | + requested = Some(Action::Undo(n_undo - 1 - i)); |
| 86 | + } |
| 87 | + } |
| 88 | + } |
| 89 | + |
| 90 | + section_header(ui, &theme, "Current State"); |
| 91 | + let current_label = undo |
| 92 | + .last() |
| 93 | + .map(|s| s.as_str()) |
| 94 | + .unwrap_or("Initial state"); |
| 95 | + history_row( |
| 96 | + ui, |
| 97 | + &theme, |
| 98 | + regular::CARET_RIGHT, |
| 99 | + current_label, |
| 100 | + RowKind::Current, |
| 101 | + ); |
| 102 | + |
| 103 | + section_header(ui, &theme, "Redo Stack"); |
| 104 | + if redo.is_empty() { |
| 105 | + empty_section_hint(ui, &theme, "Nothing to redo."); |
| 106 | + } else { |
| 107 | + // Most-immediate redo first (back of deque). |
| 108 | + for (i, label) in redo.iter().rev().enumerate() { |
| 109 | + if history_row( |
| 110 | + ui, |
| 111 | + &theme, |
| 112 | + regular::ARROW_BEND_UP_RIGHT, |
| 113 | + label, |
| 114 | + RowKind::Future, |
| 115 | + ) { |
| 116 | + requested = Some(Action::Redo(i + 1)); |
| 117 | + } |
| 118 | + } |
| 119 | + } |
| 120 | + |
| 121 | + ui.add_space(8.0); |
| 122 | + }); |
| 123 | + |
| 124 | + if let (Some(cmds), Some(action)) = (cmds, requested) { |
| 125 | + match action { |
| 126 | + Action::Undo(n) => { |
| 127 | + cmds.push(move |world: &mut World| { |
| 128 | + for _ in 0..n { |
| 129 | + renzora_undo::undo_once(world); |
| 130 | + } |
| 131 | + }); |
| 132 | + } |
| 133 | + Action::Redo(n) => { |
| 134 | + cmds.push(move |world: &mut World| { |
| 135 | + for _ in 0..n { |
| 136 | + renzora_undo::redo_once(world); |
| 137 | + } |
| 138 | + }); |
| 139 | + } |
| 140 | + } |
| 141 | + } |
| 142 | + } |
| 143 | + |
| 144 | + fn closable(&self) -> bool { |
| 145 | + true |
| 146 | + } |
| 147 | + |
| 148 | + fn default_location(&self) -> PanelLocation { |
| 149 | + PanelLocation::Right |
| 150 | + } |
| 151 | +} |
| 152 | + |
| 153 | +#[derive(Copy, Clone, PartialEq)] |
| 154 | +enum RowKind { |
| 155 | + Past, |
| 156 | + Current, |
| 157 | + Future, |
| 158 | +} |
| 159 | + |
| 160 | +fn section_header(ui: &mut egui::Ui, theme: &Theme, label: &str) { |
| 161 | + let text_muted = theme.text.muted.to_color32(); |
| 162 | + let header_bg = theme.panels.category_frame_bg.to_color32(); |
| 163 | + egui::Frame::new() |
| 164 | + .fill(header_bg) |
| 165 | + .corner_radius(CornerRadius::ZERO) |
| 166 | + .inner_margin(egui::Margin::symmetric(8, 4)) |
| 167 | + .show(ui, |ui| { |
| 168 | + ui.horizontal(|ui| { |
| 169 | + ui.label( |
| 170 | + RichText::new(label) |
| 171 | + .size(11.0) |
| 172 | + .strong() |
| 173 | + .color(text_muted), |
| 174 | + ); |
| 175 | + }); |
| 176 | + }); |
| 177 | +} |
| 178 | + |
| 179 | +fn empty_section_hint(ui: &mut egui::Ui, theme: &Theme, text: &str) { |
| 180 | + let muted = theme.text.muted.to_color32(); |
| 181 | + egui::Frame::new() |
| 182 | + .inner_margin(egui::Margin::symmetric(12, 6)) |
| 183 | + .show(ui, |ui| { |
| 184 | + ui.label(RichText::new(text).size(11.0).italics().color(muted)); |
| 185 | + }); |
| 186 | +} |
| 187 | + |
| 188 | +/// Render a single history row. Returns true if it was clicked. |
| 189 | +fn history_row(ui: &mut egui::Ui, theme: &Theme, icon: &str, label: &str, kind: RowKind) -> bool { |
| 190 | + let text_primary = theme.text.primary.to_color32(); |
| 191 | + let text_muted = theme.text.muted.to_color32(); |
| 192 | + let highlight = theme.semantic.selection_stroke.to_color32(); |
| 193 | + |
| 194 | + let (icon_color, label_color, is_current) = match kind { |
| 195 | + RowKind::Current => (highlight, text_primary, true), |
| 196 | + RowKind::Past => (text_muted, text_primary, false), |
| 197 | + RowKind::Future => (text_muted, text_muted, false), |
| 198 | + }; |
| 199 | + |
| 200 | + let sense = if is_current { Sense::hover() } else { Sense::click() }; |
| 201 | + let row_height = 22.0; |
| 202 | + let (rect, response) = |
| 203 | + ui.allocate_exact_size(egui::vec2(ui.available_width(), row_height), sense); |
| 204 | + |
| 205 | + if is_current { |
| 206 | + ui.painter().rect_filled( |
| 207 | + rect, |
| 208 | + CornerRadius::ZERO, |
| 209 | + Color32::from_rgba_premultiplied( |
| 210 | + highlight.r(), |
| 211 | + highlight.g(), |
| 212 | + highlight.b(), |
| 213 | + 40, |
| 214 | + ), |
| 215 | + ); |
| 216 | + } else if response.hovered() { |
| 217 | + ui.painter().rect_filled( |
| 218 | + rect, |
| 219 | + CornerRadius::ZERO, |
| 220 | + Color32::from_rgba_premultiplied(255, 255, 255, 12), |
| 221 | + ); |
| 222 | + ui.ctx().set_cursor_icon(CursorIcon::PointingHand); |
| 223 | + } |
| 224 | + |
| 225 | + let painter = ui.painter_at(rect); |
| 226 | + let mut x = rect.left() + 12.0; |
| 227 | + let y_center = rect.center().y; |
| 228 | + |
| 229 | + painter.text( |
| 230 | + egui::pos2(x, y_center), |
| 231 | + egui::Align2::LEFT_CENTER, |
| 232 | + icon, |
| 233 | + egui::FontId::proportional(12.0), |
| 234 | + icon_color, |
| 235 | + ); |
| 236 | + x += 18.0; |
| 237 | + |
| 238 | + painter.text( |
| 239 | + egui::pos2(x, y_center), |
| 240 | + egui::Align2::LEFT_CENTER, |
| 241 | + label, |
| 242 | + egui::FontId::proportional(12.0), |
| 243 | + label_color, |
| 244 | + ); |
| 245 | + |
| 246 | + response.clicked() |
| 247 | +} |
| 248 | + |
| 249 | +#[derive(Default)] |
| 250 | +pub struct HistoryPanelPlugin; |
| 251 | + |
| 252 | +impl Plugin for HistoryPanelPlugin { |
| 253 | + fn build(&self, app: &mut App) { |
| 254 | + info!("[editor] HistoryPanelPlugin"); |
| 255 | + app.register_panel(HistoryPanel::default()); |
| 256 | + } |
| 257 | +} |
| 258 | + |
| 259 | +renzora::add!(HistoryPanelPlugin, Editor); |
0 commit comments