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;