Skip to main content

fresh/view/
margin.rs

1use crate::model::marker::{MarkerId, MarkerList};
2use ratatui::style::{Color, Style};
3use std::collections::BTreeMap;
4
5/// Minimum number of digits reserved in the line-number column.
6///
7/// The gutter grows with the buffer's line count, but never shrinks below this
8/// minimum — otherwise a 1-line buffer would render with a single-character
9/// number column that feels cramped next to the indicator and separator.
10pub const MIN_LINE_NUMBER_DIGITS: usize = 2;
11
12/// Position of a margin in the editor
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
14pub enum MarginPosition {
15    /// Left margin (before the text)
16    Left,
17    /// Right margin (after the text)
18    Right,
19}
20
21/// A line indicator displayed in the gutter's indicator column
22/// Can be used for git status, breakpoints, bookmarks, etc.
23///
24/// Indicators are anchored to byte positions via markers, so they automatically
25/// shift when text is inserted or deleted before them.
26#[derive(Debug, Clone, PartialEq)]
27pub struct LineIndicator {
28    /// The symbol to display (e.g., "│", "●", "★")
29    pub symbol: String,
30    /// The color of the indicator
31    pub color: Color,
32    /// Priority for display when multiple indicators exist (higher wins)
33    pub priority: i32,
34    /// Marker ID anchoring this indicator to a byte position
35    /// The line number is derived from this position at render time
36    pub marker_id: MarkerId,
37}
38
39impl LineIndicator {
40    /// Create a new line indicator (marker_id will be set when added to MarginManager)
41    pub fn new(symbol: impl Into<String>, color: Color, priority: i32) -> Self {
42        Self {
43            symbol: symbol.into(),
44            color,
45            priority,
46            marker_id: MarkerId(0), // Placeholder, set by MarginManager
47        }
48    }
49
50    /// Create a line indicator with a specific marker ID
51    pub fn with_marker(
52        symbol: impl Into<String>,
53        color: Color,
54        priority: i32,
55        marker_id: MarkerId,
56    ) -> Self {
57        Self {
58            symbol: symbol.into(),
59            color,
60            priority,
61            marker_id,
62        }
63    }
64}
65
66/// Content type for a margin at a specific line
67#[derive(Debug, Clone, PartialEq)]
68pub enum MarginContent {
69    /// Simple text (e.g., line number)
70    Text(String),
71    /// Symbol with optional color (e.g., breakpoint, error indicator)
72    Symbol { text: String, style: Style },
73    /// Multiple items stacked (e.g., line number + breakpoint)
74    Stacked(Vec<MarginContent>),
75    /// Empty/cleared margin
76    Empty,
77}
78
79impl MarginContent {
80    /// Create a simple text margin content
81    pub fn text(text: impl Into<String>) -> Self {
82        Self::Text(text.into())
83    }
84
85    /// Create a symbol with styling
86    pub fn symbol(text: impl Into<String>, style: Style) -> Self {
87        Self::Symbol {
88            text: text.into(),
89            style,
90        }
91    }
92
93    /// Create a colored symbol
94    pub fn colored_symbol(text: impl Into<String>, color: Color) -> Self {
95        Self::Symbol {
96            text: text.into(),
97            style: Style::default().fg(color),
98        }
99    }
100
101    /// Check if this margin content is empty
102    pub fn is_empty(&self) -> bool {
103        matches!(self, Self::Empty)
104    }
105
106    /// Render this margin content to a string with width padding
107    pub fn render(&self, width: usize) -> (String, Option<Style>) {
108        match self {
109            Self::Text(text) => {
110                let padded = format!("{:>width$}", text, width = width);
111                (padded, None)
112            }
113            Self::Symbol { text, style } => {
114                let padded = format!("{:>width$}", text, width = width);
115                (padded, Some(*style))
116            }
117            Self::Stacked(items) => {
118                // For stacked items, render the last non-empty one
119                for item in items.iter().rev() {
120                    if !item.is_empty() {
121                        return item.render(width);
122                    }
123                }
124                (format!("{:>width$}", "", width = width), None)
125            }
126            Self::Empty => (format!("{:>width$}", "", width = width), None),
127        }
128    }
129}
130
131/// Configuration for a margin
132#[derive(Debug, Clone, PartialEq)]
133pub struct MarginConfig {
134    /// Position of the margin (left or right)
135    pub position: MarginPosition,
136
137    /// Width of the margin in characters
138    /// For left margin with line numbers, this is calculated dynamically
139    pub width: usize,
140
141    /// Whether this margin is enabled
142    pub enabled: bool,
143
144    /// Whether to show a separator (e.g., "│") after the margin
145    pub show_separator: bool,
146
147    /// Separator character(s)
148    pub separator: String,
149
150    /// Default style for the margin
151    pub style: Style,
152
153    /// Default separator style
154    pub separator_style: Style,
155}
156
157impl MarginConfig {
158    /// Create a default left margin config (for line numbers)
159    pub fn left_default() -> Self {
160        Self {
161            position: MarginPosition::Left,
162            width: MIN_LINE_NUMBER_DIGITS,
163            enabled: true,
164            show_separator: true,
165            separator: " │ ".to_string(), // Separator with spaces: " │ " (space before for indicators, space after for readability)
166            style: Style::default().fg(Color::DarkGray),
167            separator_style: Style::default().fg(Color::DarkGray),
168        }
169    }
170
171    /// Create a default right margin config
172    pub fn right_default() -> Self {
173        Self {
174            position: MarginPosition::Right,
175            width: 0,
176            enabled: false,
177            show_separator: false,
178            separator: String::new(),
179            style: Style::default(),
180            separator_style: Style::default(),
181        }
182    }
183
184    /// Calculate the total width including indicator column and separator
185    /// Format: [indicator (1 char)][line_number (N chars)][separator (3 chars)]
186    pub fn total_width(&self) -> usize {
187        if self.enabled {
188            // 1 char for indicator column + line number width + separator
189            1 + self.width
190                + if self.show_separator {
191                    self.separator.chars().count()
192                } else {
193                    0
194                }
195        } else {
196            0
197        }
198    }
199}
200
201/// A margin annotation for a specific line
202#[derive(Debug, Clone)]
203pub struct MarginAnnotation {
204    /// The line number (0-indexed)
205    pub line: usize,
206
207    /// The margin position (left or right)
208    pub position: MarginPosition,
209
210    /// The content to display
211    pub content: MarginContent,
212
213    /// Optional ID for this annotation (for removal/updates)
214    pub id: Option<String>,
215}
216
217impl MarginAnnotation {
218    /// Create a new margin annotation
219    pub fn new(line: usize, position: MarginPosition, content: MarginContent) -> Self {
220        Self {
221            line,
222            position,
223            content,
224            id: None,
225        }
226    }
227
228    /// Create an annotation with an ID
229    pub fn with_id(
230        line: usize,
231        position: MarginPosition,
232        content: MarginContent,
233        id: String,
234    ) -> Self {
235        Self {
236            line,
237            position,
238            content,
239            id: Some(id),
240        }
241    }
242
243    /// Helper: Create a line number annotation for the left margin
244    pub fn line_number(line: usize) -> Self {
245        Self::new(
246            line,
247            MarginPosition::Left,
248            MarginContent::text(format!("{}", line + 1)), // 1-indexed display
249        )
250    }
251
252    /// Helper: Create a breakpoint indicator
253    pub fn breakpoint(line: usize) -> Self {
254        Self::new(
255            line,
256            MarginPosition::Left,
257            MarginContent::colored_symbol("●", Color::Red),
258        )
259    }
260
261    /// Helper: Create an error indicator
262    pub fn error(line: usize) -> Self {
263        Self::new(
264            line,
265            MarginPosition::Left,
266            MarginContent::colored_symbol("✗", Color::Red),
267        )
268    }
269
270    /// Helper: Create a warning indicator
271    pub fn warning(line: usize) -> Self {
272        Self::new(
273            line,
274            MarginPosition::Left,
275            MarginContent::colored_symbol("⚠", Color::Yellow),
276        )
277    }
278
279    /// Helper: Create an info indicator
280    pub fn info(line: usize) -> Self {
281        Self::new(
282            line,
283            MarginPosition::Left,
284            MarginContent::colored_symbol("ℹ", Color::Blue),
285        )
286    }
287}
288
289/// Manages margins and annotations for a buffer
290/// This is similar to OverlayManager - a general-purpose primitive for margin decorations
291///
292/// Line indicators use byte-position markers that automatically adjust when the buffer
293/// is edited. This ensures indicators stay anchored to the content they represent.
294#[derive(Debug)]
295pub struct MarginManager {
296    /// Configuration for left margin
297    pub left_config: MarginConfig,
298
299    /// Configuration for right margin
300    pub right_config: MarginConfig,
301
302    /// Annotations per line (left margin)
303    /// Uses BTreeMap for efficient range queries
304    left_annotations: BTreeMap<usize, Vec<MarginAnnotation>>,
305
306    /// Annotations per line (right margin)
307    right_annotations: BTreeMap<usize, Vec<MarginAnnotation>>,
308
309    /// Marker list for tracking indicator positions through edits
310    /// Shared with the buffer's edit tracking
311    indicator_markers: MarkerList,
312
313    /// Line indicators stored by marker ID
314    /// Maps marker_id -> (namespace -> indicator)
315    /// The line number is computed at render time from the marker's byte position
316    line_indicators: BTreeMap<u64, BTreeMap<String, LineIndicator>>,
317}
318
319impl MarginManager {
320    /// Create a new margin manager with default settings
321    pub fn new() -> Self {
322        Self {
323            left_config: MarginConfig::left_default(),
324            right_config: MarginConfig::right_default(),
325            left_annotations: BTreeMap::new(),
326            right_annotations: BTreeMap::new(),
327            indicator_markers: MarkerList::new(),
328            line_indicators: BTreeMap::new(),
329        }
330    }
331
332    /// Create a margin manager with line numbers disabled
333    pub fn without_line_numbers() -> Self {
334        let mut manager = Self::new();
335        manager.left_config.width = 0;
336        manager.left_config.enabled = false;
337        manager
338    }
339
340    // =========================================================================
341    // Edit Propagation - called when buffer content changes
342    // =========================================================================
343
344    /// Adjust all indicator markers after an insertion
345    /// Call this when text is inserted into the buffer
346    pub fn adjust_for_insert(&mut self, position: usize, length: usize) {
347        self.indicator_markers.adjust_for_insert(position, length);
348    }
349
350    /// Adjust all indicator markers after a deletion
351    /// Call this when text is deleted from the buffer
352    pub fn adjust_for_delete(&mut self, position: usize, length: usize) {
353        self.indicator_markers.adjust_for_delete(position, length);
354    }
355
356    /// Set a line indicator at a byte position for a specific namespace
357    ///
358    /// The indicator is anchored to the byte position and will automatically
359    /// shift when text is inserted or deleted before it.
360    ///
361    /// Returns the marker ID that can be used to remove or update the indicator.
362    pub fn set_line_indicator(
363        &mut self,
364        byte_offset: usize,
365        namespace: String,
366        mut indicator: LineIndicator,
367    ) -> MarkerId {
368        // Create a marker at this byte position (left affinity - stays before inserted text)
369        let marker_id = self.indicator_markers.create(byte_offset, true);
370        indicator.marker_id = marker_id;
371
372        self.line_indicators
373            .entry(marker_id.0)
374            .or_default()
375            .insert(namespace, indicator);
376
377        marker_id
378    }
379
380    /// Remove line indicator for a specific namespace at a marker
381    pub fn remove_line_indicator(&mut self, marker_id: MarkerId, namespace: &str) {
382        if let Some(indicators) = self.line_indicators.get_mut(&marker_id.0) {
383            indicators.remove(namespace);
384            if indicators.is_empty() {
385                self.line_indicators.remove(&marker_id.0);
386                self.indicator_markers.delete(marker_id);
387            }
388        }
389    }
390
391    /// Clear all line indicators for a specific namespace
392    pub fn clear_line_indicators_for_namespace(&mut self, namespace: &str) {
393        // Collect marker IDs to delete (can't modify while iterating)
394        let mut markers_to_delete = Vec::new();
395
396        for (&marker_id, indicators) in self.line_indicators.iter_mut() {
397            indicators.remove(namespace);
398            if indicators.is_empty() {
399                markers_to_delete.push(marker_id);
400            }
401        }
402
403        // Delete empty marker entries and their markers
404        for marker_id in markers_to_delete {
405            self.line_indicators.remove(&marker_id);
406            self.indicator_markers.delete(MarkerId(marker_id));
407        }
408    }
409
410    /// Get the line indicator for a specific line number
411    ///
412    /// This looks up all indicators whose markers resolve to the given line.
413    /// Returns the highest priority indicator if multiple exist on the same line.
414    ///
415    /// Note: This is O(n) in the number of indicators. For rendering, prefer
416    /// `get_indicators_in_viewport` which is more efficient.
417    pub fn get_line_indicator(
418        &self,
419        line: usize,
420        get_line_fn: impl Fn(usize) -> usize,
421    ) -> Option<&LineIndicator> {
422        // Find all indicators on this line
423        let mut best: Option<&LineIndicator> = None;
424
425        for (&marker_id, indicators) in &self.line_indicators {
426            if let Some(byte_pos) = self.indicator_markers.get_position(MarkerId(marker_id)) {
427                let indicator_line = get_line_fn(byte_pos);
428                if indicator_line == line {
429                    // Found an indicator on this line, check if it's higher priority
430                    for indicator in indicators.values() {
431                        if best.is_none() || indicator.priority > best.unwrap().priority {
432                            best = Some(indicator);
433                        }
434                    }
435                }
436            }
437        }
438
439        best
440    }
441
442    /// Get indicators within a viewport byte range
443    ///
444    /// Only queries markers within `viewport_start..viewport_end`, avoiding
445    /// iteration over the entire indicator set.
446    ///
447    /// Returns a map of line_number -> highest priority indicator for that line.
448    /// The `get_line_fn` converts byte offsets to line numbers.
449    pub fn get_indicators_for_viewport(
450        &self,
451        viewport_start: usize,
452        viewport_end: usize,
453        get_line_fn: impl Fn(usize) -> usize,
454    ) -> BTreeMap<usize, LineIndicator> {
455        let mut by_line: BTreeMap<usize, LineIndicator> = BTreeMap::new();
456
457        // Query only markers within the viewport byte range
458        for (marker_id, byte_pos, _end) in self
459            .indicator_markers
460            .query_range(viewport_start, viewport_end)
461        {
462            // Look up the indicators for this marker
463            if let Some(indicators) = self.line_indicators.get(&marker_id.0) {
464                let line = get_line_fn(byte_pos);
465
466                // Get highest priority indicator for this marker
467                if let Some(indicator) = indicators.values().max_by_key(|ind| ind.priority) {
468                    // Check if this is higher priority than existing indicator on this line
469                    if let Some(existing) = by_line.get(&line) {
470                        if indicator.priority > existing.priority {
471                            by_line.insert(line, indicator.clone());
472                        }
473                    } else {
474                        by_line.insert(line, indicator.clone());
475                    }
476                }
477            }
478        }
479
480        by_line
481    }
482
483    /// Get the byte position of a line indicator's marker.
484    ///
485    /// Returns the current byte offset for the given marker ID, or None if the
486    /// marker doesn't exist. Useful for testing that marker positions survive
487    /// undo/redo correctly.
488    pub fn get_indicator_position(&self, marker_id: MarkerId) -> Option<usize> {
489        self.indicator_markers.get_position(marker_id)
490    }
491
492    /// Query indicator markers in a byte range.
493    /// Returns (MarkerId, start, end) tuples for markers in the range.
494    pub fn query_indicator_range(&self, start: usize, end: usize) -> Vec<(MarkerId, usize, usize)> {
495        self.indicator_markers.query_range(start, end)
496    }
497
498    /// Move a line indicator's marker to a new byte position.
499    ///
500    /// This is a no-op if the marker doesn't exist in the indicator markers.
501    /// Used to restore displaced markers after undo.
502    pub fn set_indicator_position(&mut self, marker_id: MarkerId, new_position: usize) {
503        // Only move if this marker is actually tracked by margins
504        if self.line_indicators.contains_key(&marker_id.0) {
505            self.indicator_markers.set_position(marker_id, new_position);
506        }
507    }
508
509    /// Add an annotation to a margin
510    pub fn add_annotation(&mut self, annotation: MarginAnnotation) {
511        let annotations = match annotation.position {
512            MarginPosition::Left => &mut self.left_annotations,
513            MarginPosition::Right => &mut self.right_annotations,
514        };
515
516        annotations
517            .entry(annotation.line)
518            .or_insert_with(Vec::new)
519            .push(annotation);
520    }
521
522    /// Remove all annotations with a specific ID
523    pub fn remove_by_id(&mut self, id: &str) {
524        // Remove from left annotations
525        for annotations in self.left_annotations.values_mut() {
526            annotations.retain(|a| a.id.as_deref() != Some(id));
527        }
528
529        // Remove from right annotations
530        for annotations in self.right_annotations.values_mut() {
531            annotations.retain(|a| a.id.as_deref() != Some(id));
532        }
533
534        // Clean up empty entries
535        self.left_annotations.retain(|_, v| !v.is_empty());
536        self.right_annotations.retain(|_, v| !v.is_empty());
537    }
538
539    /// Remove all annotations at a specific line
540    pub fn remove_at_line(&mut self, line: usize, position: MarginPosition) {
541        match position {
542            MarginPosition::Left => {
543                self.left_annotations.remove(&line);
544            }
545            MarginPosition::Right => {
546                self.right_annotations.remove(&line);
547            }
548        }
549    }
550
551    /// Clear all annotations in a position
552    pub fn clear_position(&mut self, position: MarginPosition) {
553        match position {
554            MarginPosition::Left => self.left_annotations.clear(),
555            MarginPosition::Right => self.right_annotations.clear(),
556        }
557    }
558
559    /// Clear all annotations
560    pub fn clear_all(&mut self) {
561        self.left_annotations.clear();
562        self.right_annotations.clear();
563    }
564
565    /// Get all annotations at a specific line
566    pub fn get_at_line(
567        &self,
568        line: usize,
569        position: MarginPosition,
570    ) -> Option<&[MarginAnnotation]> {
571        let annotations = match position {
572            MarginPosition::Left => &self.left_annotations,
573            MarginPosition::Right => &self.right_annotations,
574        };
575        annotations.get(&line).map(|v| v.as_slice())
576    }
577
578    /// Get the content to render for a specific line in a margin.
579    /// If `show_line_numbers` is true and position is Left, includes line number.
580    pub fn render_line(
581        &self,
582        line: usize,
583        position: MarginPosition,
584        _buffer_total_lines: usize,
585        show_line_numbers: bool,
586    ) -> MarginContent {
587        let annotations = match position {
588            MarginPosition::Left => &self.left_annotations,
589            MarginPosition::Right => &self.right_annotations,
590        };
591
592        // Get user annotations
593        let user_annotations = annotations.get(&line).cloned().unwrap_or_default();
594
595        // For left margin, combine with line numbers if enabled
596        if position == MarginPosition::Left && show_line_numbers {
597            let line_num = MarginContent::text(format!("{}", line + 1));
598
599            if user_annotations.is_empty() {
600                return line_num;
601            }
602
603            // Stack line number with user annotations
604            let mut stack = vec![line_num];
605            stack.extend(user_annotations.into_iter().map(|a| a.content));
606            MarginContent::Stacked(stack)
607        } else if let Some(annotation) = user_annotations.first() {
608            annotation.content.clone()
609        } else {
610            MarginContent::Empty
611        }
612    }
613
614    /// Update the left margin width based on buffer size.
615    /// Only adjusts width when `show_line_numbers` is true.
616    pub fn update_width_for_buffer(&mut self, buffer_total_lines: usize, show_line_numbers: bool) {
617        if show_line_numbers {
618            let digits = if buffer_total_lines == 0 {
619                1
620            } else {
621                ((buffer_total_lines as f64).log10().floor() as usize) + 1
622            };
623            self.left_config.width = digits.max(MIN_LINE_NUMBER_DIGITS);
624        }
625    }
626
627    /// Get the total width of the left margin (including separator)
628    /// The separator includes the diagnostic indicator when present
629    pub fn left_total_width(&self) -> usize {
630        self.left_config.total_width()
631    }
632
633    /// Get the total width of the right margin (including separator)
634    pub fn right_total_width(&self) -> usize {
635        self.right_config.total_width()
636    }
637
638    /// Configure left margin layout for line number visibility.
639    ///
640    /// This adjusts `left_config.enabled` and `left_config.width` so that
641    /// `left_total_width()` returns the correct gutter size for the given
642    /// `show_line_numbers` setting. Called at render time with the per-split
643    /// line number state.
644    pub fn configure_for_line_numbers(&mut self, show_line_numbers: bool) {
645        if !show_line_numbers {
646            self.left_config.width = 0;
647            self.left_config.enabled = false;
648        } else {
649            self.left_config.enabled = true;
650            if self.left_config.width == 0 {
651                self.left_config.width = MIN_LINE_NUMBER_DIGITS;
652            }
653        }
654    }
655
656    /// Get the number of annotations in a position
657    pub fn annotation_count(&self, position: MarginPosition) -> usize {
658        match position {
659            MarginPosition::Left => self.left_annotations.values().map(|v| v.len()).sum(),
660            MarginPosition::Right => self.right_annotations.values().map(|v| v.len()).sum(),
661        }
662    }
663}
664
665impl Default for MarginManager {
666    fn default() -> Self {
667        Self::new()
668    }
669}
670
671#[cfg(test)]
672mod tests {
673    use super::*;
674
675    #[test]
676    fn test_margin_content_text() {
677        let content = MarginContent::text("123");
678        let (rendered, style) = content.render(5);
679        assert_eq!(rendered, "  123");
680        assert!(style.is_none());
681    }
682
683    #[test]
684    fn test_margin_content_symbol() {
685        let content = MarginContent::colored_symbol("●", Color::Red);
686        let (rendered, style) = content.render(3);
687        assert_eq!(rendered, "  ●");
688        assert!(style.is_some());
689    }
690
691    #[test]
692    fn test_margin_config_total_width() {
693        let mut config = MarginConfig::left_default();
694        config.width = 4;
695        config.separator = " │ ".to_string();
696        assert_eq!(config.total_width(), 8); // 1 (indicator) + 4 (line num) + 3 (separator)
697
698        config.show_separator = false;
699        assert_eq!(config.total_width(), 5); // 1 (indicator) + 4 (line num)
700
701        config.enabled = false;
702        assert_eq!(config.total_width(), 0);
703    }
704
705    #[test]
706    fn test_margin_annotation_helpers() {
707        let line_num = MarginAnnotation::line_number(5);
708        assert_eq!(line_num.line, 5);
709        assert_eq!(line_num.position, MarginPosition::Left);
710
711        let breakpoint = MarginAnnotation::breakpoint(10);
712        assert_eq!(breakpoint.line, 10);
713        assert_eq!(breakpoint.position, MarginPosition::Left);
714    }
715
716    #[test]
717    fn test_margin_manager_add_remove() {
718        let mut manager = MarginManager::new();
719
720        // Add annotation
721        let annotation = MarginAnnotation::line_number(5);
722        manager.add_annotation(annotation);
723
724        assert_eq!(manager.annotation_count(MarginPosition::Left), 1);
725
726        // Add annotation with ID
727        let annotation = MarginAnnotation::with_id(
728            10,
729            MarginPosition::Left,
730            MarginContent::text("test"),
731            "test-id".to_string(),
732        );
733        manager.add_annotation(annotation);
734
735        assert_eq!(manager.annotation_count(MarginPosition::Left), 2);
736
737        // Remove by ID
738        manager.remove_by_id("test-id");
739        assert_eq!(manager.annotation_count(MarginPosition::Left), 1);
740
741        // Clear all
742        manager.clear_all();
743        assert_eq!(manager.annotation_count(MarginPosition::Left), 0);
744    }
745
746    #[test]
747    fn test_margin_manager_render_line() {
748        let mut manager = MarginManager::new();
749
750        // Without annotations, should render line number when show_line_numbers=true
751        let content = manager.render_line(5, MarginPosition::Left, 100, true);
752        let (rendered, _) = content.render(4);
753        assert!(rendered.contains("6")); // Line 5 is displayed as "6" (1-indexed)
754
755        // Add a breakpoint annotation
756        manager.add_annotation(MarginAnnotation::breakpoint(5));
757
758        // Should now render stacked content (line number + breakpoint)
759        let content = manager.render_line(5, MarginPosition::Left, 100, true);
760        assert!(matches!(content, MarginContent::Stacked(_)));
761    }
762
763    #[test]
764    fn test_margin_manager_update_width() {
765        let mut manager = MarginManager::new();
766
767        // Single-line buffer — clamped to the minimum
768        manager.update_width_for_buffer(1, true);
769        assert_eq!(manager.left_config.width, MIN_LINE_NUMBER_DIGITS);
770
771        // Two-digit buffer
772        manager.update_width_for_buffer(99, true);
773        assert_eq!(manager.left_config.width, 2);
774
775        // Three-digit buffer — now tracks the actual digit count rather than
776        // padding to a fixed minimum of 4 (see issue #1204).
777        manager.update_width_for_buffer(500, true);
778        assert_eq!(manager.left_config.width, 3);
779
780        // Medium buffer (4 digits)
781        manager.update_width_for_buffer(1000, true);
782        assert_eq!(manager.left_config.width, 4);
783
784        // Large buffer (5 digits)
785        manager.update_width_for_buffer(10000, true);
786        assert_eq!(manager.left_config.width, 5);
787
788        // Very large buffer (7 digits)
789        manager.update_width_for_buffer(1000000, true);
790        assert_eq!(manager.left_config.width, 7);
791    }
792
793    #[test]
794    fn test_margin_manager_total_width_adapts_to_buffer() {
795        // Regression test for issue #1204: the rendered gutter width should
796        // scale with the actual line count instead of being pinned to a fixed
797        // minimum of 4 digits.
798        let mut manager = MarginManager::new();
799
800        // Small buffer — 2-digit minimum, gutter = 1 (indicator) + 2 + 3 (" │ ")
801        manager.update_width_for_buffer(10, true);
802        assert_eq!(manager.left_total_width(), 6);
803
804        // 3-digit line count — gutter grows to 7
805        manager.update_width_for_buffer(250, true);
806        assert_eq!(manager.left_total_width(), 7);
807
808        // 4-digit line count — gutter grows to 8
809        manager.update_width_for_buffer(5000, true);
810        assert_eq!(manager.left_total_width(), 8);
811    }
812
813    #[test]
814    fn test_margin_manager_without_line_numbers() {
815        let manager = MarginManager::without_line_numbers();
816        assert!(!manager.left_config.enabled);
817
818        let content = manager.render_line(5, MarginPosition::Left, 100, false);
819        assert!(content.is_empty());
820    }
821
822    #[test]
823    fn test_margin_position_left_right() {
824        let mut manager = MarginManager::new();
825
826        manager.add_annotation(MarginAnnotation::new(
827            1,
828            MarginPosition::Left,
829            MarginContent::text("left"),
830        ));
831
832        manager.add_annotation(MarginAnnotation::new(
833            1,
834            MarginPosition::Right,
835            MarginContent::text("right"),
836        ));
837
838        assert_eq!(manager.annotation_count(MarginPosition::Left), 1);
839        assert_eq!(manager.annotation_count(MarginPosition::Right), 1);
840
841        manager.clear_position(MarginPosition::Left);
842        assert_eq!(manager.annotation_count(MarginPosition::Left), 0);
843        assert_eq!(manager.annotation_count(MarginPosition::Right), 1);
844    }
845
846    // Helper: simulates a buffer where each line is 10 bytes (9 chars + newline)
847    // Line 0 = bytes 0-9, Line 1 = bytes 10-19, etc.
848    fn byte_to_line(byte_offset: usize) -> usize {
849        byte_offset / 10
850    }
851
852    // Helper: get byte offset for start of a line
853    fn line_to_byte(line: usize) -> usize {
854        line * 10
855    }
856
857    #[test]
858    fn test_line_indicator_basic() {
859        let mut manager = MarginManager::new();
860
861        // Add a line indicator at byte offset 50 (line 5 in our simulated buffer)
862        let indicator = LineIndicator::new("│", Color::Green, 10);
863        manager.set_line_indicator(line_to_byte(5), "git-gutter".to_string(), indicator);
864
865        // Check it can be retrieved on line 5
866        let retrieved = manager.get_line_indicator(5, byte_to_line);
867        assert!(retrieved.is_some());
868        let retrieved = retrieved.unwrap();
869        assert_eq!(retrieved.symbol, "│");
870        assert_eq!(retrieved.color, Color::Green);
871        assert_eq!(retrieved.priority, 10);
872
873        // Non-existent line should return None
874        assert!(manager.get_line_indicator(10, byte_to_line).is_none());
875    }
876
877    #[test]
878    fn test_line_indicator_multiple_namespaces() {
879        let mut manager = MarginManager::new();
880
881        // Add indicators from different namespaces at the same byte position (line 5)
882        let git_indicator = LineIndicator::new("│", Color::Green, 10);
883        let breakpoint_indicator = LineIndicator::new("●", Color::Red, 20);
884
885        manager.set_line_indicator(line_to_byte(5), "git-gutter".to_string(), git_indicator);
886        manager.set_line_indicator(
887            line_to_byte(5),
888            "breakpoints".to_string(),
889            breakpoint_indicator,
890        );
891
892        // Should return the highest priority indicator
893        let retrieved = manager.get_line_indicator(5, byte_to_line);
894        assert!(retrieved.is_some());
895        let retrieved = retrieved.unwrap();
896        assert_eq!(retrieved.symbol, "●"); // Breakpoint has higher priority
897        assert_eq!(retrieved.priority, 20);
898    }
899
900    #[test]
901    fn test_line_indicator_clear_namespace() {
902        let mut manager = MarginManager::new();
903
904        // Add indicators on multiple lines
905        manager.set_line_indicator(
906            line_to_byte(1),
907            "git-gutter".to_string(),
908            LineIndicator::new("│", Color::Green, 10),
909        );
910        manager.set_line_indicator(
911            line_to_byte(2),
912            "git-gutter".to_string(),
913            LineIndicator::new("│", Color::Yellow, 10),
914        );
915        manager.set_line_indicator(
916            line_to_byte(3),
917            "breakpoints".to_string(),
918            LineIndicator::new("●", Color::Red, 20),
919        );
920
921        // Clear git-gutter namespace
922        manager.clear_line_indicators_for_namespace("git-gutter");
923
924        // Git gutter indicators should be gone
925        assert!(manager.get_line_indicator(1, byte_to_line).is_none());
926        assert!(manager.get_line_indicator(2, byte_to_line).is_none());
927
928        // Breakpoint should still be there
929        let breakpoint = manager.get_line_indicator(3, byte_to_line);
930        assert!(breakpoint.is_some());
931        assert_eq!(breakpoint.unwrap().symbol, "●");
932    }
933
934    #[test]
935    fn test_line_indicator_remove_specific() {
936        let mut manager = MarginManager::new();
937
938        // Add two indicators at the same byte position (line 5)
939        let git_marker = manager.set_line_indicator(
940            line_to_byte(5),
941            "git-gutter".to_string(),
942            LineIndicator::new("│", Color::Green, 10),
943        );
944        let bp_marker = manager.set_line_indicator(
945            line_to_byte(5),
946            "breakpoints".to_string(),
947            LineIndicator::new("●", Color::Red, 20),
948        );
949
950        // Remove just the git-gutter indicator
951        manager.remove_line_indicator(git_marker, "git-gutter");
952
953        // Should still have the breakpoint indicator on line 5
954        let retrieved = manager.get_line_indicator(5, byte_to_line);
955        assert!(retrieved.is_some());
956        assert_eq!(retrieved.unwrap().symbol, "●");
957
958        // Remove the breakpoint indicator too
959        manager.remove_line_indicator(bp_marker, "breakpoints");
960
961        // Now no indicators on line 5
962        assert!(manager.get_line_indicator(5, byte_to_line).is_none());
963    }
964
965    #[test]
966    fn test_line_indicator_shifts_on_insert() {
967        let mut manager = MarginManager::new();
968
969        // Add indicator on line 5 (byte 50)
970        manager.set_line_indicator(
971            line_to_byte(5),
972            "git-gutter".to_string(),
973            LineIndicator::new("│", Color::Green, 10),
974        );
975
976        // Verify it's on line 5
977        assert!(manager.get_line_indicator(5, byte_to_line).is_some());
978        assert!(manager.get_line_indicator(6, byte_to_line).is_none());
979
980        // Insert 10 bytes (one line) at the beginning
981        manager.adjust_for_insert(0, 10);
982
983        // Now indicator should be on line 6 (shifted down by 1)
984        assert!(manager.get_line_indicator(5, byte_to_line).is_none());
985        assert!(manager.get_line_indicator(6, byte_to_line).is_some());
986    }
987
988    #[test]
989    fn test_line_indicator_shifts_on_delete() {
990        let mut manager = MarginManager::new();
991
992        // Add indicator on line 5 (byte 50)
993        manager.set_line_indicator(
994            line_to_byte(5),
995            "git-gutter".to_string(),
996            LineIndicator::new("│", Color::Green, 10),
997        );
998
999        // Verify it's on line 5
1000        assert!(manager.get_line_indicator(5, byte_to_line).is_some());
1001
1002        // Delete first 20 bytes (2 lines)
1003        manager.adjust_for_delete(0, 20);
1004
1005        // Now indicator should be on line 3 (shifted up by 2)
1006        assert!(manager.get_line_indicator(5, byte_to_line).is_none());
1007        assert!(manager.get_line_indicator(3, byte_to_line).is_some());
1008    }
1009
1010    #[test]
1011    fn test_multiple_indicators_shift_together() {
1012        let mut manager = MarginManager::new();
1013
1014        // Add indicators on lines 3, 5, and 7
1015        manager.set_line_indicator(
1016            line_to_byte(3),
1017            "git-gutter".to_string(),
1018            LineIndicator::new("│", Color::Green, 10),
1019        );
1020        manager.set_line_indicator(
1021            line_to_byte(5),
1022            "git-gutter".to_string(),
1023            LineIndicator::new("│", Color::Yellow, 10),
1024        );
1025        manager.set_line_indicator(
1026            line_to_byte(7),
1027            "git-gutter".to_string(),
1028            LineIndicator::new("│", Color::Red, 10),
1029        );
1030
1031        // Insert 2 lines (20 bytes) at byte 25 (middle of line 2)
1032        // This should shift lines 3, 5, 7 -> lines 5, 7, 9
1033        manager.adjust_for_insert(25, 20);
1034
1035        // Old positions should be empty
1036        assert!(manager.get_line_indicator(3, byte_to_line).is_none());
1037
1038        // New positions should have indicators
1039        assert!(manager.get_line_indicator(5, byte_to_line).is_some());
1040        assert!(manager.get_line_indicator(7, byte_to_line).is_some());
1041        assert!(manager.get_line_indicator(9, byte_to_line).is_some());
1042    }
1043}