1#![forbid(unsafe_code)]
2
3use 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#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct HistoryEntry {
29 pub description: String,
31 pub is_redo: bool,
33}
34
35impl HistoryEntry {
36 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
48pub enum HistoryPanelMode {
49 #[default]
51 Compact,
52 Full,
54}
55
56#[derive(Debug, Clone)]
61pub struct HistoryPanel {
62 title: String,
64 undo_items: Vec<String>,
66 redo_items: Vec<String>,
68 mode: HistoryPanelMode,
70 compact_limit: usize,
72 title_style: Style,
74 undo_style: Style,
76 redo_style: Style,
78 marker_style: Style,
80 bg_style: Style,
82 marker_text: String,
84 undo_icon: String,
86 redo_icon: String,
88}
89
90impl Default for HistoryPanel {
91 fn default() -> Self {
92 Self::new()
93 }
94}
95
96impl HistoryPanel {
97 #[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 #[must_use]
119 pub fn with_title(mut self, title: impl Into<String>) -> Self {
120 self.title = title.into();
121 self
122 }
123
124 #[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 #[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 #[must_use]
140 pub fn with_mode(mut self, mode: HistoryPanelMode) -> Self {
141 self.mode = mode;
142 self
143 }
144
145 #[must_use]
147 pub fn with_compact_limit(mut self, limit: usize) -> Self {
148 self.compact_limit = limit;
149 self
150 }
151
152 #[must_use]
154 pub fn with_title_style(mut self, style: Style) -> Self {
155 self.title_style = style;
156 self
157 }
158
159 #[must_use]
161 pub fn with_undo_style(mut self, style: Style) -> Self {
162 self.undo_style = style;
163 self
164 }
165
166 #[must_use]
168 pub fn with_redo_style(mut self, style: Style) -> Self {
169 self.redo_style = style;
170 self
171 }
172
173 #[must_use]
175 pub fn with_marker_style(mut self, style: Style) -> Self {
176 self.marker_style = style;
177 self
178 }
179
180 #[must_use]
182 pub fn with_bg_style(mut self, style: Style) -> Self {
183 self.bg_style = style;
184 self
185 }
186
187 #[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 #[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 #[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 #[must_use]
210 pub fn is_empty(&self) -> bool {
211 self.undo_items.is_empty() && self.redo_items.is_empty()
212 }
213
214 #[must_use]
216 pub fn len(&self) -> usize {
217 self.undo_items.len() + self.redo_items.len()
218 }
219
220 #[must_use]
222 pub fn undo_items(&self) -> &[String] {
223 &self.undo_items
224 }
225
226 #[must_use]
228 pub fn redo_items(&self) -> &[String] {
229 &self.redo_items
230 }
231
232 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 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 if row < area.height {
249 row += 1;
250 }
251 }
252
253 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 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 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 if row < area.height {
290 let y = area.y.saturating_add(row);
291 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 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 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 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); }
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 let cell = frame.buffer.get(0, 0).unwrap();
417 assert_eq!(cell.content.as_char(), Some('H')); }
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); }
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); }
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}