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