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    /// Namespaces under which `marker_id` has a stored indicator.
493    /// Exposed for the semantic-test marker lookup which queries by
494    /// namespace string rather than `MarkerId`.
495    pub fn namespaces_for_marker(&self, marker_id: MarkerId) -> Vec<String> {
496        self.line_indicators
497            .get(&marker_id.0)
498            .map(|indicators| indicators.keys().cloned().collect())
499            .unwrap_or_default()
500    }
501
502    /// Query indicator markers in a byte range.
503    /// Returns (MarkerId, start, end) tuples for markers in the range.
504    pub fn query_indicator_range(&self, start: usize, end: usize) -> Vec<(MarkerId, usize, usize)> {
505        self.indicator_markers.query_range(start, end)
506    }
507
508    /// Move a line indicator's marker to a new byte position.
509    ///
510    /// This is a no-op if the marker doesn't exist in the indicator markers.
511    /// Used to restore displaced markers after undo.
512    pub fn set_indicator_position(&mut self, marker_id: MarkerId, new_position: usize) {
513        // Only move if this marker is actually tracked by margins
514        if self.line_indicators.contains_key(&marker_id.0) {
515            self.indicator_markers.set_position(marker_id, new_position);
516        }
517    }
518
519    /// Add an annotation to a margin
520    pub fn add_annotation(&mut self, annotation: MarginAnnotation) {
521        let annotations = match annotation.position {
522            MarginPosition::Left => &mut self.left_annotations,
523            MarginPosition::Right => &mut self.right_annotations,
524        };
525
526        annotations
527            .entry(annotation.line)
528            .or_insert_with(Vec::new)
529            .push(annotation);
530    }
531
532    /// Remove all annotations with a specific ID
533    pub fn remove_by_id(&mut self, id: &str) {
534        // Remove from left annotations
535        for annotations in self.left_annotations.values_mut() {
536            annotations.retain(|a| a.id.as_deref() != Some(id));
537        }
538
539        // Remove from right annotations
540        for annotations in self.right_annotations.values_mut() {
541            annotations.retain(|a| a.id.as_deref() != Some(id));
542        }
543
544        // Clean up empty entries
545        self.left_annotations.retain(|_, v| !v.is_empty());
546        self.right_annotations.retain(|_, v| !v.is_empty());
547    }
548
549    /// Remove all annotations at a specific line
550    pub fn remove_at_line(&mut self, line: usize, position: MarginPosition) {
551        match position {
552            MarginPosition::Left => {
553                self.left_annotations.remove(&line);
554            }
555            MarginPosition::Right => {
556                self.right_annotations.remove(&line);
557            }
558        }
559    }
560
561    /// Clear all annotations in a position
562    pub fn clear_position(&mut self, position: MarginPosition) {
563        match position {
564            MarginPosition::Left => self.left_annotations.clear(),
565            MarginPosition::Right => self.right_annotations.clear(),
566        }
567    }
568
569    /// Clear all annotations
570    pub fn clear_all(&mut self) {
571        self.left_annotations.clear();
572        self.right_annotations.clear();
573    }
574
575    /// Get all annotations at a specific line
576    pub fn get_at_line(
577        &self,
578        line: usize,
579        position: MarginPosition,
580    ) -> Option<&[MarginAnnotation]> {
581        let annotations = match position {
582            MarginPosition::Left => &self.left_annotations,
583            MarginPosition::Right => &self.right_annotations,
584        };
585        annotations.get(&line).map(|v| v.as_slice())
586    }
587
588    /// Get the content to render for a specific line in a margin.
589    /// If `show_line_numbers` is true and position is Left, includes line number.
590    pub fn render_line(
591        &self,
592        line: usize,
593        position: MarginPosition,
594        _buffer_total_lines: usize,
595        show_line_numbers: bool,
596    ) -> MarginContent {
597        let annotations = match position {
598            MarginPosition::Left => &self.left_annotations,
599            MarginPosition::Right => &self.right_annotations,
600        };
601
602        // Get user annotations
603        let user_annotations = annotations.get(&line).cloned().unwrap_or_default();
604
605        // For left margin, combine with line numbers if enabled
606        if position == MarginPosition::Left && show_line_numbers {
607            let line_num = MarginContent::text(format!("{}", line + 1));
608
609            if user_annotations.is_empty() {
610                return line_num;
611            }
612
613            // Stack line number with user annotations
614            let mut stack = vec![line_num];
615            stack.extend(user_annotations.into_iter().map(|a| a.content));
616            MarginContent::Stacked(stack)
617        } else if let Some(annotation) = user_annotations.first() {
618            annotation.content.clone()
619        } else {
620            MarginContent::Empty
621        }
622    }
623
624    /// Update the left margin width based on buffer size.
625    /// Only adjusts width when `show_line_numbers` is true.
626    pub fn update_width_for_buffer(&mut self, buffer_total_lines: usize, show_line_numbers: bool) {
627        if show_line_numbers {
628            let digits = if buffer_total_lines == 0 {
629                1
630            } else {
631                ((buffer_total_lines as f64).log10().floor() as usize) + 1
632            };
633            self.left_config.width = digits.max(MIN_LINE_NUMBER_DIGITS);
634        }
635    }
636
637    /// Get the total width of the left margin (including separator)
638    /// The separator includes the diagnostic indicator when present
639    pub fn left_total_width(&self) -> usize {
640        self.left_config.total_width()
641    }
642
643    /// Get the total width of the right margin (including separator)
644    pub fn right_total_width(&self) -> usize {
645        self.right_config.total_width()
646    }
647
648    /// Configure left margin layout for line number visibility.
649    ///
650    /// This adjusts `left_config.enabled` and `left_config.width` so that
651    /// `left_total_width()` returns the correct gutter size for the given
652    /// `show_line_numbers` setting. Called at render time with the per-split
653    /// line number state.
654    pub fn configure_for_line_numbers(&mut self, show_line_numbers: bool) {
655        if !show_line_numbers {
656            self.left_config.width = 0;
657            self.left_config.enabled = false;
658        } else {
659            self.left_config.enabled = true;
660            if self.left_config.width == 0 {
661                self.left_config.width = MIN_LINE_NUMBER_DIGITS;
662            }
663        }
664    }
665
666    /// Get the number of annotations in a position
667    pub fn annotation_count(&self, position: MarginPosition) -> usize {
668        match position {
669            MarginPosition::Left => self.left_annotations.values().map(|v| v.len()).sum(),
670            MarginPosition::Right => self.right_annotations.values().map(|v| v.len()).sum(),
671        }
672    }
673}
674
675impl Default for MarginManager {
676    fn default() -> Self {
677        Self::new()
678    }
679}
680
681#[cfg(test)]
682mod tests {
683    use super::*;
684
685    #[test]
686    fn test_margin_content_text() {
687        let content = MarginContent::text("123");
688        let (rendered, style) = content.render(5);
689        assert_eq!(rendered, "  123");
690        assert!(style.is_none());
691    }
692
693    #[test]
694    fn test_margin_content_symbol() {
695        let content = MarginContent::colored_symbol("●", Color::Red);
696        let (rendered, style) = content.render(3);
697        assert_eq!(rendered, "  ●");
698        assert!(style.is_some());
699    }
700
701    #[test]
702    fn test_margin_config_total_width() {
703        let mut config = MarginConfig::left_default();
704        config.width = 4;
705        config.separator = " │ ".to_string();
706        assert_eq!(config.total_width(), 8); // 1 (indicator) + 4 (line num) + 3 (separator)
707
708        config.show_separator = false;
709        assert_eq!(config.total_width(), 5); // 1 (indicator) + 4 (line num)
710
711        config.enabled = false;
712        assert_eq!(config.total_width(), 0);
713    }
714
715    #[test]
716    fn test_margin_annotation_helpers() {
717        let line_num = MarginAnnotation::line_number(5);
718        assert_eq!(line_num.line, 5);
719        assert_eq!(line_num.position, MarginPosition::Left);
720
721        let breakpoint = MarginAnnotation::breakpoint(10);
722        assert_eq!(breakpoint.line, 10);
723        assert_eq!(breakpoint.position, MarginPosition::Left);
724    }
725
726    #[test]
727    fn test_margin_manager_add_remove() {
728        let mut manager = MarginManager::new();
729
730        // Add annotation
731        let annotation = MarginAnnotation::line_number(5);
732        manager.add_annotation(annotation);
733
734        assert_eq!(manager.annotation_count(MarginPosition::Left), 1);
735
736        // Add annotation with ID
737        let annotation = MarginAnnotation::with_id(
738            10,
739            MarginPosition::Left,
740            MarginContent::text("test"),
741            "test-id".to_string(),
742        );
743        manager.add_annotation(annotation);
744
745        assert_eq!(manager.annotation_count(MarginPosition::Left), 2);
746
747        // Remove by ID
748        manager.remove_by_id("test-id");
749        assert_eq!(manager.annotation_count(MarginPosition::Left), 1);
750
751        // Clear all
752        manager.clear_all();
753        assert_eq!(manager.annotation_count(MarginPosition::Left), 0);
754    }
755
756    #[test]
757    fn test_margin_manager_render_line() {
758        let mut manager = MarginManager::new();
759
760        // Without annotations, should render line number when show_line_numbers=true
761        let content = manager.render_line(5, MarginPosition::Left, 100, true);
762        let (rendered, _) = content.render(4);
763        assert!(rendered.contains("6")); // Line 5 is displayed as "6" (1-indexed)
764
765        // Add a breakpoint annotation
766        manager.add_annotation(MarginAnnotation::breakpoint(5));
767
768        // Should now render stacked content (line number + breakpoint)
769        let content = manager.render_line(5, MarginPosition::Left, 100, true);
770        assert!(matches!(content, MarginContent::Stacked(_)));
771    }
772
773    #[test]
774    fn test_margin_manager_update_width() {
775        let mut manager = MarginManager::new();
776
777        // Single-line buffer — clamped to the minimum
778        manager.update_width_for_buffer(1, true);
779        assert_eq!(manager.left_config.width, MIN_LINE_NUMBER_DIGITS);
780
781        // Two-digit buffer
782        manager.update_width_for_buffer(99, true);
783        assert_eq!(manager.left_config.width, 2);
784
785        // Three-digit buffer — now tracks the actual digit count rather than
786        // padding to a fixed minimum of 4 (see issue #1204).
787        manager.update_width_for_buffer(500, true);
788        assert_eq!(manager.left_config.width, 3);
789
790        // Medium buffer (4 digits)
791        manager.update_width_for_buffer(1000, true);
792        assert_eq!(manager.left_config.width, 4);
793
794        // Large buffer (5 digits)
795        manager.update_width_for_buffer(10000, true);
796        assert_eq!(manager.left_config.width, 5);
797
798        // Very large buffer (7 digits)
799        manager.update_width_for_buffer(1000000, true);
800        assert_eq!(manager.left_config.width, 7);
801    }
802
803    #[test]
804    fn test_margin_manager_total_width_adapts_to_buffer() {
805        // Regression test for issue #1204: the rendered gutter width should
806        // scale with the actual line count instead of being pinned to a fixed
807        // minimum of 4 digits.
808        let mut manager = MarginManager::new();
809
810        // Small buffer — 2-digit minimum, gutter = 1 (indicator) + 2 + 3 (" │ ")
811        manager.update_width_for_buffer(10, true);
812        assert_eq!(manager.left_total_width(), 6);
813
814        // 3-digit line count — gutter grows to 7
815        manager.update_width_for_buffer(250, true);
816        assert_eq!(manager.left_total_width(), 7);
817
818        // 4-digit line count — gutter grows to 8
819        manager.update_width_for_buffer(5000, true);
820        assert_eq!(manager.left_total_width(), 8);
821    }
822
823    #[test]
824    fn test_margin_manager_without_line_numbers() {
825        let manager = MarginManager::without_line_numbers();
826        assert!(!manager.left_config.enabled);
827
828        let content = manager.render_line(5, MarginPosition::Left, 100, false);
829        assert!(content.is_empty());
830    }
831
832    #[test]
833    fn test_margin_position_left_right() {
834        let mut manager = MarginManager::new();
835
836        manager.add_annotation(MarginAnnotation::new(
837            1,
838            MarginPosition::Left,
839            MarginContent::text("left"),
840        ));
841
842        manager.add_annotation(MarginAnnotation::new(
843            1,
844            MarginPosition::Right,
845            MarginContent::text("right"),
846        ));
847
848        assert_eq!(manager.annotation_count(MarginPosition::Left), 1);
849        assert_eq!(manager.annotation_count(MarginPosition::Right), 1);
850
851        manager.clear_position(MarginPosition::Left);
852        assert_eq!(manager.annotation_count(MarginPosition::Left), 0);
853        assert_eq!(manager.annotation_count(MarginPosition::Right), 1);
854    }
855
856    // Helper: simulates a buffer where each line is 10 bytes (9 chars + newline)
857    // Line 0 = bytes 0-9, Line 1 = bytes 10-19, etc.
858    fn byte_to_line(byte_offset: usize) -> usize {
859        byte_offset / 10
860    }
861
862    // Helper: get byte offset for start of a line
863    fn line_to_byte(line: usize) -> usize {
864        line * 10
865    }
866
867    #[test]
868    fn test_line_indicator_basic() {
869        let mut manager = MarginManager::new();
870
871        // Add a line indicator at byte offset 50 (line 5 in our simulated buffer)
872        let indicator = LineIndicator::new("│", Color::Green, 10);
873        manager.set_line_indicator(line_to_byte(5), "git-gutter".to_string(), indicator);
874
875        // Check it can be retrieved on line 5
876        let retrieved = manager.get_line_indicator(5, byte_to_line);
877        assert!(retrieved.is_some());
878        let retrieved = retrieved.unwrap();
879        assert_eq!(retrieved.symbol, "│");
880        assert_eq!(retrieved.color, Color::Green);
881        assert_eq!(retrieved.priority, 10);
882
883        // Non-existent line should return None
884        assert!(manager.get_line_indicator(10, byte_to_line).is_none());
885    }
886
887    #[test]
888    fn test_line_indicator_multiple_namespaces() {
889        let mut manager = MarginManager::new();
890
891        // Add indicators from different namespaces at the same byte position (line 5)
892        let git_indicator = LineIndicator::new("│", Color::Green, 10);
893        let breakpoint_indicator = LineIndicator::new("●", Color::Red, 20);
894
895        manager.set_line_indicator(line_to_byte(5), "git-gutter".to_string(), git_indicator);
896        manager.set_line_indicator(
897            line_to_byte(5),
898            "breakpoints".to_string(),
899            breakpoint_indicator,
900        );
901
902        // Should return the highest priority indicator
903        let retrieved = manager.get_line_indicator(5, byte_to_line);
904        assert!(retrieved.is_some());
905        let retrieved = retrieved.unwrap();
906        assert_eq!(retrieved.symbol, "●"); // Breakpoint has higher priority
907        assert_eq!(retrieved.priority, 20);
908    }
909
910    #[test]
911    fn test_line_indicator_clear_namespace() {
912        let mut manager = MarginManager::new();
913
914        // Add indicators on multiple lines
915        manager.set_line_indicator(
916            line_to_byte(1),
917            "git-gutter".to_string(),
918            LineIndicator::new("│", Color::Green, 10),
919        );
920        manager.set_line_indicator(
921            line_to_byte(2),
922            "git-gutter".to_string(),
923            LineIndicator::new("│", Color::Yellow, 10),
924        );
925        manager.set_line_indicator(
926            line_to_byte(3),
927            "breakpoints".to_string(),
928            LineIndicator::new("●", Color::Red, 20),
929        );
930
931        // Clear git-gutter namespace
932        manager.clear_line_indicators_for_namespace("git-gutter");
933
934        // Git gutter indicators should be gone
935        assert!(manager.get_line_indicator(1, byte_to_line).is_none());
936        assert!(manager.get_line_indicator(2, byte_to_line).is_none());
937
938        // Breakpoint should still be there
939        let breakpoint = manager.get_line_indicator(3, byte_to_line);
940        assert!(breakpoint.is_some());
941        assert_eq!(breakpoint.unwrap().symbol, "●");
942    }
943
944    #[test]
945    fn test_line_indicator_remove_specific() {
946        let mut manager = MarginManager::new();
947
948        // Add two indicators at the same byte position (line 5)
949        let git_marker = manager.set_line_indicator(
950            line_to_byte(5),
951            "git-gutter".to_string(),
952            LineIndicator::new("│", Color::Green, 10),
953        );
954        let bp_marker = manager.set_line_indicator(
955            line_to_byte(5),
956            "breakpoints".to_string(),
957            LineIndicator::new("●", Color::Red, 20),
958        );
959
960        // Remove just the git-gutter indicator
961        manager.remove_line_indicator(git_marker, "git-gutter");
962
963        // Should still have the breakpoint indicator on line 5
964        let retrieved = manager.get_line_indicator(5, byte_to_line);
965        assert!(retrieved.is_some());
966        assert_eq!(retrieved.unwrap().symbol, "●");
967
968        // Remove the breakpoint indicator too
969        manager.remove_line_indicator(bp_marker, "breakpoints");
970
971        // Now no indicators on line 5
972        assert!(manager.get_line_indicator(5, byte_to_line).is_none());
973    }
974
975    #[test]
976    fn test_line_indicator_shifts_on_insert() {
977        let mut manager = MarginManager::new();
978
979        // Add indicator on line 5 (byte 50)
980        manager.set_line_indicator(
981            line_to_byte(5),
982            "git-gutter".to_string(),
983            LineIndicator::new("│", Color::Green, 10),
984        );
985
986        // Verify it's on line 5
987        assert!(manager.get_line_indicator(5, byte_to_line).is_some());
988        assert!(manager.get_line_indicator(6, byte_to_line).is_none());
989
990        // Insert 10 bytes (one line) at the beginning
991        manager.adjust_for_insert(0, 10);
992
993        // Now indicator should be on line 6 (shifted down by 1)
994        assert!(manager.get_line_indicator(5, byte_to_line).is_none());
995        assert!(manager.get_line_indicator(6, byte_to_line).is_some());
996    }
997
998    #[test]
999    fn test_line_indicator_shifts_on_delete() {
1000        let mut manager = MarginManager::new();
1001
1002        // Add indicator on line 5 (byte 50)
1003        manager.set_line_indicator(
1004            line_to_byte(5),
1005            "git-gutter".to_string(),
1006            LineIndicator::new("│", Color::Green, 10),
1007        );
1008
1009        // Verify it's on line 5
1010        assert!(manager.get_line_indicator(5, byte_to_line).is_some());
1011
1012        // Delete first 20 bytes (2 lines)
1013        manager.adjust_for_delete(0, 20);
1014
1015        // Now indicator should be on line 3 (shifted up by 2)
1016        assert!(manager.get_line_indicator(5, byte_to_line).is_none());
1017        assert!(manager.get_line_indicator(3, byte_to_line).is_some());
1018    }
1019
1020    #[test]
1021    fn test_multiple_indicators_shift_together() {
1022        let mut manager = MarginManager::new();
1023
1024        // Add indicators on lines 3, 5, and 7
1025        manager.set_line_indicator(
1026            line_to_byte(3),
1027            "git-gutter".to_string(),
1028            LineIndicator::new("│", Color::Green, 10),
1029        );
1030        manager.set_line_indicator(
1031            line_to_byte(5),
1032            "git-gutter".to_string(),
1033            LineIndicator::new("│", Color::Yellow, 10),
1034        );
1035        manager.set_line_indicator(
1036            line_to_byte(7),
1037            "git-gutter".to_string(),
1038            LineIndicator::new("│", Color::Red, 10),
1039        );
1040
1041        // Insert 2 lines (20 bytes) at byte 25 (middle of line 2)
1042        // This should shift lines 3, 5, 7 -> lines 5, 7, 9
1043        manager.adjust_for_insert(25, 20);
1044
1045        // Old positions should be empty
1046        assert!(manager.get_line_indicator(3, byte_to_line).is_none());
1047
1048        // New positions should have indicators
1049        assert!(manager.get_line_indicator(5, byte_to_line).is_some());
1050        assert!(manager.get_line_indicator(7, byte_to_line).is_some());
1051        assert!(manager.get_line_indicator(9, byte_to_line).is_some());
1052    }
1053}