Skip to main content

tau_term_screen/
priority_line.rs

1//! Priority-based single-line layout.
2
3use crate::style::{is_line_break_grapheme, push_grapheme_cells, visit_styled_graphemes};
4use crate::{Cell, Style, StyledText};
5
6/// Display-column bounds for middle truncation of one [`PriorityLine`] item.
7///
8/// Both bounds include the one-column `┄` marker. Content wider than
9/// `max_width` is always truncated, even when the line has spare room. A
10/// retained item may shrink as far as `min_width` while competing for space.
11#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12pub struct PriorityLineTruncation {
13    /// Smallest permitted retained representation, including `┄`.
14    min_width: usize,
15    /// Largest permitted retained representation, including `┄`.
16    max_width: usize,
17}
18
19impl PriorityLineTruncation {
20    /// Creates valid inclusive display-column bounds.
21    ///
22    /// # Panics
23    ///
24    /// Panics when `min_width` is zero or exceeds `max_width`.
25    #[must_use]
26    pub const fn new(min_width: usize, max_width: usize) -> Self {
27        assert!(0 < min_width, "minimum truncation width must be positive");
28        assert!(
29            min_width <= max_width,
30            "minimum truncation width must not exceed maximum"
31        );
32        Self {
33            min_width,
34            max_width,
35        }
36    }
37
38    /// Returns the smallest permitted retained display width.
39    #[must_use]
40    pub const fn min_width(self) -> usize {
41        self.min_width
42    }
43
44    /// Returns the largest permitted retained display width.
45    #[must_use]
46    pub const fn max_width(self) -> usize {
47        self.max_width
48    }
49}
50
51/// Nonnegative importance assigned to one independently hideable line item.
52///
53/// Zero is the most important value. Larger values disappear first.
54#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
55pub struct PriorityLinePriority(
56    /// Stored nonnegative importance value.
57    u16,
58);
59
60impl PriorityLinePriority {
61    /// Creates a priority from its nonnegative integer value.
62    #[must_use]
63    pub const fn new(value: u16) -> Self {
64        Self(value)
65    }
66
67    /// Returns the underlying integer value.
68    #[must_use]
69    pub const fn get(self) -> u16 {
70        self.0
71    }
72}
73
74/// Which edge owns an item in a [`PriorityLine`].
75#[derive(Clone, Copy, Debug, PartialEq, Eq)]
76pub enum PriorityLineAlignment {
77    /// Keep the item in the left-aligned group.
78    Left,
79    /// Keep the item in the right-aligned group.
80    Right,
81}
82
83/// One independently hideable item in a [`PriorityLine`].
84#[derive(Clone, Debug, PartialEq, Eq)]
85struct PriorityLineItem {
86    /// Styled content retained or hidden as one unit.
87    content: StyledText,
88    /// Importance where smaller values are retained before larger values.
89    priority: PriorityLinePriority,
90    /// Edge group that determines the item's visual placement.
91    alignment: PriorityLineAlignment,
92    /// Optional inclusive bounds for middle truncation.
93    truncation: Option<PriorityLineTruncation>,
94    /// Whether a retained predecessor in the same group receives a separator.
95    separated: bool,
96}
97
98/// Sanitized cells plus exact source grapheme boundaries for one item.
99struct PriorityLineItemCells {
100    /// Sanitized styled character cells.
101    cells: Vec<Cell>,
102    /// Cell ranges for complete non-line-break grapheme clusters.
103    graphemes: Vec<std::ops::Range<usize>>,
104}
105
106/// One internal layout pass and its essential-band outcome.
107pub(crate) struct PriorityLineLayout {
108    /// Exactly one terminal row.
109    pub(crate) row: Vec<Cell>,
110    /// Whether every configured essential item survived.
111    pub(crate) required_items_fit: bool,
112}
113
114/// A single line that progressively hides lower-importance styled items.
115///
116/// Items retain insertion order and styling within each alignment group; all
117/// retained left items render before all retained right items. Normally,
118/// adjacent items within a group receive one separator cell; explicitly
119/// attached fragments do not. Retained left and right groups receive at least
120/// one padding cell between them. Layout hides larger priorities first until
121/// the line fits. For equal priorities, later inserted items hide first. Empty
122/// content passed to any push method is ignored.
123///
124/// After every lower-importance item is discarded, a highest-importance
125/// survivor that cannot fit is also hidden. The result stays empty rather than
126/// resurrecting a less-important smaller item, wrapping, or clipping content.
127/// Attach the line through [`crate::StyledBlock::priority_line`] so block
128/// layout can recompute retention for the current terminal width.
129///
130/// Truncatable items first reserve their configured minimum representation.
131/// If all item minima do not fit, whole items disappear by the same priority
132/// rule as non-truncatable items. Remaining columns then grow retained
133/// truncatable items toward their configured maxima in ascending priority and
134/// insertion order. This makes competition deterministic while retaining as
135/// many useful elements as their minima permit. Truncation preserves complete
136/// Unicode grapheme clusters and terminal display width, using exactly `┄`
137/// between the retained prefix and suffix.
138///
139/// Callers may mark an essential priority band with [`Self::require_through`].
140/// If any accepted item in that band cannot survive, the line becomes empty
141/// instead of presenting only part of the essential meaning.
142#[derive(Clone, Debug, Default, PartialEq, Eq)]
143pub struct PriorityLine {
144    /// Items in stable visual and equal-priority order.
145    items: Vec<PriorityLineItem>,
146    /// Optional strongest-to-threshold band that must survive as a unit.
147    required_through: Option<PriorityLinePriority>,
148    /// Style used for one-column separators within edge groups.
149    separator_style: Style,
150}
151
152impl PriorityLine {
153    /// Creates an empty priority line.
154    #[must_use]
155    pub fn new() -> Self {
156        Self::default()
157    }
158
159    /// Returns `true` when the line contains no accepted items.
160    #[must_use]
161    pub fn is_empty(&self) -> bool {
162        self.items.is_empty()
163    }
164
165    /// Requires every accepted item at or above `priority` to survive layout.
166    ///
167    /// If the terminal cannot fit those items at their minimum
168    /// representations, layout returns a fill-only row instead of presenting
169    /// an incomplete essential set. Items with larger numeric priorities keep
170    /// their normal independent truncation and hiding behavior.
171    pub fn require_through(&mut self, priority: PriorityLinePriority) {
172        self.required_through = Some(priority);
173    }
174
175    /// Sets the style for one-column separators between ordinary items.
176    pub fn set_separator_style(&mut self, style: Style) {
177        self.separator_style = style;
178    }
179
180    /// Appends one independently hideable item to an edge group.
181    pub fn push(
182        &mut self,
183        priority: PriorityLinePriority,
184        alignment: PriorityLineAlignment,
185        content: impl Into<StyledText>,
186    ) {
187        self.push_item(priority, alignment, content.into(), None, true);
188    }
189
190    /// Appends one middle-truncatable item to an edge group.
191    ///
192    /// The item stays whole when it fits within `truncation.max_width()`;
193    /// otherwise layout retains a prefix and suffix around the exact `┄`
194    /// marker. It may disappear by priority when even all retained items'
195    /// minimum representations and separators cannot fit.
196    pub fn push_truncated(
197        &mut self,
198        priority: PriorityLinePriority,
199        alignment: PriorityLineAlignment,
200        content: impl Into<StyledText>,
201        truncation: PriorityLineTruncation,
202    ) {
203        self.push_item(priority, alignment, content.into(), Some(truncation), true);
204    }
205
206    /// Appends one item without a separator from its retained predecessor.
207    ///
208    /// This is for independently prioritized fragments that form one visual
209    /// token, such as a status label followed by optional details beginning
210    /// with punctuation.
211    pub fn push_attached(
212        &mut self,
213        priority: PriorityLinePriority,
214        alignment: PriorityLineAlignment,
215        content: impl Into<StyledText>,
216    ) {
217        self.push_item(priority, alignment, content.into(), None, false);
218    }
219
220    /// Appends one middle-truncatable item without a preceding separator.
221    ///
222    /// The truncation and disappearance rules match [`Self::push_truncated`].
223    pub fn push_truncated_attached(
224        &mut self,
225        priority: PriorityLinePriority,
226        alignment: PriorityLineAlignment,
227        content: impl Into<StyledText>,
228        truncation: PriorityLineTruncation,
229    ) {
230        self.push_item(priority, alignment, content.into(), Some(truncation), false);
231    }
232
233    /// Stores one nonempty item with its optional truncation policy.
234    fn push_item(
235        &mut self,
236        priority: PriorityLinePriority,
237        alignment: PriorityLineAlignment,
238        content: StyledText,
239        truncation: Option<PriorityLineTruncation>,
240        separated: bool,
241    ) {
242        if content.is_empty() {
243            return;
244        }
245        self.items.push(PriorityLineItem {
246            content,
247            priority,
248            alignment,
249            truncation,
250            separated,
251        });
252    }
253
254    /// Lays out retained items in exactly one plain-filled row for `width`
255    /// terminal columns.
256    #[must_use]
257    pub fn layout(&self, width: usize) -> Vec<Cell> {
258        self.layout_with_fill(width, Cell::plain(' ')).row
259    }
260
261    /// Lays out one row using `fill` for unused columns.
262    pub(crate) fn layout_with_fill(&self, width: usize, fill: Cell) -> PriorityLineLayout {
263        let cells: Vec<PriorityLineItemCells> = self
264            .items
265            .iter()
266            .map(|item| item_cells(&item.content))
267            .collect();
268        let mut allocations: Vec<usize> = self
269            .items
270            .iter()
271            .zip(&cells)
272            .map(|(item, cells)| minimum_item_width(item, cells))
273            .collect();
274        let retained = minimum_retention(&self.items, &allocations, width);
275        if !required_items_retained(&self.items, &retained, self.required_through) {
276            return PriorityLineLayout {
277                row: std::iter::repeat_n(fill, width).collect(),
278                required_items_fit: false,
279            };
280        }
281
282        let used = allocated_width(&self.items, &cells, &retained, &allocations);
283        let mut remaining = width.saturating_sub(used);
284        let mut growth_order: Vec<usize> = self
285            .items
286            .iter()
287            .enumerate()
288            .filter(|(index, item)| retained[*index] && item.truncation.is_some())
289            .map(|(index, _)| index)
290            .collect();
291        growth_order.sort_by_key(|index| (self.items[*index].priority, *index));
292        for index in growth_order {
293            let maximum = maximum_item_width(&self.items[index], &cells[index]);
294            let current_width = rendered_item_width(&cells[index], allocations[index]);
295            let mut selected = allocations[index];
296            let mut selected_width = current_width;
297            let affordable_maximum = maximum.min(allocations[index].saturating_add(remaining));
298            for candidate in (allocations[index]..=affordable_maximum).rev() {
299                let candidate_width = rendered_item_width(&cells[index], candidate);
300                if candidate_width.saturating_sub(current_width) <= remaining {
301                    selected = candidate;
302                    selected_width = candidate_width;
303                    break;
304                }
305            }
306            allocations[index] = selected;
307            remaining = remaining.saturating_sub(selected_width.saturating_sub(current_width));
308        }
309
310        let left = group_cells(
311            &self.items,
312            &cells,
313            &retained,
314            &allocations,
315            PriorityLineAlignment::Left,
316            self.separator_style,
317        );
318        let right = group_cells(
319            &self.items,
320            &cells,
321            &retained,
322            &allocations,
323            PriorityLineAlignment::Right,
324            self.separator_style,
325        );
326        let left_width = cells_width(&left);
327        let right_width = cells_width(&right);
328        let padding = width.saturating_sub(left_width + right_width);
329
330        let mut row = Vec::new();
331        row.extend(left);
332        row.extend(std::iter::repeat_n(fill, padding));
333        row.extend(right);
334        PriorityLineLayout {
335            row,
336            required_items_fit: true,
337        }
338    }
339}
340
341fn minimum_retention(items: &[PriorityLineItem], allocations: &[usize], width: usize) -> Vec<bool> {
342    let mut retained = vec![true; items.len()];
343    while reserved_width(items, &retained, allocations) > width {
344        let Some(index) = items
345            .iter()
346            .enumerate()
347            .filter(|(index, _)| retained[*index])
348            .max_by_key(|(index, item)| (item.priority, *index))
349            .map(|(index, _)| index)
350        else {
351            break;
352        };
353        retained[index] = false;
354    }
355    retained
356}
357
358fn reserved_width(items: &[PriorityLineItem], retained: &[bool], allocations: &[usize]) -> usize {
359    grouped_width(items, retained, |index| allocations[index])
360}
361
362fn required_items_retained(
363    items: &[PriorityLineItem],
364    retained: &[bool],
365    required_through: Option<PriorityLinePriority>,
366) -> bool {
367    !required_through.is_some_and(|required_through| {
368        items
369            .iter()
370            .enumerate()
371            .any(|(index, item)| item.priority <= required_through && !retained[index])
372    })
373}
374
375fn minimum_item_width(item: &PriorityLineItem, cells: &PriorityLineItemCells) -> usize {
376    item.truncation.map_or_else(
377        || cells_width(&cells.cells),
378        |truncation| truncation.min_width().min(cells_width(&cells.cells)),
379    )
380}
381
382fn maximum_item_width(item: &PriorityLineItem, cells: &PriorityLineItemCells) -> usize {
383    item.truncation.map_or_else(
384        || cells_width(&cells.cells),
385        |truncation| truncation.max_width().min(cells_width(&cells.cells)),
386    )
387}
388
389fn allocated_width(
390    items: &[PriorityLineItem],
391    cells: &[PriorityLineItemCells],
392    retained: &[bool],
393    allocations: &[usize],
394) -> usize {
395    grouped_width(items, retained, |index| {
396        rendered_item_width(&cells[index], allocations[index])
397    })
398}
399
400fn grouped_width(
401    items: &[PriorityLineItem],
402    retained: &[bool],
403    mut item_width: impl FnMut(usize) -> usize,
404) -> usize {
405    let mut group_width = |alignment| {
406        let mut width = 0;
407        let mut has_item = false;
408        for (index, item) in items.iter().enumerate() {
409            if !retained[index] || item.alignment != alignment {
410                continue;
411            }
412            if has_item && item.separated {
413                width += 1;
414            }
415            width += item_width(index);
416            has_item = true;
417        }
418        (width, has_item)
419    };
420    let (left_width, has_left) = group_width(PriorityLineAlignment::Left);
421    let (right_width, has_right) = group_width(PriorityLineAlignment::Right);
422    left_width + right_width + usize::from(has_left && has_right)
423}
424
425fn group_cells(
426    items: &[PriorityLineItem],
427    item_cells: &[PriorityLineItemCells],
428    retained: &[bool],
429    allocations: &[usize],
430    alignment: PriorityLineAlignment,
431    separator_style: Style,
432) -> Vec<Cell> {
433    let mut cells = Vec::new();
434    let mut needs_separator = false;
435    for (index, item) in items.iter().enumerate() {
436        if !retained[index] || item.alignment != alignment {
437            continue;
438        }
439        if needs_separator && item.separated {
440            cells.push(Cell::new(' ', separator_style));
441        }
442        cells.extend(middle_truncated_cells(
443            &item_cells[index],
444            allocations[index],
445        ));
446        needs_separator = true;
447    }
448    cells
449}
450
451fn rendered_item_width(cells: &PriorityLineItemCells, width: usize) -> usize {
452    cells_width(&middle_truncated_cells(cells, width))
453}
454
455fn middle_truncated_cells(item: &PriorityLineItemCells, width: usize) -> Vec<Cell> {
456    let cells = &item.cells;
457    if cells_width(cells) <= width {
458        return cells.clone();
459    }
460    if width == 0 {
461        return Vec::new();
462    }
463
464    let graphemes = &item.graphemes;
465    let content_budget = width - 1;
466    let prefix_budget = content_budget.div_ceil(2);
467    let suffix_budget = content_budget / 2;
468    let mut prefix_end = 0;
469    let mut prefix_width = 0;
470    while prefix_end < graphemes.len() {
471        let range = &graphemes[prefix_end];
472        let grapheme_width = cells_width(&cells[range.clone()]);
473        if prefix_budget < prefix_width + grapheme_width {
474            break;
475        }
476        prefix_width += grapheme_width;
477        prefix_end += 1;
478    }
479
480    let mut suffix_start = graphemes.len();
481    let mut suffix_width = 0;
482    while prefix_end < suffix_start {
483        let range = &graphemes[suffix_start - 1];
484        let grapheme_width = cells_width(&cells[range.clone()]);
485        if suffix_budget < suffix_width + grapheme_width {
486            break;
487        }
488        suffix_width += grapheme_width;
489        suffix_start -= 1;
490    }
491
492    let mut spare = content_budget.saturating_sub(prefix_width + suffix_width);
493    while prefix_end < suffix_start {
494        let prefix_range = &graphemes[prefix_end];
495        let next_prefix_width = cells_width(&cells[prefix_range.clone()]);
496        if next_prefix_width <= spare {
497            spare -= next_prefix_width;
498            prefix_end += 1;
499            continue;
500        }
501        let suffix_range = &graphemes[suffix_start - 1];
502        let next_suffix_width = cells_width(&cells[suffix_range.clone()]);
503        if next_suffix_width <= spare {
504            spare -= next_suffix_width;
505            suffix_start -= 1;
506            continue;
507        }
508        break;
509    }
510
511    let prefix_cell_end = graphemes
512        .get(prefix_end)
513        .map_or(cells.len(), |range| range.start);
514    let suffix_cell_start = graphemes
515        .get(suffix_start)
516        .map_or(cells.len(), |range| range.start);
517    let marker_style = cells
518        .get(prefix_cell_end)
519        .or_else(|| cells.get(suffix_cell_start))
520        .or_else(|| cells.first())
521        .map_or_else(Style::default, |cell| cell.style);
522    let mut out = Vec::new();
523    out.extend_from_slice(&cells[..prefix_cell_end]);
524    out.push(Cell::new('┄', marker_style));
525    out.extend_from_slice(&cells[suffix_cell_start..]);
526    out
527}
528
529fn item_cells(content: &StyledText) -> PriorityLineItemCells {
530    let mut cells = Vec::new();
531    let mut graphemes = Vec::new();
532    visit_styled_graphemes(content.spans(), |grapheme, style, hyperlink| {
533        if is_line_break_grapheme(grapheme) {
534            return;
535        }
536        let start = cells.len();
537        push_grapheme_cells(&mut cells, grapheme, style, hyperlink);
538        graphemes.push(start..cells.len());
539    });
540    PriorityLineItemCells { cells, graphemes }
541}
542
543fn cells_width(cells: &[Cell]) -> usize {
544    cells.iter().map(Cell::col_width).sum()
545}
546
547#[cfg(test)]
548#[path = "priority_line/tests.rs"]
549mod tests;