Skip to main content

ferroui_base/media/
text_path_segment_ellipsis.rs

1use std::rc::Rc;
2
3use crate::media::text_formatting::{
4    DrawableTextRun, FormattingObjectPool, LogicalTextRunEnumerator, ShapedTextRun, TextFormatterImpl, TextCharacters, TextCollapsingProperties, TextFormatter, TextLine, TextRun,
5    TextRunProperties,
6};
7use crate::media::FlowDirection;
8use crate::utilities::MathUtilities;
9
10/// Provides text collapsing properties that replace the middle segments of a file path with an ellipsis symbol when
11/// the rendered width exceeds a specified limit.
12///
13/// This class is typically used to display file paths in a compact form by collapsing segments near
14/// the center and inserting an ellipsis, ensuring that the most relevant parts of the path remain visible. It is
15/// suitable for scenarios where space is limited, such as UI elements that display file or directory paths. The
16/// collapsing behavior prioritizes preserving the beginning and end segments of the path.
17pub struct TextPathSegmentEllipsis {
18    width: f64,
19    symbol: Rc<dyn TextRun>,
20    flow_direction: FlowDirection,
21}
22
23/// A stretch of the line's text: either one separator or the text between separators.
24#[derive(Clone, Copy)]
25struct Segment {
26    start: i32,
27    length: i32,
28    width: f64,
29    is_separator: bool,
30}
31
32impl TextPathSegmentEllipsis {
33    /// Initializes a new instance with the specified ellipsis symbol, maximum
34    /// width, text run properties, and flow direction.
35    ///
36    /// * `ellipsis` — the string to use as the ellipsis symbol when collapsing path segments.
37    /// * `width` — the maximum width, in device-independent pixels, within which the text must fit.
38    /// * `text_run_properties` — the text formatting properties to apply to the ellipsis symbol.
39    /// * `flow_direction` — the flow direction for text layout.
40    pub fn new(
41        ellipsis: &str,
42        width: f64,
43        text_run_properties: Rc<dyn TextRunProperties>,
44        flow_direction: FlowDirection,
45    ) -> Self {
46        Self { width, symbol: Rc::new(TextCharacters::from_str(ellipsis, text_run_properties)), flow_direction }
47    }
48
49    /// The part of upstream's `Collapse` that runs with the logical runs
50    /// rented; the caller returns `logical_runs` afterwards.
51    fn collapse_core(
52        &self,
53        text_line: &dyn TextLine,
54        logical_runs: &[Rc<dyn TextRun>],
55        shaped_symbol: Rc<ShapedTextRun>,
56        total_width: f64,
57        object_pool: &FormattingObjectPool,
58    ) -> Option<Vec<Rc<dyn TextRun>>> {
59        // Pre-compute cumulative run start char positions so that
60        // measure_segment_width can binary-search to the first overlapping
61        // run instead of re-scanning from index 0 on every call. Built
62        // once per collapse; reused by every segment-width measurement.
63        // run_start_chars[i] = sum of lengths of runs 0..i-1;
64        // run_start_chars[count] = total char length (sentinel).
65        let mut run_start_chars = vec![0i32; logical_runs.len() + 1];
66
67        for (i, run) in logical_runs.iter().enumerate() {
68            run_start_chars[i + 1] = run_start_chars[i] + run.length();
69        }
70
71        // Segment ranges
72        let mut segments: Vec<Segment> = Vec::new();
73        let mut candidate_segment_indices: Vec<usize> = Vec::new();
74        let mut global_index = 0i32;
75        let mut current_seg_start = 0i32;
76        let mut in_separator = false;
77
78        for run in logical_runs {
79            if run.is::<ShapedTextRun>() {
80                for &ch in run.text_span() {
81                    if Self::is_separator(ch) {
82                        // finish previous non-separator segment
83                        if !in_separator && global_index - current_seg_start > 0 {
84                            let segment_width = Self::measure_segment_width(
85                                logical_runs,
86                                &run_start_chars,
87                                current_seg_start,
88                                global_index - current_seg_start,
89                            );
90
91                            segments.push(Segment {
92                                start: current_seg_start,
93                                length: global_index - current_seg_start,
94                                width: segment_width,
95                                is_separator: false,
96                            });
97                        }
98
99                        let separator_width =
100                            Self::measure_segment_width(logical_runs, &run_start_chars, global_index, 1);
101
102                        // separator as its own segment
103                        segments.push(Segment { start: global_index, length: 1, width: separator_width, is_separator: true });
104
105                        // next segment starts after separator
106                        current_seg_start = global_index + 1;
107                        in_separator = true;
108                    } else if in_separator {
109                        // start of a non-separator segment
110                        current_seg_start = global_index;
111                        in_separator = false;
112                    }
113
114                    global_index += 1;
115                }
116            } else {
117                // Non shaped run is treated as non-separator
118                if in_separator {
119                    current_seg_start = global_index;
120                    in_separator = false;
121                }
122
123                global_index += run.length();
124            }
125        }
126
127        // Add last pending segment if any
128        if global_index - current_seg_start > 0 {
129            let segment_width = Self::measure_segment_width(
130                logical_runs,
131                &run_start_chars,
132                current_seg_start,
133                global_index - current_seg_start,
134            );
135
136            segments.push(Segment {
137                start: current_seg_start,
138                length: global_index - current_seg_start,
139                width: segment_width,
140                is_separator: false,
141            });
142        }
143
144        if segments.is_empty() {
145            // Nothing to collapse
146            return None;
147        }
148
149        let mut prefix = vec![0f64; segments.len() + 1];
150
151        // Measure segment widths
152        for (i, segment) in segments.iter().enumerate() {
153            if !segment.is_separator {
154                candidate_segment_indices.push(i);
155            }
156
157            prefix[i + 1] = prefix[i] + segment.width;
158        }
159
160        // Determine center character index to prefer collapsing ranges near the middle.
161        let mid_char = global_index / 2;
162
163        // Find candidate whose center is closest to mid_char
164        let mut center_candidate_idx = 0i32;
165        let mut best_dist = i64::MAX;
166
167        for (i, &candidate_segment_index) in candidate_segment_indices.iter().enumerate() {
168            let segment = segments[candidate_segment_index];
169            let seg_center = segment.start + segment.length / 2;
170            let dist = (seg_center - mid_char).abs() as i64;
171
172            if dist < best_dist {
173                best_dist = dist;
174                center_candidate_idx = i as i32;
175            }
176        }
177
178        // Expand windows around center_candidate_idx.
179        let candidate_count = candidate_segment_indices.len() as i32;
180
181        if candidate_count > 0 {
182            let mut window_starts: Vec<i32> = Vec::new();
183
184            for window_size in 1..=candidate_count {
185                // For a given window_size, try all windows of that size centered as close as possible to center_candidate_idx.
186                // Compute start index of window such that center is as near as possible.
187                let half = (window_size - 1) / 2;
188                let start = center_candidate_idx - half;
189                // For even window sizes, prefer left-leaning start, also try shifting the window across the center.
190                window_starts.clear();
191
192                // clamp start range
193                let min_start = 0.max(center_candidate_idx - (window_size - 1));
194                let max_start = (candidate_count - window_size).min(center_candidate_idx + (window_size - 1));
195
196                // Left side first
197                let mut s = start;
198                while s >= min_start {
199                    window_starts.push(s);
200                    s -= 1;
201                }
202
203                // Right side next
204                let mut s = start + 1;
205                while s <= max_start {
206                    window_starts.push(s);
207                    s += 1;
208                }
209
210                for &window_start in &window_starts {
211                    if window_start < 0 || window_start + window_size > candidate_count {
212                        continue;
213                    }
214
215                    let left_cand = window_start;
216                    let right_cand = window_start + window_size - 1;
217
218                    // Map candidate window to segments range (in segments list)
219                    let seg_start_index = candidate_segment_indices[left_cand as usize];
220                    let seg_end_index = candidate_segment_indices[right_cand as usize];
221
222                    // Ensure that we leave at least one character on each side (prefer middle-only removal)
223                    let left_remaining = segments[seg_start_index].start;
224                    let right_remaining = global_index - (segments[seg_end_index].start + segments[seg_end_index].length);
225
226                    if left_remaining <= 0 || right_remaining <= 0 {
227                        continue;
228                    }
229
230                    let trimmed_width = prefix[seg_end_index + 1] - prefix[seg_start_index];
231
232                    if MathUtilities::less_than_or_close(
233                        total_width - trimmed_width + shaped_symbol.size().width,
234                        self.width,
235                    ) {
236                        // perform split using character indices
237                        let remove_start = segments[seg_start_index].start;
238                        let remove_length =
239                            (segments[seg_end_index].start + segments[seg_end_index].length) - remove_start;
240
241                        let (first, remainder) =
242                            TextFormatterImpl::split_text_runs(logical_runs, remove_start, object_pool).deconstruct();
243
244                        let Some(remainder) = remainder else {
245                            // We reached the end
246                            object_pool.text_run_lists.return_optional(first);
247
248                            return None;
249                        };
250
251                        let (middle, last) =
252                            TextFormatterImpl::split_text_runs(&remainder, remove_length, object_pool).deconstruct();
253
254                        // Build resulting runs
255                        // first + shaped_symbol + last
256                        let mut result: Vec<Rc<dyn TextRun>> = Vec::with_capacity(
257                            first.as_ref().map_or(0, |runs| runs.len()) + 1 + last.as_ref().map_or(0, |runs| runs.len()),
258                        );
259
260                        if let Some(first) = &first {
261                            result.extend(first.iter().cloned());
262                        }
263
264                        result.push(shaped_symbol);
265
266                        if let Some(last) = &last {
267                            result.extend(last.iter().cloned());
268                        }
269
270                        // Return rented lists
271                        object_pool.text_run_lists.return_optional(first);
272                        object_pool.text_run_lists.return_list(remainder);
273                        object_pool.text_run_lists.return_optional(middle);
274                        object_pool.text_run_lists.return_optional(last);
275
276                        return Some(result);
277                    }
278                }
279            }
280        }
281
282        // Fallback - try to trim at segment boundaries from start
283        let mut current_length = 0;
284        let mut remaining_width = text_line.width_including_trailing_whitespace();
285
286        for (segment_index, segment) in segments.iter().enumerate() {
287            if segment_index < segments.len() - 1
288                && MathUtilities::greater_than(remaining_width - segment.width, self.width)
289            {
290                remaining_width -= segment.width;
291                current_length += segment.length;
292
293                continue;
294            }
295
296            // Split before current segment
297            let (first, second): (Option<Vec<Rc<dyn TextRun>>>, Option<Vec<Rc<dyn TextRun>>>) =
298                TextFormatterImpl::split_text_runs(logical_runs, current_length, object_pool).deconstruct();
299
300            let mut trimmed_run: Option<Rc<dyn TextRun>> = None;
301            let mut remaining_run_count = 0;
302
303            if let Some(second) = &second {
304                if !second.is_empty() {
305                    remaining_run_count = second.len() - 1;
306
307                    let run = &second[0];
308
309                    if let Some(shaped_run) = run.downcast_ref::<ShapedTextRun>() {
310                        let measure_width = self.width - shaped_symbol.size().width;
311
312                        if let Some((length, _)) = shaped_run.try_measure_characters_backwards(measure_width) {
313                            let split_at = shaped_run.length() - length;
314
315                            if split_at > 0 {
316                                trimmed_run = shaped_run.split(split_at).second.map(|run| run as Rc<dyn TextRun>);
317                            } else if length > 0 {
318                                // The whole run fits in the remaining budget — no split needed,
319                                // use the run as-is. (Splitting at 0 panics.)
320                                trimmed_run = Some(run.clone());
321                            }
322                            // else: length == 0 → nothing of this run survives; trimmed_run stays None.
323                        }
324                    }
325                }
326            }
327
328            let run_count = usize::from(trimmed_run.is_some()) + 1 + remaining_run_count;
329
330            let mut result: Vec<Rc<dyn TextRun>> = Vec::with_capacity(run_count);
331
332            // Append symbol
333            result.push(shaped_symbol);
334
335            // Append trimmed run if any
336            if let Some(trimmed_run) = trimmed_run {
337                result.push(trimmed_run);
338            }
339
340            // Append remaining runs
341            if let Some(second) = &second {
342                result.extend(second.iter().skip(1).cloned());
343            }
344
345            // Return rented lists
346            object_pool.text_run_lists.return_optional(first);
347            object_pool.text_run_lists.return_optional(second);
348
349            return Some(result);
350        }
351
352        // No suitable segment found
353        None
354    }
355
356    /// Whether the character separates path segments. Upstream lists the
357    /// platform's directory separators next to `/` and `\`; on every platform
358    /// that is this set.
359    fn is_separator(ch: u16) -> bool {
360        ch == b'/' as u16 || ch == b'\\' as u16
361    }
362
363    fn measure_segment_width(
364        runs: &[Rc<dyn TextRun>],
365        run_start_chars: &[i32],
366        segment_start: i32,
367        segment_length: i32,
368    ) -> f64 {
369        if segment_length <= 0 {
370            return 0.0;
371        }
372
373        let segment_end = segment_start + segment_length;
374
375        // Binary search run_start_chars for the largest i with run_start_chars[i] <= segment_start.
376        // That's the first run whose range can overlap the segment.
377        let mut i = Self::find_first_overlapping_run(run_start_chars, segment_start);
378
379        let mut width = 0.0;
380
381        while i < runs.len() {
382            let run_start = run_start_chars[i];
383
384            if run_start >= segment_end {
385                break;
386            }
387
388            let run = &runs[i];
389            let run_end = run_start + run.length();
390
391            let overlap_start = segment_start.max(run_start);
392            let overlap_end = segment_end.min(run_end);
393
394            if overlap_end > overlap_start {
395                if let Some(shaped) = run.downcast_ref::<ShapedTextRun>() {
396                    // The shaped buffer answers this from its cluster cache; O(log clusters).
397                    width += shaped.shaped_buffer().get_char_range_width(overlap_start - run_start, overlap_end - run_start);
398                } else if let Some(drawable) = run.as_drawable() {
399                    // Drawables are atomic: count full width when they completely overlap.
400                    if overlap_end - overlap_start >= run.length() {
401                        width += drawable.size().width;
402                    }
403                }
404            }
405
406            i += 1;
407        }
408
409        width
410    }
411
412    fn find_first_overlapping_run(run_start_chars: &[i32], char_index: i32) -> usize {
413        if char_index <= 0 {
414            return 0;
415        }
416
417        // Upper bound excludes the sentinel entry; we want a run index, not a boundary.
418        if run_start_chars.len() < 2 {
419            return 0;
420        }
421
422        let mut lo = 0usize;
423        let mut hi = run_start_chars.len() - 2;
424
425        while lo < hi {
426            let mid = (lo + hi + 1) >> 1;
427
428            if run_start_chars[mid] <= char_index {
429                lo = mid;
430            } else {
431                hi = mid - 1;
432            }
433        }
434
435        lo
436    }
437}
438
439impl TextCollapsingProperties for TextPathSegmentEllipsis {
440    fn width(&self) -> f64 {
441        self.width
442    }
443
444    fn symbol(&self) -> &Rc<dyn TextRun> {
445        &self.symbol
446    }
447
448    fn flow_direction(&self) -> FlowDirection {
449        self.flow_direction
450    }
451
452    fn collapse(&self, text_line: &dyn TextLine) -> Option<Vec<Rc<dyn TextRun>>> {
453        if text_line.text_runs().is_empty() {
454            return None;
455        }
456
457        let object_pool = FormattingObjectPool::instance();
458
459        let shaped_symbol = <dyn TextFormatter>::create_symbol(&*self.symbol, self.flow_direction);
460
461        if MathUtilities::less_than(self.width, shaped_symbol.size().width) {
462            // Nothing to collapse
463            return None;
464        }
465
466        let total_width = text_line.width();
467
468        if MathUtilities::less_than_or_close(total_width, self.width) {
469            // Nothing to collapse
470            return None;
471        }
472
473        // Extract logical runs from the line
474        let mut logical_runs = object_pool.text_run_lists.rent();
475
476        {
477            let mut enumerator = LogicalTextRunEnumerator::new(text_line);
478
479            while let Some(run) = enumerator.move_next() {
480                logical_runs.push(run);
481            }
482        }
483
484        let result = self.collapse_core(text_line, &logical_runs, shaped_symbol, total_width, object_pool);
485
486        object_pool.text_run_lists.return_list(logical_runs);
487
488        result
489    }
490}