Skip to main content

ftui_widgets/
history_panel.rs

1#![forbid(unsafe_code)]
2
3//! History panel widget for displaying undo/redo command history.
4//!
5//! Renders a styled list of command descriptions showing the undo/redo history
6//! stack. The current position in the history is marked to indicate what will
7//! be undone/redone next.
8//!
9//! # Example
10//!
11//! ```ignore
12//! use ftui_widgets::history_panel::HistoryPanel;
13//!
14//! let panel = HistoryPanel::new()
15//!     .with_undo_items(&["Insert text", "Delete word"])
16//!     .with_redo_items(&["Paste"])
17//!     .with_title("History");
18//! ```
19
20use crate::{Widget, draw_text_span};
21use ftui_core::geometry::Rect;
22use ftui_render::frame::Frame;
23use ftui_style::Style;
24use ftui_text::wrap::display_width;
25
26/// A single entry in the history panel.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct HistoryEntry {
29    /// Description of the command.
30    pub description: String,
31    /// Whether this entry is in the undo or redo stack.
32    pub is_redo: bool,
33}
34
35impl HistoryEntry {
36    /// Create a new history entry.
37    #[must_use]
38    pub fn new(description: impl Into<String>, is_redo: bool) -> Self {
39        Self {
40            description: description.into(),
41            is_redo,
42        }
43    }
44}
45
46/// Display mode for the history panel.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
48pub enum HistoryPanelMode {
49    /// Compact mode: shows only the most recent undo/redo items.
50    #[default]
51    Compact,
52    /// Full mode: shows the complete history stack.
53    Full,
54}
55
56/// History panel widget that displays undo/redo command history.
57///
58/// The panel shows commands in chronological order with the current position
59/// marked. Commands above the marker can be undone, commands below can be redone.
60#[derive(Debug, Clone)]
61pub struct HistoryPanel {
62    /// Title displayed at the top of the panel.
63    title: String,
64    /// Entries in the undo stack (oldest first).
65    undo_items: Vec<String>,
66    /// Entries in the redo stack (oldest first).
67    redo_items: Vec<String>,
68    /// Display mode.
69    mode: HistoryPanelMode,
70    /// Maximum items to show in compact mode.
71    compact_limit: usize,
72    /// Style for the title.
73    title_style: Style,
74    /// Style for undo items.
75    undo_style: Style,
76    /// Style for redo items (dimmed, as they are "future" commands).
77    redo_style: Style,
78    /// Style for the current position marker.
79    marker_style: Style,
80    /// Style for the panel background.
81    bg_style: Style,
82    /// Current position marker text.
83    marker_text: String,
84    /// Undo icon prefix.
85    undo_icon: String,
86    /// Redo icon prefix.
87    redo_icon: String,
88}
89
90impl Default for HistoryPanel {
91    fn default() -> Self {
92        Self::new()
93    }
94}
95
96impl HistoryPanel {
97    /// Create a new history panel with no entries.
98    #[must_use]
99    pub fn new() -> Self {
100        Self {
101            title: "History".to_string(),
102            undo_items: Vec::new(),
103            redo_items: Vec::new(),
104            mode: HistoryPanelMode::Compact,
105            compact_limit: 5,
106            title_style: Style::new().bold(),
107            undo_style: Style::default(),
108            redo_style: Style::new().dim(),
109            marker_style: Style::new().bold(),
110            bg_style: Style::default(),
111            marker_text: "─── current ───".to_string(),
112            undo_icon: "↶ ".to_string(),
113            redo_icon: "↷ ".to_string(),
114        }
115    }
116
117    /// Set the panel title.
118    #[must_use]
119    pub fn with_title(mut self, title: impl Into<String>) -> Self {
120        self.title = title.into();
121        self
122    }
123
124    /// Set the undo items (descriptions from oldest to newest).
125    #[must_use]
126    pub fn with_undo_items(mut self, items: &[impl AsRef<str>]) -> Self {
127        self.undo_items = items.iter().map(|s| s.as_ref().to_string()).collect();
128        self
129    }
130
131    /// Set the redo items (descriptions from oldest to newest).
132    #[must_use]
133    pub fn with_redo_items(mut self, items: &[impl AsRef<str>]) -> Self {
134        self.redo_items = items.iter().map(|s| s.as_ref().to_string()).collect();
135        self
136    }
137
138    /// Set the display mode.
139    #[must_use]
140    pub fn with_mode(mut self, mode: HistoryPanelMode) -> Self {
141        self.mode = mode;
142        self
143    }
144
145    /// Set the compact mode limit.
146    #[must_use]
147    pub fn with_compact_limit(mut self, limit: usize) -> Self {
148        self.compact_limit = limit;
149        self
150    }
151
152    /// Set the title style.
153    #[must_use]
154    pub fn with_title_style(mut self, style: Style) -> Self {
155        self.title_style = style;
156        self
157    }
158
159    /// Set the undo items style.
160    #[must_use]
161    pub fn with_undo_style(mut self, style: Style) -> Self {
162        self.undo_style = style;
163        self
164    }
165
166    /// Set the redo items style.
167    #[must_use]
168    pub fn with_redo_style(mut self, style: Style) -> Self {
169        self.redo_style = style;
170        self
171    }
172
173    /// Set the marker style.
174    #[must_use]
175    pub fn with_marker_style(mut self, style: Style) -> Self {
176        self.marker_style = style;
177        self
178    }
179
180    /// Set the background style.
181    #[must_use]
182    pub fn with_bg_style(mut self, style: Style) -> Self {
183        self.bg_style = style;
184        self
185    }
186
187    /// Set the marker text.
188    #[must_use]
189    pub fn with_marker_text(mut self, text: impl Into<String>) -> Self {
190        self.marker_text = text.into();
191        self
192    }
193
194    /// Set the undo icon prefix.
195    #[must_use]
196    pub fn with_undo_icon(mut self, icon: impl Into<String>) -> Self {
197        self.undo_icon = icon.into();
198        self
199    }
200
201    /// Set the redo icon prefix.
202    #[must_use]
203    pub fn with_redo_icon(mut self, icon: impl Into<String>) -> Self {
204        self.redo_icon = icon.into();
205        self
206    }
207
208    /// Check if there are any history items.
209    #[must_use]
210    pub fn is_empty(&self) -> bool {
211        self.undo_items.is_empty() && self.redo_items.is_empty()
212    }
213
214    /// Get the total number of items.
215    #[must_use]
216    pub fn len(&self) -> usize {
217        self.undo_items.len() + self.redo_items.len()
218    }
219
220    /// Get the undo stack items.
221    #[must_use]
222    pub fn undo_items(&self) -> &[String] {
223        &self.undo_items
224    }
225
226    /// Get the redo stack items.
227    #[must_use]
228    pub fn redo_items(&self) -> &[String] {
229        &self.redo_items
230    }
231
232    /// Render the panel content.
233    fn render_content(&self, area: Rect, frame: &mut Frame) {
234        if area.width == 0 || area.height == 0 {
235            return;
236        }
237
238        let max_x = area.right();
239        let mut row: u16 = 0;
240
241        // Title
242        if row < area.height && !self.title.is_empty() {
243            let y = area.y.saturating_add(row);
244            draw_text_span(frame, area.x, y, &self.title, self.title_style, max_x);
245            row += 1;
246
247            // Blank line after title
248            if row < area.height {
249                row += 1;
250            }
251        }
252
253        // Determine which items to show based on mode
254        let (undo_to_show, redo_to_show) = match self.mode {
255            HistoryPanelMode::Compact => {
256                let half_limit = self.compact_limit / 2;
257                let undo_start = self.undo_items.len().saturating_sub(half_limit);
258                let redo_end = half_limit.min(self.redo_items.len());
259                (&self.undo_items[undo_start..], &self.redo_items[..redo_end])
260            }
261            HistoryPanelMode::Full => (&self.undo_items[..], &self.redo_items[..]),
262        };
263
264        // Show ellipsis if there are hidden undo items
265        if self.mode == HistoryPanelMode::Compact
266            && undo_to_show.len() < self.undo_items.len()
267            && row < area.height
268        {
269            let y = area.y.saturating_add(row);
270            let hidden = self.undo_items.len() - undo_to_show.len();
271            let text = format!("... ({} more)", hidden);
272            draw_text_span(frame, area.x, y, &text, self.redo_style, max_x);
273            row += 1;
274        }
275
276        // Undo items (oldest first, so they appear top-to-bottom chronologically)
277        for desc in undo_to_show {
278            if row >= area.height {
279                break;
280            }
281            let y = area.y.saturating_add(row);
282            let icon_end =
283                draw_text_span(frame, area.x, y, &self.undo_icon, self.undo_style, max_x);
284            draw_text_span(frame, icon_end, y, desc, self.undo_style, max_x);
285            row += 1;
286        }
287
288        // Current position marker
289        if row < area.height {
290            let y = area.y.saturating_add(row);
291            // Center the marker
292            let marker_width = display_width(&self.marker_text);
293            let available = area.width as usize;
294            let pad_left = available.saturating_sub(marker_width) / 2;
295            let x = area.x.saturating_add(pad_left as u16);
296            draw_text_span(frame, x, y, &self.marker_text, self.marker_style, max_x);
297            row += 1;
298        }
299
300        // Redo items (these are "future" commands that can be redone)
301        for desc in redo_to_show {
302            if row >= area.height {
303                break;
304            }
305            let y = area.y.saturating_add(row);
306            let icon_end =
307                draw_text_span(frame, area.x, y, &self.redo_icon, self.redo_style, max_x);
308            draw_text_span(frame, icon_end, y, desc, self.redo_style, max_x);
309            row += 1;
310        }
311
312        // Show ellipsis if there are hidden redo items
313        if self.mode == HistoryPanelMode::Compact
314            && redo_to_show.len() < self.redo_items.len()
315            && row < area.height
316        {
317            let y = area.y.saturating_add(row);
318            let hidden = self.redo_items.len() - redo_to_show.len();
319            let text = format!("... ({} more)", hidden);
320            draw_text_span(frame, area.x, y, &text, self.redo_style, max_x);
321        }
322    }
323}
324
325impl Widget for HistoryPanel {
326    fn render(&self, area: Rect, frame: &mut Frame) {
327        // Fill background if style is set
328        if let Some(bg) = self.bg_style.bg {
329            for y in area.y..area.bottom() {
330                for x in area.x..area.right() {
331                    if let Some(cell) = frame.buffer.get_mut(x, y) {
332                        cell.bg = bg;
333                    }
334                }
335            }
336        }
337
338        self.render_content(area, frame);
339    }
340
341    fn is_essential(&self) -> bool {
342        false
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349    use ftui_render::frame::Frame;
350    use ftui_render::grapheme_pool::GraphemePool;
351
352    #[test]
353    fn new_panel_is_empty() {
354        let panel = HistoryPanel::new();
355        assert!(panel.is_empty());
356        assert_eq!(panel.len(), 0);
357    }
358
359    #[test]
360    fn with_undo_items() {
361        let panel = HistoryPanel::new().with_undo_items(&["Insert text", "Delete word"]);
362        assert_eq!(panel.undo_items().len(), 2);
363        assert_eq!(panel.undo_items()[0], "Insert text");
364        assert_eq!(panel.len(), 2);
365    }
366
367    #[test]
368    fn with_redo_items() {
369        let panel = HistoryPanel::new().with_redo_items(&["Paste"]);
370        assert_eq!(panel.redo_items().len(), 1);
371        assert_eq!(panel.len(), 1);
372    }
373
374    #[test]
375    fn with_both_stacks() {
376        let panel = HistoryPanel::new()
377            .with_undo_items(&["A", "B"])
378            .with_redo_items(&["C"]);
379        assert!(!panel.is_empty());
380        assert_eq!(panel.len(), 3);
381    }
382
383    #[test]
384    fn with_title() {
385        let panel = HistoryPanel::new().with_title("My History");
386        assert_eq!(panel.title, "My History");
387    }
388
389    #[test]
390    fn with_mode() {
391        let panel = HistoryPanel::new().with_mode(HistoryPanelMode::Full);
392        assert_eq!(panel.mode, HistoryPanelMode::Full);
393    }
394
395    #[test]
396    fn render_empty() {
397        let panel = HistoryPanel::new();
398        let mut pool = GraphemePool::new();
399        let mut frame = Frame::new(30, 10, &mut pool);
400        let area = Rect::new(0, 0, 30, 10);
401        panel.render(area, &mut frame); // Should not panic
402    }
403
404    #[test]
405    fn render_with_items() {
406        let panel = HistoryPanel::new()
407            .with_undo_items(&["Insert text"])
408            .with_redo_items(&["Delete word"]);
409
410        let mut pool = GraphemePool::new();
411        let mut frame = Frame::new(30, 10, &mut pool);
412        let area = Rect::new(0, 0, 30, 10);
413        panel.render(area, &mut frame);
414
415        // Verify title appears
416        let cell = frame.buffer.get(0, 0).unwrap();
417        assert_eq!(cell.content.as_char(), Some('H')); // "History"
418    }
419
420    #[test]
421    fn render_zero_area() {
422        let panel = HistoryPanel::new().with_undo_items(&["Test"]);
423        let mut pool = GraphemePool::new();
424        let mut frame = Frame::new(30, 10, &mut pool);
425        let area = Rect::new(0, 0, 0, 0);
426        panel.render(area, &mut frame); // Should not panic
427    }
428
429    #[test]
430    fn compact_limit() {
431        let items: Vec<_> = (0..10).map(|i| format!("Item {}", i)).collect();
432        let panel = HistoryPanel::new()
433            .with_mode(HistoryPanelMode::Compact)
434            .with_compact_limit(4)
435            .with_undo_items(&items);
436
437        let mut pool = GraphemePool::new();
438        let mut frame = Frame::new(30, 20, &mut pool);
439        let area = Rect::new(0, 0, 30, 20);
440        panel.render(area, &mut frame); // Should show only last 2 undo items
441    }
442
443    #[test]
444    fn is_not_essential() {
445        let panel = HistoryPanel::new();
446        assert!(!panel.is_essential());
447    }
448
449    #[test]
450    fn default_impl() {
451        let panel = HistoryPanel::default();
452        assert!(panel.is_empty());
453    }
454
455    #[test]
456    fn with_icons() {
457        let panel = HistoryPanel::new()
458            .with_undo_icon("<< ")
459            .with_redo_icon(">> ");
460        assert_eq!(panel.undo_icon, "<< ");
461        assert_eq!(panel.redo_icon, ">> ");
462    }
463
464    #[test]
465    fn with_marker_text() {
466        let panel = HistoryPanel::new().with_marker_text("=== NOW ===");
467        assert_eq!(panel.marker_text, "=== NOW ===");
468    }
469}