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, clear_text_area, 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    #[inline]
210    #[must_use]
211    pub fn is_empty(&self) -> bool {
212        self.undo_items.is_empty() && self.redo_items.is_empty()
213    }
214
215    /// Get the total number of items.
216    #[inline]
217    #[must_use]
218    pub fn len(&self) -> usize {
219        self.undo_items.len() + self.redo_items.len()
220    }
221
222    /// Get the undo stack items.
223    #[must_use]
224    pub fn undo_items(&self) -> &[String] {
225        &self.undo_items
226    }
227
228    /// Get the redo stack items.
229    #[must_use]
230    pub fn redo_items(&self) -> &[String] {
231        &self.redo_items
232    }
233
234    /// Render the panel content.
235    fn render_content(&self, area: Rect, frame: &mut Frame) {
236        if area.width == 0 || area.height == 0 {
237            return;
238        }
239
240        let deg = frame.buffer.degradation;
241        if !deg.render_content() {
242            return;
243        }
244
245        let title_style = if deg.apply_styling() {
246            self.title_style
247        } else {
248            Style::default()
249        };
250        let undo_style = if deg.apply_styling() {
251            self.undo_style
252        } else {
253            Style::default()
254        };
255        let redo_style = if deg.apply_styling() {
256            self.redo_style
257        } else {
258            Style::default()
259        };
260        let marker_style = if deg.apply_styling() {
261            self.marker_style
262        } else {
263            Style::default()
264        };
265
266        let max_x = area.right();
267        let mut row: u16 = 0;
268
269        // Title
270        if row < area.height && !self.title.is_empty() {
271            let y = area.y.saturating_add(row);
272            draw_text_span(frame, area.x, y, &self.title, title_style, max_x);
273            row += 1;
274
275            // Blank line after title
276            if row < area.height {
277                row += 1;
278            }
279        }
280
281        // Determine which items to show based on mode
282        let (undo_to_show, redo_to_show) = match self.mode {
283            HistoryPanelMode::Compact => {
284                let half_limit = self.compact_limit / 2;
285                let undo_start = self.undo_items.len().saturating_sub(half_limit);
286                let redo_end = half_limit.min(self.redo_items.len());
287                (&self.undo_items[undo_start..], &self.redo_items[..redo_end])
288            }
289            HistoryPanelMode::Full => (&self.undo_items[..], &self.redo_items[..]),
290        };
291
292        // Show ellipsis if there are hidden undo items
293        if self.mode == HistoryPanelMode::Compact
294            && undo_to_show.len() < self.undo_items.len()
295            && row < area.height
296        {
297            let y = area.y.saturating_add(row);
298            let hidden = self.undo_items.len() - undo_to_show.len();
299            let text = format!("... ({} more)", hidden);
300            draw_text_span(frame, area.x, y, &text, redo_style, max_x);
301            row += 1;
302        }
303
304        // Undo items (oldest first, so they appear top-to-bottom chronologically)
305        for desc in undo_to_show {
306            if row >= area.height {
307                break;
308            }
309            let y = area.y.saturating_add(row);
310            let icon_end = draw_text_span(frame, area.x, y, &self.undo_icon, undo_style, max_x);
311            draw_text_span(frame, icon_end, y, desc, undo_style, max_x);
312            row += 1;
313        }
314
315        // Current position marker
316        if row < area.height {
317            let y = area.y.saturating_add(row);
318            // Center the marker
319            let marker_width = display_width(&self.marker_text);
320            let available = area.width as usize;
321            let pad_left = available.saturating_sub(marker_width) / 2;
322            let x = area.x.saturating_add(pad_left as u16);
323            draw_text_span(frame, x, y, &self.marker_text, marker_style, max_x);
324            row += 1;
325        }
326
327        // Redo items (these are "future" commands that can be redone)
328        for desc in redo_to_show {
329            if row >= area.height {
330                break;
331            }
332            let y = area.y.saturating_add(row);
333            let icon_end = draw_text_span(frame, area.x, y, &self.redo_icon, redo_style, max_x);
334            draw_text_span(frame, icon_end, y, desc, redo_style, max_x);
335            row += 1;
336        }
337
338        // Show ellipsis if there are hidden redo items
339        if self.mode == HistoryPanelMode::Compact
340            && redo_to_show.len() < self.redo_items.len()
341            && row < area.height
342        {
343            let y = area.y.saturating_add(row);
344            let hidden = self.redo_items.len() - redo_to_show.len();
345            let text = format!("... ({} more)", hidden);
346            draw_text_span(frame, area.x, y, &text, redo_style, max_x);
347        }
348    }
349}
350
351impl Widget for HistoryPanel {
352    fn render(&self, area: Rect, frame: &mut Frame) {
353        let deg = frame.buffer.degradation;
354        if !deg.render_content() {
355            clear_text_area(frame, area, Style::default());
356            return;
357        }
358
359        // Fill background area
360        let mut bg_cell = ftui_render::cell::Cell::from_char(' ');
361        crate::apply_style(
362            &mut bg_cell,
363            if deg.apply_styling() {
364                self.bg_style
365            } else {
366                Style::default()
367            },
368        );
369        frame.buffer.fill(area, bg_cell);
370
371        self.render_content(area, frame);
372    }
373
374    fn is_essential(&self) -> bool {
375        false
376    }
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382    use ftui_render::budget::DegradationLevel;
383    use ftui_render::frame::Frame;
384    use ftui_render::grapheme_pool::GraphemePool;
385
386    #[test]
387    fn new_panel_is_empty() {
388        let panel = HistoryPanel::new();
389        assert!(panel.is_empty());
390        assert_eq!(panel.len(), 0);
391    }
392
393    #[test]
394    fn with_undo_items() {
395        let panel = HistoryPanel::new().with_undo_items(&["Insert text", "Delete word"]);
396        assert_eq!(panel.undo_items().len(), 2);
397        assert_eq!(panel.undo_items()[0], "Insert text");
398        assert_eq!(panel.len(), 2);
399    }
400
401    #[test]
402    fn with_redo_items() {
403        let panel = HistoryPanel::new().with_redo_items(&["Paste"]);
404        assert_eq!(panel.redo_items().len(), 1);
405        assert_eq!(panel.len(), 1);
406    }
407
408    #[test]
409    fn with_both_stacks() {
410        let panel = HistoryPanel::new()
411            .with_undo_items(&["A", "B"])
412            .with_redo_items(&["C"]);
413        assert!(!panel.is_empty());
414        assert_eq!(panel.len(), 3);
415    }
416
417    #[test]
418    fn with_title() {
419        let panel = HistoryPanel::new().with_title("My History");
420        assert_eq!(panel.title, "My History");
421    }
422
423    #[test]
424    fn with_mode() {
425        let panel = HistoryPanel::new().with_mode(HistoryPanelMode::Full);
426        assert_eq!(panel.mode, HistoryPanelMode::Full);
427    }
428
429    #[test]
430    fn render_empty() {
431        let panel = HistoryPanel::new();
432        let mut pool = GraphemePool::new();
433        let mut frame = Frame::new(30, 10, &mut pool);
434        let area = Rect::new(0, 0, 30, 10);
435        panel.render(area, &mut frame); // Should not panic
436    }
437
438    #[test]
439    fn render_with_items() {
440        let panel = HistoryPanel::new()
441            .with_undo_items(&["Insert text"])
442            .with_redo_items(&["Delete word"]);
443
444        let mut pool = GraphemePool::new();
445        let mut frame = Frame::new(30, 10, &mut pool);
446        let area = Rect::new(0, 0, 30, 10);
447        panel.render(area, &mut frame);
448
449        // Verify title appears
450        let cell = frame.buffer.get(0, 0).unwrap();
451        assert_eq!(cell.content.as_char(), Some('H')); // "History"
452    }
453
454    #[test]
455    fn render_zero_area() {
456        let panel = HistoryPanel::new().with_undo_items(&["Test"]);
457        let mut pool = GraphemePool::new();
458        let mut frame = Frame::new(30, 10, &mut pool);
459        let area = Rect::new(0, 0, 0, 0);
460        panel.render(area, &mut frame); // Should not panic
461    }
462
463    #[test]
464    fn compact_limit() {
465        let items: Vec<_> = (0..10).map(|i| format!("Item {}", i)).collect();
466        let panel = HistoryPanel::new()
467            .with_mode(HistoryPanelMode::Compact)
468            .with_compact_limit(4)
469            .with_undo_items(&items);
470
471        let mut pool = GraphemePool::new();
472        let mut frame = Frame::new(30, 20, &mut pool);
473        let area = Rect::new(0, 0, 30, 20);
474        panel.render(area, &mut frame); // Should show only last 2 undo items
475    }
476
477    #[test]
478    fn is_not_essential() {
479        let panel = HistoryPanel::new();
480        assert!(!panel.is_essential());
481    }
482
483    #[test]
484    fn render_no_styling_drops_configured_styles() {
485        let fg = ftui_render::cell::PackedRgba::rgb(255, 0, 0);
486        let bg = ftui_render::cell::PackedRgba::rgb(0, 40, 80);
487        let panel = HistoryPanel::new()
488            .with_undo_items(&["Insert text"])
489            .with_title_style(Style::new().fg(fg).bold())
490            .with_undo_style(Style::new().fg(fg))
491            .with_marker_style(Style::new().fg(fg).italic())
492            .with_bg_style(Style::new().bg(bg));
493        let mut pool = GraphemePool::new();
494        let mut frame = Frame::new(30, 10, &mut pool);
495        frame.buffer.degradation = DegradationLevel::NoStyling;
496        let area = Rect::new(0, 0, 30, 10);
497
498        panel.render(area, &mut frame);
499
500        let title_cell = frame.buffer.get(0, 0).unwrap();
501        let background_cell = frame.buffer.get(15, 5).unwrap();
502        let default_text = ftui_render::cell::Cell::from_char('H');
503        let default_bg = ftui_render::cell::Cell::from_char(' ');
504
505        assert_eq!(title_cell.content.as_char(), Some('H'));
506        assert_eq!(title_cell.fg, default_text.fg);
507        assert_eq!(title_cell.bg, default_text.bg);
508        assert_eq!(title_cell.attrs, default_text.attrs);
509        assert_eq!(background_cell.fg, default_bg.fg);
510        assert_eq!(background_cell.bg, default_bg.bg);
511        assert_eq!(background_cell.attrs, default_bg.attrs);
512    }
513
514    #[test]
515    fn render_skeleton_is_noop() {
516        let panel = HistoryPanel::new().with_undo_items(&["Insert text"]);
517        let mut pool = GraphemePool::new();
518        let mut frame = Frame::new(30, 10, &mut pool);
519        let area = Rect::new(0, 0, 30, 10);
520        panel.render(area, &mut frame);
521
522        frame.buffer.degradation = DegradationLevel::Skeleton;
523        panel.render(area, &mut frame);
524
525        let cell = frame.buffer.get(0, 0).unwrap();
526        assert_eq!(cell.content.as_char(), Some(' '));
527        let default_cell = ftui_render::cell::Cell::from_char(' ');
528        assert_eq!(cell.fg, default_cell.fg);
529        assert_eq!(cell.bg, default_cell.bg);
530        assert_eq!(cell.attrs, default_cell.attrs);
531    }
532
533    #[test]
534    fn render_shorter_history_clears_stale_rows() {
535        let long = HistoryPanel::new()
536            .with_undo_items(&["Insert text", "Delete line", "Paste block"])
537            .with_redo_items(&["Redo thing"]);
538        let short = HistoryPanel::new().with_undo_items(&["Insert"]);
539        let mut pool = GraphemePool::new();
540        let mut frame = Frame::new(30, 10, &mut pool);
541        let area = Rect::new(0, 0, 30, 10);
542
543        long.render(area, &mut frame);
544        short.render(area, &mut frame);
545
546        for y in 6..10u16 {
547            for x in 0..30u16 {
548                assert_eq!(frame.buffer.get(x, y).unwrap().content.as_char(), Some(' '));
549            }
550        }
551    }
552
553    #[test]
554    fn default_impl() {
555        let panel = HistoryPanel::default();
556        assert!(panel.is_empty());
557    }
558
559    #[test]
560    fn with_icons() {
561        let panel = HistoryPanel::new()
562            .with_undo_icon("<< ")
563            .with_redo_icon(">> ");
564        assert_eq!(panel.undo_icon, "<< ");
565        assert_eq!(panel.redo_icon, ">> ");
566    }
567
568    #[test]
569    fn with_marker_text() {
570        let panel = HistoryPanel::new().with_marker_text("=== NOW ===");
571        assert_eq!(panel.marker_text, "=== NOW ===");
572    }
573
574    #[test]
575    fn history_entry_new() {
576        let entry = HistoryEntry::new("Delete line", false);
577        assert_eq!(entry.description, "Delete line");
578        assert!(!entry.is_redo);
579
580        let redo = HistoryEntry::new("Paste", true);
581        assert!(redo.is_redo);
582    }
583
584    #[test]
585    fn history_panel_mode_default_is_compact() {
586        assert_eq!(HistoryPanelMode::default(), HistoryPanelMode::Compact);
587    }
588
589    #[test]
590    fn with_compact_limit_setter() {
591        let panel = HistoryPanel::new().with_compact_limit(10);
592        assert_eq!(panel.compact_limit, 10);
593    }
594
595    #[test]
596    fn history_entry_equality() {
597        let a = HistoryEntry::new("X", false);
598        let b = HistoryEntry::new("X", false);
599        let c = HistoryEntry::new("X", true);
600        assert_eq!(a, b);
601        assert_ne!(a, c);
602    }
603
604    #[test]
605    fn full_mode_renders_all_items() {
606        let items: Vec<_> = (0..10).map(|i| format!("Item {i}")).collect();
607        let panel = HistoryPanel::new()
608            .with_mode(HistoryPanelMode::Full)
609            .with_undo_items(&items);
610
611        let mut pool = GraphemePool::new();
612        let mut frame = Frame::new(30, 30, &mut pool);
613        let area = Rect::new(0, 0, 30, 30);
614        panel.render(area, &mut frame); // Should not panic in full mode
615    }
616
617    // ── Edge-case tests (bd-2yn6z) ──────────────────────────
618
619    #[test]
620    fn style_setters_applied() {
621        let style = Style::new().italic();
622        let panel = HistoryPanel::new()
623            .with_title_style(style)
624            .with_undo_style(style)
625            .with_redo_style(style)
626            .with_marker_style(style)
627            .with_bg_style(style);
628        assert_eq!(panel.title_style, style);
629        assert_eq!(panel.undo_style, style);
630        assert_eq!(panel.redo_style, style);
631        assert_eq!(panel.marker_style, style);
632        assert_eq!(panel.bg_style, style);
633    }
634
635    #[test]
636    fn clone_preserves_all_fields() {
637        let panel = HistoryPanel::new()
638            .with_title("T")
639            .with_undo_items(&["A"])
640            .with_redo_items(&["B"])
641            .with_mode(HistoryPanelMode::Full)
642            .with_compact_limit(3)
643            .with_marker_text("NOW")
644            .with_undo_icon("U ")
645            .with_redo_icon("R ");
646        let cloned = panel.clone();
647        assert_eq!(cloned.title, "T");
648        assert_eq!(cloned.undo_items, vec!["A"]);
649        assert_eq!(cloned.redo_items, vec!["B"]);
650        assert_eq!(cloned.mode, HistoryPanelMode::Full);
651        assert_eq!(cloned.compact_limit, 3);
652        assert_eq!(cloned.marker_text, "NOW");
653        assert_eq!(cloned.undo_icon, "U ");
654        assert_eq!(cloned.redo_icon, "R ");
655    }
656
657    #[test]
658    fn debug_format() {
659        let panel = HistoryPanel::new();
660        let dbg = format!("{:?}", panel);
661        assert!(dbg.contains("HistoryPanel"));
662        assert!(dbg.contains("History"));
663
664        let entry = HistoryEntry::new("X", true);
665        let dbg_e = format!("{:?}", entry);
666        assert!(dbg_e.contains("HistoryEntry"));
667        assert!(dbg_e.contains("is_redo: true"));
668
669        let mode = HistoryPanelMode::Compact;
670        assert!(format!("{:?}", mode).contains("Compact"));
671    }
672
673    #[test]
674    fn history_entry_clone() {
675        let a = HistoryEntry::new("Hello", false);
676        let b = a.clone();
677        assert_eq!(a, b);
678        assert_eq!(b.description, "Hello");
679    }
680
681    #[test]
682    fn history_panel_mode_copy_eq() {
683        let a = HistoryPanelMode::Full;
684        let b = a; // Copy
685        assert_eq!(a, b);
686        assert_ne!(a, HistoryPanelMode::Compact);
687    }
688
689    #[test]
690    fn render_only_redo_no_undo() {
691        let panel = HistoryPanel::new().with_redo_items(&["Redo1", "Redo2"]);
692        let mut pool = GraphemePool::new();
693        let mut frame = Frame::new(30, 10, &mut pool);
694        let area = Rect::new(0, 0, 30, 10);
695        panel.render(area, &mut frame);
696        // Title on row 0, blank on row 1, marker on row 2, redo items on rows 3-4
697        let cell = frame.buffer.get(0, 0).unwrap();
698        assert_eq!(cell.content.as_char(), Some('H')); // "History"
699    }
700
701    #[test]
702    fn render_empty_title() {
703        let panel = HistoryPanel::new().with_title("").with_undo_items(&["A"]);
704        let mut pool = GraphemePool::new();
705        let mut frame = Frame::new(30, 10, &mut pool);
706        let area = Rect::new(0, 0, 30, 10);
707        panel.render(area, &mut frame);
708        // With empty title, undo icon should start at row 0
709        let cell = frame.buffer.get(0, 0).unwrap();
710        // First char should be undo icon '↶'
711        assert_ne!(cell.content.as_char(), Some('H'));
712    }
713
714    #[test]
715    fn compact_both_stacks_overflow() {
716        let undo: Vec<_> = (0..8).map(|i| format!("U{i}")).collect();
717        let redo: Vec<_> = (0..8).map(|i| format!("R{i}")).collect();
718        let panel = HistoryPanel::new()
719            .with_mode(HistoryPanelMode::Compact)
720            .with_compact_limit(4)
721            .with_undo_items(&undo)
722            .with_redo_items(&redo);
723        let mut pool = GraphemePool::new();
724        let mut frame = Frame::new(40, 20, &mut pool);
725        let area = Rect::new(0, 0, 40, 20);
726        panel.render(area, &mut frame);
727        // Should show: title, blank, "... (6 more)", 2 undo items, marker, 2 redo items, "... (6 more)"
728    }
729
730    #[test]
731    fn compact_limit_zero() {
732        let panel = HistoryPanel::new()
733            .with_compact_limit(0)
734            .with_undo_items(&["A", "B"])
735            .with_redo_items(&["C"]);
736        let mut pool = GraphemePool::new();
737        let mut frame = Frame::new(30, 10, &mut pool);
738        let area = Rect::new(0, 0, 30, 10);
739        panel.render(area, &mut frame); // half_limit=0, no items shown
740    }
741
742    #[test]
743    fn compact_limit_one_odd() {
744        let panel = HistoryPanel::new()
745            .with_compact_limit(1)
746            .with_undo_items(&["A", "B", "C"])
747            .with_redo_items(&["D", "E"]);
748        let mut pool = GraphemePool::new();
749        let mut frame = Frame::new(30, 10, &mut pool);
750        let area = Rect::new(0, 0, 30, 10);
751        // half_limit = 0 (1/2 = 0 in integer division), so nothing shown
752        panel.render(area, &mut frame);
753    }
754
755    #[test]
756    fn render_width_one() {
757        let panel = HistoryPanel::new()
758            .with_undo_items(&["LongItem"])
759            .with_redo_items(&["AnotherLong"]);
760        let mut pool = GraphemePool::new();
761        let mut frame = Frame::new(30, 10, &mut pool);
762        let area = Rect::new(0, 0, 1, 10);
763        panel.render(area, &mut frame); // Should not panic, content truncated
764    }
765
766    #[test]
767    fn render_height_one() {
768        let panel = HistoryPanel::new()
769            .with_undo_items(&["A"])
770            .with_redo_items(&["B"]);
771        let mut pool = GraphemePool::new();
772        let mut frame = Frame::new(30, 10, &mut pool);
773        let area = Rect::new(0, 0, 30, 1);
774        panel.render(area, &mut frame); // Only title fits
775    }
776
777    #[test]
778    fn render_height_three_no_room_for_redo() {
779        let panel = HistoryPanel::new()
780            .with_undo_items(&["A"])
781            .with_redo_items(&["B"]);
782        let mut pool = GraphemePool::new();
783        let mut frame = Frame::new(30, 10, &mut pool);
784        // title(1) + blank(1) + undo(1) = 3, marker and redo don't fit
785        let area = Rect::new(0, 0, 30, 3);
786        panel.render(area, &mut frame);
787    }
788
789    #[test]
790    fn bg_style_fills_area() {
791        use ftui_render::cell::PackedRgba;
792        let red = PackedRgba::rgb(255, 0, 0);
793        let panel = HistoryPanel::new().with_bg_style(Style::new().bg(red));
794        let mut pool = GraphemePool::new();
795        let mut frame = Frame::new(10, 5, &mut pool);
796        let area = Rect::new(0, 0, 10, 5);
797        panel.render(area, &mut frame);
798        // All cells in area should have red background
799        for y in 0..5u16 {
800            for x in 0..10u16 {
801                let cell = frame.buffer.get(x, y).unwrap();
802                assert_eq!(cell.bg, red);
803            }
804        }
805    }
806
807    #[test]
808    fn bg_style_none_does_not_fill() {
809        use ftui_render::cell::PackedRgba;
810        let panel = HistoryPanel::new();
811        let mut pool = GraphemePool::new();
812        let mut frame = Frame::new(10, 5, &mut pool);
813        let area = Rect::new(0, 0, 10, 5);
814        panel.render(area, &mut frame);
815        // Default bg should remain transparent
816        let cell = frame.buffer.get(5, 3).unwrap();
817        assert_eq!(cell.bg, PackedRgba::TRANSPARENT);
818    }
819
820    #[test]
821    fn marker_centering_even_width() {
822        let panel = HistoryPanel::new()
823            .with_title("")
824            .with_marker_text("XX")
825            .with_undo_items(&["A"]);
826        let mut pool = GraphemePool::new();
827        let mut frame = Frame::new(20, 10, &mut pool);
828        let area = Rect::new(0, 0, 20, 10);
829        panel.render(area, &mut frame);
830        // "XX" is 2 chars wide, area is 20. pad_left = (20 - 2) / 2 = 9
831        // marker starts at x=9
832        let cell_before = frame.buffer.get(8, 1).unwrap();
833        assert_ne!(cell_before.content.as_char(), Some('X'));
834        let cell_start = frame.buffer.get(9, 1).unwrap();
835        assert_eq!(cell_start.content.as_char(), Some('X'));
836    }
837
838    #[test]
839    fn marker_wider_than_area() {
840        let panel = HistoryPanel::new()
841            .with_title("")
842            .with_marker_text("VERY LONG MARKER TEXT THAT EXCEEDS");
843        let mut pool = GraphemePool::new();
844        let mut frame = Frame::new(10, 5, &mut pool);
845        let area = Rect::new(0, 0, 10, 5);
846        // marker_width > available, pad_left = 0 (saturating_sub)
847        panel.render(area, &mut frame);
848    }
849
850    #[test]
851    fn overwrite_items_replaces() {
852        let panel = HistoryPanel::new()
853            .with_undo_items(&["Old1", "Old2"])
854            .with_undo_items(&["New1"]);
855        assert_eq!(panel.undo_items().len(), 1);
856        assert_eq!(panel.undo_items()[0], "New1");
857    }
858
859    #[test]
860    fn render_at_offset_area() {
861        let panel = HistoryPanel::new()
862            .with_undo_items(&["A"])
863            .with_redo_items(&["B"]);
864        let mut pool = GraphemePool::new();
865        let mut frame = Frame::new(30, 20, &mut pool);
866        let area = Rect::new(5, 5, 20, 10);
867        panel.render(area, &mut frame);
868        // Title should be at (5, 5)
869        let cell = frame.buffer.get(5, 5).unwrap();
870        assert_eq!(cell.content.as_char(), Some('H'));
871        // (0, 0) should not have been written by the panel
872        let origin = frame.buffer.get(0, 0).unwrap();
873        assert_ne!(origin.content.as_char(), Some('H'));
874    }
875
876    #[test]
877    fn empty_undo_icon_and_redo_icon() {
878        let panel = HistoryPanel::new()
879            .with_undo_icon("")
880            .with_redo_icon("")
881            .with_undo_items(&["A"])
882            .with_redo_items(&["B"]);
883        let mut pool = GraphemePool::new();
884        let mut frame = Frame::new(30, 10, &mut pool);
885        let area = Rect::new(0, 0, 30, 10);
886        panel.render(area, &mut frame);
887    }
888
889    #[test]
890    fn full_mode_no_ellipsis() {
891        let undo: Vec<_> = (0..10).map(|i| format!("U{i}")).collect();
892        let redo: Vec<_> = (0..10).map(|i| format!("R{i}")).collect();
893        let panel = HistoryPanel::new()
894            .with_mode(HistoryPanelMode::Full)
895            .with_undo_items(&undo)
896            .with_redo_items(&redo);
897        let mut pool = GraphemePool::new();
898        let mut frame = Frame::new(30, 30, &mut pool);
899        let area = Rect::new(0, 0, 30, 30);
900        panel.render(area, &mut frame);
901        // In full mode, all items should show without ellipsis
902    }
903
904    #[test]
905    fn compact_undo_only_with_overflow() {
906        let items: Vec<_> = (0..10).map(|i| format!("Item{i}")).collect();
907        let panel = HistoryPanel::new()
908            .with_mode(HistoryPanelMode::Compact)
909            .with_compact_limit(4)
910            .with_undo_items(&items);
911        // half_limit = 2, shows last 2 of 10 undo items + ellipsis
912        let mut pool = GraphemePool::new();
913        let mut frame = Frame::new(30, 15, &mut pool);
914        let area = Rect::new(0, 0, 30, 15);
915        panel.render(area, &mut frame);
916    }
917
918    #[test]
919    fn compact_redo_only_with_overflow() {
920        let items: Vec<_> = (0..10).map(|i| format!("Item{i}")).collect();
921        let panel = HistoryPanel::new()
922            .with_mode(HistoryPanelMode::Compact)
923            .with_compact_limit(4)
924            .with_redo_items(&items);
925        // half_limit = 2, shows first 2 of 10 redo items + ellipsis
926        let mut pool = GraphemePool::new();
927        let mut frame = Frame::new(30, 15, &mut pool);
928        let area = Rect::new(0, 0, 30, 15);
929        panel.render(area, &mut frame);
930    }
931
932    #[test]
933    fn history_entry_from_string_type() {
934        let entry = HistoryEntry::new(String::from("Owned"), false);
935        assert_eq!(entry.description, "Owned");
936    }
937}