Skip to content

Commit dfb7b94

Browse files
committed
Add History panel for undo/redo stack inspection
New renzora_history dynamic plugin showing the active undo stack with a clickable list — past actions, current state highlight, and pending redos. Clicking a row jumps to that point by replaying the appropriate number of undo/redo operations through EditorCommands. Also exposes UndoStacks::labels() for read-only stack introspection and darkens the dark theme's selection_stroke to RGB (43, 109, 163) so the current-row highlight reads as an obvious blue rather than washed-out.
1 parent c5fd231 commit dfb7b94

5 files changed

Lines changed: 291 additions & 1 deletion

File tree

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ members = [
4545
"crates/renzora_auth",
4646
"crates/renzora_shape_library",
4747
"crates/renzora_system_monitor",
48+
"crates/renzora_history",
4849
]
4950
# Note: crates/renzora_editor produces the editor SDK
5051

crates/renzora_history/Cargo.toml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
[package]
2+
name = "renzora_history"
3+
version = "0.1.0"
4+
edition = "2021"
5+
6+
[lib]
7+
crate-type = ["dylib"]
8+
9+
[dependencies]
10+
renzora_undo = { path = "../renzora_undo" }
11+
renzora_theme = { path = "../renzora_theme" }
12+
egui-phosphor = { workspace = true }
13+
bevy_egui = { workspace = true }
14+
renzora_editor_framework = { path = "../renzora_editor_framework" }
15+
bevy = { workspace = true }
16+
renzora = { path = "../renzora", default-features = false, features = ["editor"] }

crates/renzora_history/src/lib.rs

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
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);

crates/renzora_theme/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ impl Default for SemanticColors {
184184
warning: ThemeColor::new(242, 166, 64),
185185
error: ThemeColor::new(230, 89, 89),
186186
selection: ThemeColor::new(69, 101, 151),
187-
selection_stroke: ThemeColor::new(100, 180, 255),
187+
selection_stroke: ThemeColor::new(43, 109, 163),
188188
}
189189
}
190190
}

crates/renzora_undo/src/lib.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,20 @@ impl UndoStacks {
7171
pub fn can_redo(&self, context: &UndoContext) -> bool {
7272
self.stacks.get(context).map_or(false, |s| !s.redo.is_empty())
7373
}
74+
/// Returns `(undo_labels, redo_labels)` for the given context.
75+
/// `undo` is ordered front=oldest → back=most recent;
76+
/// `redo` is ordered front=oldest-undone → back=next-to-redo.
77+
pub fn labels(&self, context: &UndoContext) -> (Vec<String>, Vec<String>) {
78+
self.stacks
79+
.get(context)
80+
.map(|s| {
81+
(
82+
s.undo.iter().map(|c| c.label().to_string()).collect(),
83+
s.redo.iter().map(|c| c.label().to_string()).collect(),
84+
)
85+
})
86+
.unwrap_or_default()
87+
}
7488
}
7589

7690
/// Execute `cmd` and push it onto the active (or supplied) stack.

0 commit comments

Comments
 (0)