use crate::data_context::DataContext;
use crate::segments::{
text_width, LineItem, RenderContext, RenderedSegment, Segment, SegmentDefaults, Separator,
WidthBounds,
};
use crate::theme::{self, Capability, Style, StyledRun, Theme};
use unicode_segmentation::UnicodeSegmentation;
mod decision;
pub use decision::LayoutDecision;
pub struct LayoutObservers<'a> {
warn: &'a mut dyn FnMut(&str),
on_decision: Option<&'a mut dyn FnMut(&LayoutDecision)>,
}
impl<'a> LayoutObservers<'a> {
pub fn new(warn: &'a mut dyn FnMut(&str)) -> Self {
Self {
warn,
on_decision: None,
}
}
#[must_use]
pub fn with_decision(mut self, on_decision: &'a mut dyn FnMut(&LayoutDecision)) -> Self {
self.on_decision = Some(on_decision);
self
}
pub(crate) fn warn(&mut self, msg: &str) {
(self.warn)(msg);
}
pub(crate) fn emit_with(&mut self, decision: impl FnOnce() -> LayoutDecision) {
if let Some(cb) = self.on_decision.as_mut() {
cb(&decision());
}
}
}
#[must_use]
pub fn render(items: &[LineItem], ctx: &DataContext, terminal_width: u16) -> String {
let mut warn = |msg: &str| crate::lsm_error!("{msg}");
let mut observers = LayoutObservers::new(&mut warn);
render_with_observers(
items,
ctx,
terminal_width,
&mut observers,
theme::default_theme(),
Capability::None,
false,
)
}
#[must_use]
pub fn render_with_observers(
items: &[LineItem],
ctx: &DataContext,
terminal_width: u16,
observers: &mut LayoutObservers<'_>,
theme: &Theme,
capability: Capability,
hyperlinks: bool,
) -> String {
let runs = render_to_runs(items, ctx, terminal_width, observers);
runs_to_ansi(&runs, theme, capability, hyperlinks)
}
#[must_use]
pub fn render_to_runs(
items: &[LineItem],
ctx: &DataContext,
terminal_width: u16,
observers: &mut LayoutObservers<'_>,
) -> Vec<StyledRun> {
let rc = RenderContext::new(terminal_width);
let layout_items = collect_items_with(items, ctx, &rc, observers);
let laid_out = apply_layout(layout_items, ctx, &rc, terminal_width, observers);
items_to_runs(&laid_out)
}
#[must_use]
pub fn runs_to_ansi(
runs: &[StyledRun],
theme: &Theme,
capability: Capability,
hyperlinks: bool,
) -> String {
let mut out = String::new();
for run in runs {
let link = run.style.hyperlink.as_deref().filter(|_| hyperlinks);
if let Some(url) = link {
push_osc8_open(&mut out, url);
}
let open = theme::sgr_open(&run.style, theme, capability);
if open.is_empty() {
out.push_str(&run.text);
} else {
out.push_str(&open);
out.push_str(&run.text);
out.push_str(theme::sgr_reset());
}
if link.is_some() {
push_osc8_close(&mut out);
}
}
out
}
fn push_osc8_open(out: &mut String, url: &str) {
out.push_str("\x1b]8;;");
for c in url.chars() {
if !c.is_control() {
out.push(c);
}
}
out.push_str("\x1b\\");
}
fn push_osc8_close(out: &mut String) {
out.push_str("\x1b]8;;\x1b\\");
}
enum LayoutItem<'a> {
Segment(SegmentEntry<'a>),
Separator(Separator),
}
struct SegmentEntry<'a> {
id: &'a std::borrow::Cow<'static, str>,
rendered: RenderedSegment,
defaults: SegmentDefaults,
segment: &'a dyn Segment,
}
fn collect_items_with<'a>(
items: &'a [LineItem],
ctx: &DataContext,
rc: &RenderContext,
observers: &mut LayoutObservers<'_>,
) -> Vec<LayoutItem<'a>> {
let mut out: Vec<LayoutItem<'a>> = Vec::with_capacity(items.len());
for item in items {
match item {
LineItem::Segment { id, segment } => {
let defaults = segment.defaults();
let rendered = match segment.render(ctx, rc) {
Ok(Some(r)) => r,
Ok(None) => {
pop_trailing_separator(&mut out);
continue;
}
Err(err) => {
observers.warn(&format!("segment error: {err}"));
pop_trailing_separator(&mut out);
continue;
}
};
let Some(rendered) = apply_width_bounds(rendered, defaults.width, id, observers)
else {
pop_trailing_separator(&mut out);
continue;
};
out.push(LayoutItem::Segment(SegmentEntry {
id,
rendered,
defaults,
segment: segment.as_ref(),
}));
}
LineItem::Separator(sep) => {
if matches!(out.last(), Some(LayoutItem::Segment(_))) {
out.push(LayoutItem::Separator(sep.clone()));
}
}
}
}
pop_trailing_separator(&mut out);
for i in 0..out.len() {
apply_override_at(&mut out, i);
}
out
}
fn pop_trailing_separator(out: &mut Vec<LayoutItem<'_>>) {
if matches!(out.last(), Some(LayoutItem::Separator(_))) {
out.pop();
}
}
fn apply_override_at(items: &mut [LayoutItem<'_>], idx: usize) {
let override_sep = match items.get(idx) {
Some(LayoutItem::Segment(seg)) => seg.rendered.right_separator.clone(),
_ => None,
};
if let Some(s) = override_sep {
if let Some(LayoutItem::Separator(slot)) = items.get_mut(idx + 1) {
*slot = s;
}
}
}
fn apply_layout<'a>(
mut items: Vec<LayoutItem<'a>>,
ctx: &DataContext,
rc: &RenderContext,
terminal_width: u16,
observers: &mut LayoutObservers<'_>,
) -> Vec<LayoutItem<'a>> {
let budget = u32::from(terminal_width);
loop {
let total = total_width(&items);
if total <= budget {
break;
}
let Some(drop_idx) = highest_priority_droppable(&items) else {
break;
};
let overflow = total - budget;
let LayoutItem::Segment(seg) = &items[drop_idx] else {
break;
};
let id: &std::borrow::Cow<'static, str> = seg.id;
let priority = seg.defaults.priority;
let pre_width = seg.rendered.width;
let truncatable = seg.defaults.truncatable;
let target =
u16::try_from(u32::from(pre_width).saturating_sub(overflow)).unwrap_or(u16::MAX);
if let Some(shrunk) = try_shrink(seg, ctx, rc, overflow) {
let to_width = shrunk.width;
if let LayoutItem::Segment(s) = &mut items[drop_idx] {
s.rendered = shrunk;
}
apply_override_at(&mut items, drop_idx);
observers.emit_with(|| {
LayoutDecision::shrink_applied(id.clone(), pre_width, to_width, target)
});
} else if truncatable {
if let Some(reflowed) = try_reflow(seg, overflow) {
let to_width = reflowed.rendered.width;
items[drop_idx] = LayoutItem::Segment(reflowed);
apply_override_at(&mut items, drop_idx);
observers.emit_with(|| {
LayoutDecision::reflow_applied(id.clone(), pre_width, to_width, target)
});
} else {
observers.emit_with(|| {
LayoutDecision::priority_drop(
id.clone(),
priority,
terminal_width,
overflow,
pre_width,
)
});
drop_segment_and_adjacent_separator(&mut items, drop_idx);
}
} else {
observers.emit_with(|| {
LayoutDecision::priority_drop(
id.clone(),
priority,
terminal_width,
overflow,
pre_width,
)
});
drop_segment_and_adjacent_separator(&mut items, drop_idx);
}
}
items
}
fn highest_priority_droppable(items: &[LayoutItem<'_>]) -> Option<usize> {
items
.iter()
.enumerate()
.filter_map(|(i, item)| match item {
LayoutItem::Segment(seg) if seg.defaults.priority > 0 => {
Some((i, seg.defaults.priority))
}
_ => None,
})
.max_by_key(|(_, pri)| *pri)
.map(|(i, _)| i)
}
fn drop_segment_and_adjacent_separator(items: &mut Vec<LayoutItem<'_>>, idx: usize) {
let next_is_sep = matches!(items.get(idx + 1), Some(LayoutItem::Separator(_)));
let prev_is_sep = idx > 0 && matches!(items.get(idx - 1), Some(LayoutItem::Separator(_)));
if next_is_sep {
items.remove(idx);
items.remove(idx);
} else if prev_is_sep {
items.remove(idx);
items.remove(idx - 1);
} else {
items.remove(idx);
}
}
#[cfg(test)]
fn render_items(
items: Vec<LayoutItem<'_>>,
ctx: &DataContext,
rc: &RenderContext,
terminal_width: u16,
theme: &Theme,
capability: Capability,
) -> String {
let mut warn: fn(&str) = |_: &str| {};
let mut observers = LayoutObservers::new(&mut warn);
let laid_out = apply_layout(items, ctx, rc, terminal_width, &mut observers);
let runs = items_to_runs(&laid_out);
runs_to_ansi(&runs, theme, capability, false)
}
fn items_to_runs(items: &[LayoutItem<'_>]) -> Vec<StyledRun> {
items
.iter()
.filter_map(|item| match item {
LayoutItem::Segment(seg) => Some(StyledRun {
text: seg.rendered.text.clone(),
style: seg.rendered.style.clone(),
}),
LayoutItem::Separator(sep) => {
let text = sep.text();
if text.is_empty() {
None
} else {
Some(StyledRun {
text: text.to_string(),
style: separator_style(sep),
})
}
}
})
.collect()
}
fn separator_style(sep: &Separator) -> Style {
match sep {
Separator::Powerline { .. } => Style::role(theme::Role::Muted),
_ => Style::default(),
}
}
fn total_width(items: &[LayoutItem<'_>]) -> u32 {
items
.iter()
.map(|item| match item {
LayoutItem::Segment(seg) => u32::from(seg.rendered.width),
LayoutItem::Separator(sep) => u32::from(sep.width()),
})
.sum()
}
#[allow(clippy::ptr_arg)] fn apply_width_bounds(
rendered: RenderedSegment,
bounds: Option<WidthBounds>,
id: &std::borrow::Cow<'static, str>,
observers: &mut LayoutObservers<'_>,
) -> Option<RenderedSegment> {
let Some(bounds) = bounds else {
return Some(rendered);
};
if rendered.width < bounds.min() {
let rendered_width = rendered.width;
let min = bounds.min();
observers.emit_with(|| {
LayoutDecision::width_bound_under_min_drop(id.clone(), rendered_width, min)
});
return None;
}
if rendered.width > bounds.max() {
let rendered_width = rendered.width;
let max = bounds.max();
observers.emit_with(|| {
LayoutDecision::width_bound_over_max_truncate(id.clone(), rendered_width, max)
});
return Some(truncate_to(rendered, max));
}
Some(rendered)
}
fn try_reflow<'a>(item: &SegmentEntry<'a>, overflow: u32) -> Option<SegmentEntry<'a>> {
let floor = item.defaults.width.map_or(2, |b| b.min().max(2));
let cur = item.rendered.width;
let target = u32::from(cur).checked_sub(overflow)?;
let target_u16 = u16::try_from(target).ok()?;
if target_u16 < floor {
return None;
}
let truncated = truncate_to(item.rendered.clone(), target_u16);
if truncated.width < floor {
return None;
}
Some(SegmentEntry {
id: item.id,
rendered: truncated,
defaults: item.defaults,
segment: item.segment,
})
}
fn try_shrink(
item: &SegmentEntry<'_>,
ctx: &DataContext,
rc: &RenderContext,
overflow: u32,
) -> Option<RenderedSegment> {
let cur = item.rendered.width;
let target = u16::try_from(u32::from(cur).checked_sub(overflow)?).ok()?;
let min_floor = item.defaults.width.map_or(0, |b| b.min());
if target < min_floor {
return None;
}
let shrunk = item.segment.shrink_to_fit(ctx, rc, target)?;
if shrunk.width > target {
crate::lsm_warn!(
"segment shrink_to_fit returned width {} > target {}; rejecting",
shrunk.width,
target,
);
return None;
}
if shrunk.width < min_floor {
return None;
}
Some(shrunk)
}
pub(crate) fn truncate_to(rendered: RenderedSegment, max_cells: u16) -> RenderedSegment {
if max_cells == 0 {
return RenderedSegment::from_parts(
String::new(),
0,
rendered.right_separator,
rendered.style,
);
}
let budget = max_cells.saturating_sub(1);
let mut out = String::new();
let mut used: u16 = 0;
for cluster in rendered.text.graphemes(true) {
let w = text_width(cluster);
if used.saturating_add(w) > budget {
break;
}
out.push_str(cluster);
used = used.saturating_add(w);
}
out.push('…');
RenderedSegment::from_parts(
out,
used.saturating_add(1),
rendered.right_separator,
rendered.style,
)
}
#[cfg(test)]
mod tests;