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            // Hide the line-number digits and the separator, but keep the gutter
657            // enabled with a zero-width number column so the 1-char indicator
658            // slot survives. This lets diagnostic / git / fold indicators still
659            // render in compose mode (issue #2146) without showing line numbers
660            // or the `│` separator. The render layer draws this slot in the
661            // compose desk margin so it doesn't shrink the text width.
662            self.left_config.width = 0;
663            self.left_config.enabled = true;
664            self.left_config.show_separator = false;
665        } else {
666            self.left_config.enabled = true;
667            self.left_config.show_separator = true;
668            if self.left_config.width == 0 {
669                self.left_config.width = MIN_LINE_NUMBER_DIGITS;
670            }
671        }
672    }
673
674    /// Get the number of annotations in a position
675    pub fn annotation_count(&self, position: MarginPosition) -> usize {
676        match position {
677            MarginPosition::Left => self.left_annotations.values().map(|v| v.len()).sum(),
678            MarginPosition::Right => self.right_annotations.values().map(|v| v.len()).sum(),
679        }
680    }
681}
682
683impl Default for MarginManager {
684    fn default() -> Self {
685        Self::new()
686    }
687}
688
689#[cfg(test)]
690mod tests {
691    use super::*;
692
693    #[test]
694    fn test_margin_content_text() {
695        let content = MarginContent::text("123");
696        let (rendered, style) = content.render(5);
697        assert_eq!(rendered, "  123");
698        assert!(style.is_none());
699    }
700
701    #[test]
702    fn test_margin_content_symbol() {
703        let content = MarginContent::colored_symbol("●", Color::Red);
704        let (rendered, style) = content.render(3);
705        assert_eq!(rendered, "  ●");
706        assert!(style.is_some());
707    }
708
709    #[test]
710    fn test_margin_config_total_width() {
711        let mut config = MarginConfig::left_default();
712        config.width = 4;
713        config.separator = " │ ".to_string();
714        assert_eq!(config.total_width(), 8); // 1 (indicator) + 4 (line num) + 3 (separator)
715
716        config.show_separator = false;
717        assert_eq!(config.total_width(), 5); // 1 (indicator) + 4 (line num)
718
719        config.enabled = false;
720        assert_eq!(config.total_width(), 0);
721    }
722
723    #[test]
724    fn test_margin_annotation_helpers() {
725        let line_num = MarginAnnotation::line_number(5);
726        assert_eq!(line_num.line, 5);
727        assert_eq!(line_num.position, MarginPosition::Left);
728
729        let breakpoint = MarginAnnotation::breakpoint(10);
730        assert_eq!(breakpoint.line, 10);
731        assert_eq!(breakpoint.position, MarginPosition::Left);
732    }
733
734    #[test]
735    fn test_margin_manager_add_remove() {
736        let mut manager = MarginManager::new();
737
738        // Add annotation
739        let annotation = MarginAnnotation::line_number(5);
740        manager.add_annotation(annotation);
741
742        assert_eq!(manager.annotation_count(MarginPosition::Left), 1);
743
744        // Add annotation with ID
745        let annotation = MarginAnnotation::with_id(
746            10,
747            MarginPosition::Left,
748            MarginContent::text("test"),
749            "test-id".to_string(),
750        );
751        manager.add_annotation(annotation);
752
753        assert_eq!(manager.annotation_count(MarginPosition::Left), 2);
754
755        // Remove by ID
756        manager.remove_by_id("test-id");
757        assert_eq!(manager.annotation_count(MarginPosition::Left), 1);
758
759        // Clear all
760        manager.clear_all();
761        assert_eq!(manager.annotation_count(MarginPosition::Left), 0);
762    }
763
764    #[test]
765    fn test_margin_manager_render_line() {
766        let mut manager = MarginManager::new();
767
768        // Without annotations, should render line number when show_line_numbers=true
769        let content = manager.render_line(5, MarginPosition::Left, 100, true);
770        let (rendered, _) = content.render(4);
771        assert!(rendered.contains("6")); // Line 5 is displayed as "6" (1-indexed)
772
773        // Add a breakpoint annotation
774        manager.add_annotation(MarginAnnotation::breakpoint(5));
775
776        // Should now render stacked content (line number + breakpoint)
777        let content = manager.render_line(5, MarginPosition::Left, 100, true);
778        assert!(matches!(content, MarginContent::Stacked(_)));
779    }
780
781    #[test]
782    fn test_margin_manager_update_width() {
783        let mut manager = MarginManager::new();
784
785        // Single-line buffer — clamped to the minimum
786        manager.update_width_for_buffer(1, true);
787        assert_eq!(manager.left_config.width, MIN_LINE_NUMBER_DIGITS);
788
789        // Two-digit buffer
790        manager.update_width_for_buffer(99, true);
791        assert_eq!(manager.left_config.width, 2);
792
793        // Three-digit buffer — now tracks the actual digit count rather than
794        // padding to a fixed minimum of 4 (see issue #1204).
795        manager.update_width_for_buffer(500, true);
796        assert_eq!(manager.left_config.width, 3);
797
798        // Medium buffer (4 digits)
799        manager.update_width_for_buffer(1000, true);
800        assert_eq!(manager.left_config.width, 4);
801
802        // Large buffer (5 digits)
803        manager.update_width_for_buffer(10000, true);
804        assert_eq!(manager.left_config.width, 5);
805
806        // Very large buffer (7 digits)
807        manager.update_width_for_buffer(1000000, true);
808        assert_eq!(manager.left_config.width, 7);
809    }
810
811    #[test]
812    fn test_margin_manager_total_width_adapts_to_buffer() {
813        // Regression test for issue #1204: the rendered gutter width should
814        // scale with the actual line count instead of being pinned to a fixed
815        // minimum of 4 digits.
816        let mut manager = MarginManager::new();
817
818        // Small buffer — 2-digit minimum, gutter = 1 (indicator) + 2 + 3 (" │ ")
819        manager.update_width_for_buffer(10, true);
820        assert_eq!(manager.left_total_width(), 6);
821
822        // 3-digit line count — gutter grows to 7
823        manager.update_width_for_buffer(250, true);
824        assert_eq!(manager.left_total_width(), 7);
825
826        // 4-digit line count — gutter grows to 8
827        manager.update_width_for_buffer(5000, true);
828        assert_eq!(manager.left_total_width(), 8);
829    }
830
831    #[test]
832    fn test_margin_manager_without_line_numbers() {
833        let manager = MarginManager::without_line_numbers();
834        assert!(!manager.left_config.enabled);
835
836        let content = manager.render_line(5, MarginPosition::Left, 100, false);
837        assert!(content.is_empty());
838    }
839
840    #[test]
841    fn test_margin_position_left_right() {
842        let mut manager = MarginManager::new();
843
844        manager.add_annotation(MarginAnnotation::new(
845            1,
846            MarginPosition::Left,
847            MarginContent::text("left"),
848        ));
849
850        manager.add_annotation(MarginAnnotation::new(
851            1,
852            MarginPosition::Right,
853            MarginContent::text("right"),
854        ));
855
856        assert_eq!(manager.annotation_count(MarginPosition::Left), 1);
857        assert_eq!(manager.annotation_count(MarginPosition::Right), 1);
858
859        manager.clear_position(MarginPosition::Left);
860        assert_eq!(manager.annotation_count(MarginPosition::Left), 0);
861        assert_eq!(manager.annotation_count(MarginPosition::Right), 1);
862    }
863
864    // Helper: simulates a buffer where each line is 10 bytes (9 chars + newline)
865    // Line 0 = bytes 0-9, Line 1 = bytes 10-19, etc.
866    fn byte_to_line(byte_offset: usize) -> usize {
867        byte_offset / 10
868    }
869
870    // Helper: get byte offset for start of a line
871    fn line_to_byte(line: usize) -> usize {
872        line * 10
873    }
874
875    #[test]
876    fn test_line_indicator_basic() {
877        let mut manager = MarginManager::new();
878
879        // Add a line indicator at byte offset 50 (line 5 in our simulated buffer)
880        let indicator = LineIndicator::new("│", Color::Green, 10);
881        manager.set_line_indicator(line_to_byte(5), "git-gutter".to_string(), indicator);
882
883        // Check it can be retrieved on line 5
884        let retrieved = manager.get_line_indicator(5, byte_to_line);
885        assert!(retrieved.is_some());
886        let retrieved = retrieved.unwrap();
887        assert_eq!(retrieved.symbol, "│");
888        assert_eq!(retrieved.color, Color::Green);
889        assert_eq!(retrieved.priority, 10);
890
891        // Non-existent line should return None
892        assert!(manager.get_line_indicator(10, byte_to_line).is_none());
893    }
894
895    #[test]
896    fn test_line_indicator_multiple_namespaces() {
897        let mut manager = MarginManager::new();
898
899        // Add indicators from different namespaces at the same byte position (line 5)
900        let git_indicator = LineIndicator::new("│", Color::Green, 10);
901        let breakpoint_indicator = LineIndicator::new("●", Color::Red, 20);
902
903        manager.set_line_indicator(line_to_byte(5), "git-gutter".to_string(), git_indicator);
904        manager.set_line_indicator(
905            line_to_byte(5),
906            "breakpoints".to_string(),
907            breakpoint_indicator,
908        );
909
910        // Should return the highest priority indicator
911        let retrieved = manager.get_line_indicator(5, byte_to_line);
912        assert!(retrieved.is_some());
913        let retrieved = retrieved.unwrap();
914        assert_eq!(retrieved.symbol, "●"); // Breakpoint has higher priority
915        assert_eq!(retrieved.priority, 20);
916    }
917
918    #[test]
919    fn test_line_indicator_clear_namespace() {
920        let mut manager = MarginManager::new();
921
922        // Add indicators on multiple lines
923        manager.set_line_indicator(
924            line_to_byte(1),
925            "git-gutter".to_string(),
926            LineIndicator::new("│", Color::Green, 10),
927        );
928        manager.set_line_indicator(
929            line_to_byte(2),
930            "git-gutter".to_string(),
931            LineIndicator::new("│", Color::Yellow, 10),
932        );
933        manager.set_line_indicator(
934            line_to_byte(3),
935            "breakpoints".to_string(),
936            LineIndicator::new("●", Color::Red, 20),
937        );
938
939        // Clear git-gutter namespace
940        manager.clear_line_indicators_for_namespace("git-gutter");
941
942        // Git gutter indicators should be gone
943        assert!(manager.get_line_indicator(1, byte_to_line).is_none());
944        assert!(manager.get_line_indicator(2, byte_to_line).is_none());
945
946        // Breakpoint should still be there
947        let breakpoint = manager.get_line_indicator(3, byte_to_line);
948        assert!(breakpoint.is_some());
949        assert_eq!(breakpoint.unwrap().symbol, "●");
950    }
951
952    #[test]
953    fn test_line_indicator_remove_specific() {
954        let mut manager = MarginManager::new();
955
956        // Add two indicators at the same byte position (line 5)
957        let git_marker = manager.set_line_indicator(
958            line_to_byte(5),
959            "git-gutter".to_string(),
960            LineIndicator::new("│", Color::Green, 10),
961        );
962        let bp_marker = manager.set_line_indicator(
963            line_to_byte(5),
964            "breakpoints".to_string(),
965            LineIndicator::new("●", Color::Red, 20),
966        );
967
968        // Remove just the git-gutter indicator
969        manager.remove_line_indicator(git_marker, "git-gutter");
970
971        // Should still have the breakpoint indicator on line 5
972        let retrieved = manager.get_line_indicator(5, byte_to_line);
973        assert!(retrieved.is_some());
974        assert_eq!(retrieved.unwrap().symbol, "●");
975
976        // Remove the breakpoint indicator too
977        manager.remove_line_indicator(bp_marker, "breakpoints");
978
979        // Now no indicators on line 5
980        assert!(manager.get_line_indicator(5, byte_to_line).is_none());
981    }
982
983    #[test]
984    fn test_line_indicator_shifts_on_insert() {
985        let mut manager = MarginManager::new();
986
987        // Add indicator on line 5 (byte 50)
988        manager.set_line_indicator(
989            line_to_byte(5),
990            "git-gutter".to_string(),
991            LineIndicator::new("│", Color::Green, 10),
992        );
993
994        // Verify it's on line 5
995        assert!(manager.get_line_indicator(5, byte_to_line).is_some());
996        assert!(manager.get_line_indicator(6, byte_to_line).is_none());
997
998        // Insert 10 bytes (one line) at the beginning
999        manager.adjust_for_insert(0, 10);
1000
1001        // Now indicator should be on line 6 (shifted down by 1)
1002        assert!(manager.get_line_indicator(5, byte_to_line).is_none());
1003        assert!(manager.get_line_indicator(6, byte_to_line).is_some());
1004    }
1005
1006    #[test]
1007    fn test_line_indicator_shifts_on_delete() {
1008        let mut manager = MarginManager::new();
1009
1010        // Add indicator on line 5 (byte 50)
1011        manager.set_line_indicator(
1012            line_to_byte(5),
1013            "git-gutter".to_string(),
1014            LineIndicator::new("│", Color::Green, 10),
1015        );
1016
1017        // Verify it's on line 5
1018        assert!(manager.get_line_indicator(5, byte_to_line).is_some());
1019
1020        // Delete first 20 bytes (2 lines)
1021        manager.adjust_for_delete(0, 20);
1022
1023        // Now indicator should be on line 3 (shifted up by 2)
1024        assert!(manager.get_line_indicator(5, byte_to_line).is_none());
1025        assert!(manager.get_line_indicator(3, byte_to_line).is_some());
1026    }
1027
1028    #[test]
1029    fn test_multiple_indicators_shift_together() {
1030        let mut manager = MarginManager::new();
1031
1032        // Add indicators on lines 3, 5, and 7
1033        manager.set_line_indicator(
1034            line_to_byte(3),
1035            "git-gutter".to_string(),
1036            LineIndicator::new("│", Color::Green, 10),
1037        );
1038        manager.set_line_indicator(
1039            line_to_byte(5),
1040            "git-gutter".to_string(),
1041            LineIndicator::new("│", Color::Yellow, 10),
1042        );
1043        manager.set_line_indicator(
1044            line_to_byte(7),
1045            "git-gutter".to_string(),
1046            LineIndicator::new("│", Color::Red, 10),
1047        );
1048
1049        // Insert 2 lines (20 bytes) at byte 25 (middle of line 2)
1050        // This should shift lines 3, 5, 7 -> lines 5, 7, 9
1051        manager.adjust_for_insert(25, 20);
1052
1053        // Old positions should be empty
1054        assert!(manager.get_line_indicator(3, byte_to_line).is_none());
1055
1056        // New positions should have indicators
1057        assert!(manager.get_line_indicator(5, byte_to_line).is_some());
1058        assert!(manager.get_line_indicator(7, byte_to_line).is_some());
1059        assert!(manager.get_line_indicator(9, byte_to_line).is_some());
1060    }
1061}