Skip to main content

linesmith_core/layout/
mod.rs

1//! Layout engine. Takes a list of `Segment`s plus a `StatusContext` and
2//! fits their renders into a terminal-width budget, dropping the
3//! highest-priority (numerically largest) segments first — or, when a
4//! segment opts in via `truncatable`, shrinking it to fit before drop.
5//! Priority-0 segments are never dropped or truncated, even when that
6//! overflows the budget.
7//!
8//! See `docs/specs/segment-system.md` §Layout algorithm.
9
10use crate::data_context::DataContext;
11use crate::segments::{
12    text_width, LineItem, RenderContext, RenderedSegment, Segment, SegmentDefaults, Separator,
13    WidthBounds,
14};
15use crate::theme::{self, Capability, Style, StyledRun, Theme};
16use unicode_segmentation::UnicodeSegmentation;
17
18mod decision;
19pub use decision::LayoutDecision;
20
21/// Two-channel observer the layout engine threads through every
22/// render entry point per ADR-0026. `warn` is required (the engine
23/// uses it for segment-render error diagnostics); `on_decision` is
24/// optional and receives a typed [`LayoutDecision`] per emit site.
25///
26/// The TUI live preview attaches `on_decision` to collect per-frame
27/// status; the production stdout path doesn't attach one, so
28/// disabled-path cost is one `Option::is_none()` check per emit site
29/// (struct construction is deferred behind [`Self::emit_with`]'s
30/// closure, so even `Cow::Owned` user-config ids don't allocate
31/// when no observer is attached).
32///
33/// Single lifetime `'a` because Rust unifies independent borrows to
34/// their shortest common lifetime — a second lifetime parameter
35/// would buy no flexibility for `&mut dyn FnMut` borrows.
36pub struct LayoutObservers<'a> {
37    warn: &'a mut dyn FnMut(&str),
38    on_decision: Option<&'a mut dyn FnMut(&LayoutDecision)>,
39}
40
41impl<'a> LayoutObservers<'a> {
42    /// Construct with the required `warn` channel; no observer attached.
43    pub fn new(warn: &'a mut dyn FnMut(&str)) -> Self {
44        Self {
45            warn,
46            on_decision: None,
47        }
48    }
49
50    /// Attach an `on_decision` callback so the engine routes typed
51    /// `LayoutDecision` events through it at every emit site. Calling
52    /// twice replaces the previous callback (last-write-wins).
53    #[must_use]
54    pub fn with_decision(mut self, on_decision: &'a mut dyn FnMut(&LayoutDecision)) -> Self {
55        self.on_decision = Some(on_decision);
56        self
57    }
58
59    /// Engine-side: emit a warning through the `warn` channel.
60    pub(crate) fn warn(&mut self, msg: &str) {
61        (self.warn)(msg);
62    }
63
64    /// Engine-side: emit a typed decision. The closure constructs
65    /// the `LayoutDecision` only when an observer is attached, so
66    /// production callers (no `on_decision`) pay zero allocations
67    /// per emit site even for `Cow::Owned` user-config segment ids.
68    pub(crate) fn emit_with(&mut self, decision: impl FnOnce() -> LayoutDecision) {
69        if let Some(cb) = self.on_decision.as_mut() {
70            cb(&decision());
71        }
72    }
73}
74
75/// Render `items` for `ctx` within `terminal_width` cells. Returns the
76/// final line without a trailing newline. Segment render errors go
77/// through [`crate::lsm_error!`] so a broken segment always surfaces,
78/// even under `LINESMITH_LOG=off` — a blank statusline with zero
79/// diagnostic is a bad UX even when the user opted into quiet mode.
80/// Output is unstyled (callers that want theming use
81/// [`render_with_observers`] with their own observers).
82#[must_use]
83pub fn render(items: &[LineItem], ctx: &DataContext, terminal_width: u16) -> String {
84    let mut warn = |msg: &str| crate::lsm_error!("{msg}");
85    let mut observers = LayoutObservers::new(&mut warn);
86    render_with_observers(
87        items,
88        ctx,
89        terminal_width,
90        &mut observers,
91        theme::default_theme(),
92        Capability::None,
93        false,
94    )
95}
96
97/// Same as [`render`] but routes segment render-error diagnostics
98/// (and `LayoutDecision` events, when an observer is attached)
99/// through `observers`, and emits ANSI SGR around each segment per
100/// `theme` and `capability`. Used by [`crate::run_with_context`] so
101/// `cli_main` tests can capture segment errors alongside exit codes
102/// while the render path picks up theme colors.
103///
104/// `hyperlinks` gates OSC 8 emission for runs whose `Style.hyperlink`
105/// is set. Pass `true` when the terminal advertises OSC 8 support
106/// (e.g. via the `supports-hyperlinks` crate or an explicit user
107/// override), `false` otherwise — capable terminals render the run
108/// as a clickable link, others see plain text.
109///
110/// Thin wrapper over [`render_to_runs`] + [`runs_to_ansi`]; same
111/// layout, same bytes. Callers that need the styled-run form (e.g.
112/// the TUI preview pane) call [`render_to_runs`] directly.
113#[must_use]
114pub fn render_with_observers(
115    items: &[LineItem],
116    ctx: &DataContext,
117    terminal_width: u16,
118    observers: &mut LayoutObservers<'_>,
119    theme: &Theme,
120    capability: Capability,
121    hyperlinks: bool,
122) -> String {
123    let runs = render_to_runs(items, ctx, terminal_width, observers);
124    runs_to_ansi(&runs, theme, capability, hyperlinks)
125}
126
127/// Render `items` into a flat [`StyledRun`] sequence. One run per
128/// surviving segment, plus one run per non-empty surviving separator
129/// (in render order). Layout decisions — priority-drop,
130/// `shrink_to_fit`, truncatable reflow, width-bound truncation —
131/// match [`render`] / [`render_with_observers`] exactly; only the
132/// emit form differs.
133///
134/// `Separator::None` contributes no run; it would be an empty-text
135/// run with no consumer use. Separator runs carry [`Style::default`];
136/// separators inherit no styling from their flanking segments.
137///
138/// Segment render errors and `Ok(None)` go through `observers.warn`
139/// exactly as in the ANSI path; the run sequence reflects only
140/// segments that survived to the layout pass, with separators
141/// surviving only between two surviving segments.
142///
143/// When the caller has attached an `on_decision` callback via
144/// [`LayoutObservers::with_decision`], the engine routes a typed
145/// [`LayoutDecision`] event through it at each of the five emit
146/// sites (PriorityDrop, ShrinkApplied, ReflowApplied,
147/// WidthBoundUnderMinDrop, WidthBoundOverMaxTruncate). Width-bound
148/// events fire during [`collect_items_with`]; the rest fire inside
149/// [`apply_layout`]'s reflow loop.
150#[must_use]
151pub fn render_to_runs(
152    items: &[LineItem],
153    ctx: &DataContext,
154    terminal_width: u16,
155    observers: &mut LayoutObservers<'_>,
156) -> Vec<StyledRun> {
157    let rc = RenderContext::new(terminal_width);
158    let layout_items = collect_items_with(items, ctx, &rc, observers);
159    let laid_out = apply_layout(layout_items, ctx, &rc, terminal_width, observers);
160    items_to_runs(&laid_out)
161}
162
163/// Emit a flat [`StyledRun`] sequence as an ANSI SGR-wrapped string
164/// suitable for terminal stdout. Each run with non-empty styling gets
165/// its own `sgr_open` / `sgr_reset` pair so decorations don't leak
166/// across boundaries; plain runs pass through unwrapped. When
167/// `hyperlinks` is `true`, runs carrying `Style.hyperlink` are
168/// additionally wrapped in OSC 8 open/close so capable terminals
169/// render them as clickable links; the OSC 8 wrap sits *outside* the
170/// SGR pair so the link survives the SGR reset. `hyperlinks = false`
171/// drops the URL silently — the run still emits, just without the
172/// link.
173#[must_use]
174pub fn runs_to_ansi(
175    runs: &[StyledRun],
176    theme: &Theme,
177    capability: Capability,
178    hyperlinks: bool,
179) -> String {
180    let mut out = String::new();
181    for run in runs {
182        let link = run.style.hyperlink.as_deref().filter(|_| hyperlinks);
183        if let Some(url) = link {
184            push_osc8_open(&mut out, url);
185        }
186        let open = theme::sgr_open(&run.style, theme, capability);
187        if open.is_empty() {
188            out.push_str(&run.text);
189        } else {
190            out.push_str(&open);
191            out.push_str(&run.text);
192            out.push_str(theme::sgr_reset());
193        }
194        if link.is_some() {
195            push_osc8_close(&mut out);
196        }
197    }
198    out
199}
200
201/// OSC 8 hyperlink open: `ESC ] 8 ; ; <url> ST`. Uses ESC `\` (the
202/// canonical String Terminator) rather than the BEL alternative;
203/// modern terminals accept both but ESC `\` is the spec form and
204/// safer when output is piped through tools that interpret BEL.
205///
206/// Strips control characters from `url` before emission. Without
207/// this, an embedded `ESC \` in a plugin- or repo-derived URL would
208/// terminate the OSC 8 envelope early and turn the remainder into
209/// raw terminal control sequences — the same escape-injection class
210/// `RenderedSegment::new` strips from segment text.
211fn push_osc8_open(out: &mut String, url: &str) {
212    out.push_str("\x1b]8;;");
213    for c in url.chars() {
214        if !c.is_control() {
215            out.push(c);
216        }
217    }
218    out.push_str("\x1b\\");
219}
220
221/// OSC 8 hyperlink close: same envelope, empty URL.
222fn push_osc8_close(out: &mut String) {
223    out.push_str("\x1b]8;;\x1b\\");
224}
225
226/// One slot in the post-collect layout list. `Segment` carries the
227/// rendered output, the defaults needed to place it (priority,
228/// bounds, truncatable), and a back-reference to the trait object so
229/// the reflow loop can call `shrink_to_fit` without re-walking the
230/// input slice. `Separator` carries a resolved [`Separator`] value
231/// ready for width math and emit; runtime overrides have already
232/// been merged in.
233enum LayoutItem<'a> {
234    Segment(SegmentEntry<'a>),
235    Separator(Separator),
236}
237
238struct SegmentEntry<'a> {
239    /// User-facing config name threaded through for `LayoutDecision` events (ADR-0026).
240    id: &'a std::borrow::Cow<'static, str>,
241    rendered: RenderedSegment,
242    defaults: SegmentDefaults,
243    segment: &'a dyn Segment,
244}
245
246/// Walk the raw [`LineItem`] list, render each segment, and emit a
247/// [`LayoutItem`] sequence ready for the layout pass.
248///
249/// Adjacency rules baked in here so downstream passes don't need to
250/// know about them:
251///
252/// - A separator survives only when it sits between two surviving
253///   segments. Leading separators, trailing separators, and
254///   separators flanking a dropped segment are pruned.
255/// - A segment's per-render `right_separator` override (the plugin
256///   path) replaces the inline separator immediately to its right.
257///   The override is applied here so width math and drop decisions
258///   downstream see the post-override separator value.
259fn collect_items_with<'a>(
260    items: &'a [LineItem],
261    ctx: &DataContext,
262    rc: &RenderContext,
263    observers: &mut LayoutObservers<'_>,
264) -> Vec<LayoutItem<'a>> {
265    let mut out: Vec<LayoutItem<'a>> = Vec::with_capacity(items.len());
266    for item in items {
267        match item {
268            LineItem::Segment { id, segment } => {
269                let defaults = segment.defaults();
270                let rendered = match segment.render(ctx, rc) {
271                    Ok(Some(r)) => r,
272                    Ok(None) => {
273                        pop_trailing_separator(&mut out);
274                        continue;
275                    }
276                    Err(err) => {
277                        observers.warn(&format!("segment error: {err}"));
278                        pop_trailing_separator(&mut out);
279                        continue;
280                    }
281                };
282                let Some(rendered) = apply_width_bounds(rendered, defaults.width, id, observers)
283                else {
284                    pop_trailing_separator(&mut out);
285                    continue;
286                };
287                out.push(LayoutItem::Segment(SegmentEntry {
288                    id,
289                    rendered,
290                    defaults,
291                    segment: segment.as_ref(),
292                }));
293            }
294            LineItem::Separator(sep) => {
295                // Push only when directly preceded by a surviving
296                // segment, so leading/orphaned separators drop.
297                if matches!(out.last(), Some(LayoutItem::Segment(_))) {
298                    out.push(LayoutItem::Separator(sep.clone()));
299                }
300            }
301        }
302    }
303    pop_trailing_separator(&mut out);
304    for i in 0..out.len() {
305        apply_override_at(&mut out, i);
306    }
307    out
308}
309
310fn pop_trailing_separator(out: &mut Vec<LayoutItem<'_>>) {
311    if matches!(out.last(), Some(LayoutItem::Separator(_))) {
312        out.pop();
313    }
314}
315
316/// Apply the runtime `right_separator` override for the segment at
317/// `idx` to its right-edge inline separator (if any). Called from
318/// [`collect_items_with`] across the whole list, and again from
319/// [`apply_layout`] at a single index after `shrink_to_fit` / reflow
320/// rewrites a segment's render — both paths can produce a different
321/// `right_separator` than the pre-shrink value, and the inline slot
322/// must track it.
323///
324/// `Some` overrides the inline value; `None` is a no-op (the
325/// pre-existing inline value stays). The current implementation
326/// can't distinguish "segment never had an override" from "segment
327/// flipped from `Some` back to `None`" — the conservative behavior
328/// keeps the most recently applied `Some`. Plugins that flip in
329/// the latter direction are out of contract.
330fn apply_override_at(items: &mut [LayoutItem<'_>], idx: usize) {
331    let override_sep = match items.get(idx) {
332        Some(LayoutItem::Segment(seg)) => seg.rendered.right_separator.clone(),
333        _ => None,
334    };
335    if let Some(s) = override_sep {
336        if let Some(LayoutItem::Separator(slot)) = items.get_mut(idx + 1) {
337            *slot = s;
338        }
339    }
340}
341
342/// Runs the priority-drop / shrink / reflow loop and returns surviving
343/// items in render order. When a segment must be removed, the adjacent
344/// separator goes with it (see [`drop_segment_and_adjacent_separator`]).
345///
346/// Per iteration, for the highest-priority droppable segment:
347/// `shrink_to_fit` first, `truncatable` end-ellipsis reflow second,
348/// drop the whole segment last. Each compaction path may produce a
349/// `right_separator` different from the pre-shrink value, so the
350/// inline override slot gets re-propagated after a rewrite.
351///
352/// Emits one [`LayoutDecision`] per iteration through `observers`:
353/// `ShrinkApplied`, `ReflowApplied`, or `PriorityDrop` depending on
354/// which branch fired. Width-bound emits (`WidthBoundUnderMinDrop` /
355/// `WidthBoundOverMaxTruncate`) fire earlier inside
356/// [`apply_width_bounds`] during [`collect_items_with`].
357fn apply_layout<'a>(
358    mut items: Vec<LayoutItem<'a>>,
359    ctx: &DataContext,
360    rc: &RenderContext,
361    terminal_width: u16,
362    observers: &mut LayoutObservers<'_>,
363) -> Vec<LayoutItem<'a>> {
364    let budget = u32::from(terminal_width);
365    loop {
366        let total = total_width(&items);
367        if total <= budget {
368            break;
369        }
370        let Some(drop_idx) = highest_priority_droppable(&items) else {
371            break;
372        };
373        let overflow = total - budget;
374        // `highest_priority_droppable` only returns segment indices, so
375        // this match always binds.
376        let LayoutItem::Segment(seg) = &items[drop_idx] else {
377            break;
378        };
379        // Capture immutable fields before the mutation below would
380        // invalidate `seg`. `id` is a pointer copy into the input
381        // `LineItem` slice, valid past any `items[drop_idx]` rewrite.
382        let id: &std::borrow::Cow<'static, str> = seg.id;
383        let priority = seg.defaults.priority;
384        let pre_width = seg.rendered.width;
385        let truncatable = seg.defaults.truncatable;
386        let target =
387            u16::try_from(u32::from(pre_width).saturating_sub(overflow)).unwrap_or(u16::MAX);
388
389        if let Some(shrunk) = try_shrink(seg, ctx, rc, overflow) {
390            let to_width = shrunk.width;
391            if let LayoutItem::Segment(s) = &mut items[drop_idx] {
392                s.rendered = shrunk;
393            }
394            apply_override_at(&mut items, drop_idx);
395            observers.emit_with(|| {
396                LayoutDecision::shrink_applied(id.clone(), pre_width, to_width, target)
397            });
398        } else if truncatable {
399            if let Some(reflowed) = try_reflow(seg, overflow) {
400                let to_width = reflowed.rendered.width;
401                items[drop_idx] = LayoutItem::Segment(reflowed);
402                apply_override_at(&mut items, drop_idx);
403                observers.emit_with(|| {
404                    LayoutDecision::reflow_applied(id.clone(), pre_width, to_width, target)
405                });
406            } else {
407                observers.emit_with(|| {
408                    LayoutDecision::priority_drop(
409                        id.clone(),
410                        priority,
411                        terminal_width,
412                        overflow,
413                        pre_width,
414                    )
415                });
416                drop_segment_and_adjacent_separator(&mut items, drop_idx);
417            }
418        } else {
419            observers.emit_with(|| {
420                LayoutDecision::priority_drop(
421                    id.clone(),
422                    priority,
423                    terminal_width,
424                    overflow,
425                    pre_width,
426                )
427            });
428            drop_segment_and_adjacent_separator(&mut items, drop_idx);
429        }
430    }
431    items
432}
433
434/// Index of the highest-priority droppable segment, or `None` when
435/// every segment is priority-0 (pinned).
436fn highest_priority_droppable(items: &[LayoutItem<'_>]) -> Option<usize> {
437    items
438        .iter()
439        .enumerate()
440        .filter_map(|(i, item)| match item {
441            LayoutItem::Segment(seg) if seg.defaults.priority > 0 => {
442                Some((i, seg.defaults.priority))
443            }
444            _ => None,
445        })
446        .max_by_key(|(_, pri)| *pri)
447        .map(|(i, _)| i)
448}
449
450/// Drop the segment at `idx` along with one adjacent separator: the
451/// right-edge separator first, falling back to the left-edge when
452/// the segment was the last in the line.
453fn drop_segment_and_adjacent_separator(items: &mut Vec<LayoutItem<'_>>, idx: usize) {
454    let next_is_sep = matches!(items.get(idx + 1), Some(LayoutItem::Separator(_)));
455    let prev_is_sep = idx > 0 && matches!(items.get(idx - 1), Some(LayoutItem::Separator(_)));
456    if next_is_sep {
457        items.remove(idx);
458        items.remove(idx);
459    } else if prev_is_sep {
460        items.remove(idx);
461        items.remove(idx - 1);
462    } else {
463        items.remove(idx);
464    }
465}
466
467/// Test-only helper that mirrors `render_with_observers`'s compose order.
468/// Lets unit tests build [`LayoutItem`] literals directly without
469/// restating the layout-then-emit dance per case. Decisions are dropped
470/// here; tests that need to assert emits should drive `render_to_runs`
471/// with a `Vec<LayoutDecision>`-collecting observer.
472#[cfg(test)]
473fn render_items(
474    items: Vec<LayoutItem<'_>>,
475    ctx: &DataContext,
476    rc: &RenderContext,
477    terminal_width: u16,
478    theme: &Theme,
479    capability: Capability,
480) -> String {
481    let mut warn: fn(&str) = |_: &str| {};
482    let mut observers = LayoutObservers::new(&mut warn);
483    let laid_out = apply_layout(items, ctx, rc, terminal_width, &mut observers);
484    let runs = items_to_runs(&laid_out);
485    runs_to_ansi(&runs, theme, capability, false)
486}
487
488/// Flatten step for [`render_to_runs`]: see that function for the
489/// emit contract. Separator runs carry [`Style::default`];
490/// `Separator::None` (text == "") is filtered here so consumers
491/// don't see empty-text runs.
492fn items_to_runs(items: &[LayoutItem<'_>]) -> Vec<StyledRun> {
493    items
494        .iter()
495        .filter_map(|item| match item {
496            LayoutItem::Segment(seg) => Some(StyledRun {
497                text: seg.rendered.text.clone(),
498                style: seg.rendered.style.clone(),
499            }),
500            LayoutItem::Separator(sep) => {
501                let text = sep.text();
502                if text.is_empty() {
503                    None
504                } else {
505                    Some(StyledRun {
506                        text: text.to_string(),
507                        style: separator_style(sep),
508                    })
509                }
510            }
511        })
512        .collect()
513}
514
515/// Style for an inter-segment separator run. Plain separators carry
516/// `Style::default()`; powerline chevrons get `Role::Muted` so the
517/// chevron reads as readable secondary text rather than dropping into
518/// the dim divider/border shade (which on most dark themes renders too
519/// close to the background to be legible without bg fill).
520fn separator_style(sep: &Separator) -> Style {
521    match sep {
522        Separator::Powerline { .. } => Style::role(theme::Role::Muted),
523        _ => Style::default(),
524    }
525}
526
527/// Sum of every layout item's width — segments and separators alike.
528/// `u32` prevents `u16` overflow on many wide segments.
529fn total_width(items: &[LayoutItem<'_>]) -> u32 {
530    items
531        .iter()
532        .map(|item| match item {
533            LayoutItem::Segment(seg) => u32::from(seg.rendered.width),
534            LayoutItem::Separator(sep) => u32::from(sep.width()),
535        })
536        .sum()
537}
538
539/// Applies `bounds`: under-min drops the segment (emits
540/// `LayoutDecision::WidthBoundUnderMinDrop`), over-max truncates with
541/// a trailing ellipsis and a recomputed width (emits
542/// `LayoutDecision::WidthBoundOverMaxTruncate`). `None` bounds is an
543/// explicit passthrough — the segment carries no constraints, no event.
544///
545/// `id` is `&Cow<'static, str>` (not `&str`) so the emit-site
546/// `id.clone()` preserves the `Cow::Owned` vs `Cow::Borrowed`
547/// distinction the `LayoutDecision` constructors require.
548#[allow(clippy::ptr_arg)] // see doc — `Cow` identity is load-bearing for the LayoutDecision id.
549fn apply_width_bounds(
550    rendered: RenderedSegment,
551    bounds: Option<WidthBounds>,
552    id: &std::borrow::Cow<'static, str>,
553    observers: &mut LayoutObservers<'_>,
554) -> Option<RenderedSegment> {
555    let Some(bounds) = bounds else {
556        return Some(rendered);
557    };
558    if rendered.width < bounds.min() {
559        let rendered_width = rendered.width;
560        let min = bounds.min();
561        observers.emit_with(|| {
562            LayoutDecision::width_bound_under_min_drop(id.clone(), rendered_width, min)
563        });
564        return None;
565    }
566    if rendered.width > bounds.max() {
567        let rendered_width = rendered.width;
568        let max = bounds.max();
569        observers.emit_with(|| {
570            LayoutDecision::width_bound_over_max_truncate(id.clone(), rendered_width, max)
571        });
572        return Some(truncate_to(rendered, max));
573    }
574    Some(rendered)
575}
576
577/// Shrink `item` by `overflow` cells so the layout fits, or return
578/// `None` when the result would fall below `max(width.min, 2)` cells
579/// (one content grapheme plus the ellipsis), so the caller can drop the
580/// segment whole.
581///
582/// Subtracting exactly `overflow` lands total width on the budget so
583/// the reflow loop exits on its next check; a wide grapheme straddling
584/// the boundary may yield a slightly narrower result, which still
585/// meets the `overflow` requirement.
586fn try_reflow<'a>(item: &SegmentEntry<'a>, overflow: u32) -> Option<SegmentEntry<'a>> {
587    let floor = item.defaults.width.map_or(2, |b| b.min().max(2));
588    let cur = item.rendered.width;
589    let target = u32::from(cur).checked_sub(overflow)?;
590    let target_u16 = u16::try_from(target).ok()?;
591    if target_u16 < floor {
592        return None;
593    }
594    let truncated = truncate_to(item.rendered.clone(), target_u16);
595    if truncated.width < floor {
596        return None;
597    }
598    Some(SegmentEntry {
599        id: item.id,
600        rendered: truncated,
601        defaults: item.defaults,
602        segment: item.segment,
603    })
604}
605
606/// Ask the segment to produce a render at most `cur_width - overflow`
607/// cells wide. Returns `None` when `shrink_to_fit` itself returns
608/// `None` (default impl, or the segment declined). A segment that
609/// returns `Some(r)` with `r.width > target` violates the documented
610/// contract — the engine rejects the response (to preserve the
611/// layout-fit invariant) and routes the violation through
612/// [`crate::lsm_warn!`] so the misbehavior is visible to the segment
613/// author. The caller falls through to `truncatable` end-ellipsis or
614/// drop on any of these outcomes.
615fn try_shrink(
616    item: &SegmentEntry<'_>,
617    ctx: &DataContext,
618    rc: &RenderContext,
619    overflow: u32,
620) -> Option<RenderedSegment> {
621    let cur = item.rendered.width;
622    // `cur < overflow` is reachable: one segment frequently can't
623    // absorb the whole overflow alone (e.g. cost=6 when total
624    // overshoots by 12). `checked_sub` returns `None` and the engine
625    // drops the segment so the loop iterates with a smaller total.
626    let target = u16::try_from(u32::from(cur).checked_sub(overflow)?).ok()?;
627    // Honor the user's declared `width.min` floor on the shrunk
628    // render the same way `apply_width_bounds` and `try_reflow` do —
629    // a configured min is a contract that a too-narrow render is
630    // worse than no render. No `+ 2` like `try_reflow`'s floor
631    // because `shrink_to_fit` produces an arbitrary string, not
632    // text + ellipsis.
633    let min_floor = item.defaults.width.map_or(0, |b| b.min());
634    if target < min_floor {
635        return None;
636    }
637    let shrunk = item.segment.shrink_to_fit(ctx, rc, target)?;
638    if shrunk.width > target {
639        crate::lsm_warn!(
640            "segment shrink_to_fit returned width {} > target {}; rejecting",
641            shrunk.width,
642            target,
643        );
644        return None;
645    }
646    if shrunk.width < min_floor {
647        return None;
648    }
649    Some(shrunk)
650}
651
652/// Truncate `rendered` to at most `max_cells` terminal cells, appending
653/// `…` (U+2026, 1 cell) as a continuation marker. Iterates by grapheme
654/// cluster so combining marks, ZWJ sequences, and emoji stay intact.
655pub(crate) fn truncate_to(rendered: RenderedSegment, max_cells: u16) -> RenderedSegment {
656    if max_cells == 0 {
657        return RenderedSegment::from_parts(
658            String::new(),
659            0,
660            rendered.right_separator,
661            rendered.style,
662        );
663    }
664    // Reserve one cell for the ellipsis.
665    let budget = max_cells.saturating_sub(1);
666    let mut out = String::new();
667    let mut used: u16 = 0;
668    for cluster in rendered.text.graphemes(true) {
669        let w = text_width(cluster);
670        if used.saturating_add(w) > budget {
671            break;
672        }
673        out.push_str(cluster);
674        used = used.saturating_add(w);
675    }
676    out.push('…');
677    RenderedSegment::from_parts(
678        out,
679        used.saturating_add(1),
680        rendered.right_separator,
681        rendered.style,
682    )
683}
684
685#[cfg(test)]
686mod tests;