use crate::extensions::p10k::config::{p9k_global, p9k_param};
use crate::extensions::p10k::icons;
use crate::ported::params::{getiparam, getsparam};
use crate::ported::utils::getkeystring;
use crate::ported::zsh_h::WCWIDTH;
#[derive(Debug, Clone, Default)]
pub struct Segment {
pub name: String,
pub state: Option<String>,
pub content: String,
pub icon: Option<String>,
pub fg: String,
pub bg: String,
}
pub fn is_joined_name(name: &str) -> (&str, bool) {
match name.strip_suffix("_joined") {
Some(base) => (base, true),
None => (name, false),
}
}
fn translate_color(c: &str) -> String {
if !c.is_empty() && c.bytes().all(|b| b.is_ascii_digit()) {
format!("{c:0>3}")
} else if c.len() > 1 && c.starts_with('#') && c[1..].bytes().all(|b| b.is_ascii_hexdigit()) {
c.to_ascii_lowercase()
} else {
let key = c.strip_prefix("bg-").unwrap_or(c);
let key = key.strip_prefix("fg-").unwrap_or(key);
let key = key.strip_prefix("br").unwrap_or(key);
named_color(key).unwrap_or("").to_string()
}
}
#[rustfmt::skip]
fn named_color(name: &str) -> Option<&'static str> {
let code = match name {
"black" => "000", "red" => "001", "green" => "002", "yellow" => "003",
"blue" => "004", "magenta" => "005", "cyan" => "006", "white" => "007",
"grey" => "008", "maroon" => "009", "lime" => "010", "olive" => "011",
"navy" => "012", "fuchsia" => "013", "aqua" => "014", "teal" => "014",
"silver" => "015", "grey0" => "016", "navyblue" => "017", "darkblue" => "018",
"blue3" => "020", "blue1" => "021", "darkgreen" => "022", "deepskyblue4" => "025",
"dodgerblue3" => "026", "dodgerblue2" => "027", "green4" => "028", "springgreen4" => "029",
"turquoise4" => "030", "deepskyblue3" => "032", "dodgerblue1" => "033", "darkcyan" => "036",
"lightseagreen" => "037", "deepskyblue2" => "038", "deepskyblue1" => "039", "green3" => "040",
"springgreen3" => "041", "cyan3" => "043", "darkturquoise" => "044", "turquoise2" => "045",
"green1" => "046", "springgreen2" => "047", "springgreen1" => "048", "mediumspringgreen" => "049",
"cyan2" => "050", "cyan1" => "051", "purple4" => "055", "purple3" => "056",
"blueviolet" => "057", "grey37" => "059", "mediumpurple4" => "060", "slateblue3" => "062",
"royalblue1" => "063", "chartreuse4" => "064", "paleturquoise4" => "066", "steelblue" => "067",
"steelblue3" => "068", "cornflowerblue" => "069", "darkseagreen4" => "071", "cadetblue" => "073",
"skyblue3" => "074", "chartreuse3" => "076", "seagreen3" => "078", "aquamarine3" => "079",
"mediumturquoise" => "080", "steelblue1" => "081", "seagreen2" => "083", "seagreen1" => "085",
"darkslategray2" => "087", "darkred" => "088", "darkmagenta" => "091", "orange4" => "094",
"lightpink4" => "095", "plum4" => "096", "mediumpurple3" => "098", "slateblue1" => "099",
"wheat4" => "101", "grey53" => "102", "lightslategrey" => "103", "mediumpurple" => "104",
"lightslateblue" => "105", "yellow4" => "106", "darkseagreen" => "108", "lightskyblue3" => "110",
"skyblue2" => "111", "chartreuse2" => "112", "palegreen3" => "114", "darkslategray3" => "116",
"skyblue1" => "117", "chartreuse1" => "118", "lightgreen" => "120", "aquamarine1" => "122",
"darkslategray1" => "123", "deeppink4" => "125", "mediumvioletred" => "126", "darkviolet" => "128",
"purple" => "129", "mediumorchid3" => "133", "mediumorchid" => "134", "darkgoldenrod" => "136",
"rosybrown" => "138", "grey63" => "139", "mediumpurple2" => "140", "mediumpurple1" => "141",
"darkkhaki" => "143", "navajowhite3" => "144", "grey69" => "145", "lightsteelblue3" => "146",
"lightsteelblue" => "147", "darkolivegreen3" => "149", "darkseagreen3" => "150", "lightcyan3" => "152",
"lightskyblue1" => "153", "greenyellow" => "154", "darkolivegreen2" => "155", "palegreen1" => "156",
"darkseagreen2" => "157", "paleturquoise1" => "159", "red3" => "160", "deeppink3" => "162",
"magenta3" => "164", "darkorange3" => "166", "indianred" => "167", "hotpink3" => "168",
"hotpink2" => "169", "orchid" => "170", "orange3" => "172", "lightsalmon3" => "173",
"lightpink3" => "174", "pink3" => "175", "plum3" => "176", "violet" => "177",
"gold3" => "178", "lightgoldenrod3" => "179", "tan" => "180", "mistyrose3" => "181",
"thistle3" => "182", "plum2" => "183", "yellow3" => "184", "khaki3" => "185",
"lightyellow3" => "187", "grey84" => "188", "lightsteelblue1" => "189", "yellow2" => "190",
"darkolivegreen1" => "192", "darkseagreen1" => "193", "honeydew2" => "194", "lightcyan1" => "195",
"red1" => "196", "deeppink2" => "197", "deeppink1" => "199", "magenta2" => "200",
"magenta1" => "201", "orangered1" => "202", "indianred1" => "204", "hotpink" => "206",
"mediumorchid1" => "207", "darkorange" => "208", "salmon1" => "209", "lightcoral" => "210",
"palevioletred1" => "211", "orchid2" => "212", "orchid1" => "213", "orange1" => "214",
"sandybrown" => "215", "lightsalmon1" => "216", "lightpink1" => "217", "pink1" => "218",
"plum1" => "219", "gold1" => "220", "lightgoldenrod2" => "222", "navajowhite1" => "223",
"mistyrose1" => "224", "thistle1" => "225", "yellow1" => "226", "lightgoldenrod1" => "227",
"khaki1" => "228", "wheat1" => "229", "cornsilk1" => "230", "grey100" => "231",
"grey3" => "232", "grey7" => "233", "grey11" => "234", "grey15" => "235",
"grey19" => "236", "grey23" => "237", "grey27" => "238", "grey30" => "239",
"grey35" => "240", "grey39" => "241", "grey42" => "242", "grey46" => "243",
"grey50" => "244", "grey54" => "245", "grey58" => "246", "grey62" => "247",
"grey66" => "248", "grey70" => "249", "grey74" => "250", "grey78" => "251",
"grey82" => "252", "grey85" => "253", "grey89" => "254", "grey93" => "255",
_ => return None,
};
Some(code)
}
fn bg_seq(color: &str) -> String {
if color.is_empty() {
"%k".to_string()
} else {
format!("%K{{{color}}}")
}
}
fn fg_seq(color: &str) -> String {
if color.is_empty() {
"%f".to_string()
} else {
format!("%F{{{color}}}")
}
}
fn resolve_color(seg: &Segment, which: &str, default: &str) -> String {
translate_color(&p9k_param(&seg.name, seg.state.as_deref(), which, default))
}
fn scheme_colors() -> (&'static str, &'static str) {
if p9k_global("COLOR_SCHEME", "dark") == "light" {
("7", "0")
} else {
("0", "7")
}
}
const UNSET: &str = "\u{1}p9k-unset\u{1}";
fn get_icon(seg: Option<&Segment>, key: &str, default: &str) -> String {
let user = match seg {
Some(s) => {
let v = p9k_param(&s.name, s.state.as_deref(), key, UNSET);
if v == UNSET { None } else { Some(v) }
}
None => getsparam(&format!("POWERLEVEL9K_{key}")),
};
match user {
Some(v) => {
let decoded = getkeystring(&v).0;
let mut it = decoded.chars();
if it.next() == Some('\u{8}') && it.next().is_some() && it.next().is_none() {
return format!("%{{{decoded}%}}");
}
decoded
}
None => {
let table = icons::icon(key);
if table.is_empty() {
default.to_string()
} else {
table.to_string()
}
}
}
}
fn seg_param(seg: &Segment, param: &str, default: &str) -> String {
p9k_param(&seg.name, seg.state.as_deref(), param, default)
}
fn visibly_nonempty(s: &str) -> bool {
let chars: Vec<char> = s.chars().collect();
let mut i = 0;
while i < chars.len() {
if chars[i] != '%' {
return true; }
match chars.get(i + 1).copied() {
Some('F') | Some('K') if chars.get(i + 2) == Some(&'{') => {
let mut j = i + 3;
while j < chars.len() && chars[j] != '}' {
j += 1;
}
i = j + 1;
}
Some('f') | Some('k') | Some('b') | Some('B') | Some('u') | Some('U')
| Some('s') | Some('S') | Some('E') => i += 2,
Some('{') => {
let mut j = i + 2;
while j + 1 < chars.len() && !(chars[j] == '%' && chars[j + 1] == '}') {
j += 1;
}
i = j + 2;
}
_ => return true,
}
}
false
}
fn resolved_icon(seg: &Segment) -> String {
let (base, _) = is_joined_name(&seg.name);
crate::p10k::expansion::apply_visual_identifier_expansion(
base,
seg.state.as_deref(),
&seg.icon.clone().unwrap_or_default(),
)
}
fn resolved_content(seg: &Segment) -> String {
let _ = crate::ported::params::setsparam(
"P9K_VISUAL_IDENTIFIER",
&seg.icon.clone().unwrap_or_default(),
);
let (base, _) = is_joined_name(&seg.name);
let expanded = crate::p10k::expansion::apply_content_expansion(
base,
seg.state.as_deref(),
&seg.content,
);
expanded.replace('\r', "")
}
fn fix_backspace_hack(s: &str) -> String {
s.replace(" %{\u{8}", "%{%G").replace(" \u{8}", "")
}
struct LeftState {
bg: Option<String>,
sep: String,
subsep: String,
sss: String,
}
fn render_left_segment(seg: &Segment, joined: bool, st: &mut LeftState, out: &mut String) -> bool {
let stripped;
let seg = match is_joined_name(&seg.name) {
(base, true) => {
stripped = Segment { name: base.to_string(), ..seg.clone() };
&stripped
}
_ => seg,
};
let content = resolved_content(seg);
let icon = resolved_icon(seg);
let has_content = visibly_nonempty(&content);
let has_icon = !icon.is_empty();
if !has_content && !has_icon {
return false;
}
let bg_color = resolve_color(seg, "BACKGROUND", &seg.bg);
let fg_color = resolve_color(seg, "FOREGROUND", &seg.fg);
let bg = bg_seq(&bg_color);
let fg = fg_seq(&fg_color);
let style = format!("%b{bg}{fg}");
let sep = get_icon(Some(seg), "LEFT_SEGMENT_SEPARATOR", "");
let subsep = get_icon(Some(seg), "LEFT_SUBSEGMENT_SEPARATOR", "");
let space = get_icon(Some(seg), "WHITESPACE_BETWEEN_LEFT_SEGMENTS", " ");
let mut left_space = get_icon(Some(seg), "LEFT_LEFT_WHITESPACE", &space);
if left_space.contains('%') {
left_space.push_str(&style); }
let mut right_space = get_icon(Some(seg), "LEFT_RIGHT_WHITESPACE", &space);
if right_space.contains('%') {
right_space.push_str(&style); }
match &st.bg {
None => {
let start = get_icon(Some(seg), "LEFT_PROMPT_FIRST_SEGMENT_START_SYMBOL", "");
if !start.is_empty() {
out.push_str(&format!("%b%k{}{start}", fg_seq(&bg_color)));
}
out.push_str(&style);
out.push_str(&left_space);
}
Some(_) if joined => {
out.push_str(&style);
}
Some(prev_bg) => {
let same_bg = bg_color == *prev_bg || (prev_bg.is_empty() && bg_color == "0");
if same_bg {
if !fg_color.is_empty() && fg_color == bg_color {
let (c1, c2) = scheme_colors();
let alt = if fg_color == c1 { c2 } else { c1 };
out.push_str(&format!("%b{bg}{}", fg_seq(alt)));
} else {
out.push_str(&format!("%b{bg}"));
}
out.push_str(&st.subsep);
out.push_str(&style);
out.push_str(&left_space);
} else {
out.push_str(&format!("%b{bg}"));
out.push_str(&st.sep);
out.push_str(&style);
out.push_str(&left_space);
}
}
}
let vi_color = resolve_color(seg, "VISUAL_IDENTIFIER_COLOR", &fg_color);
let icon_style = format!("%b{bg}{}", fg_seq(&vi_color));
let middle = get_icon(Some(seg), "LEFT_MIDDLE_WHITESPACE", " ");
if seg_param(seg, "ICON_BEFORE_CONTENT", "") != "false" {
let prefix = getkeystring(&seg_param(seg, "PREFIX", "")).0; out.push_str(&prefix);
let need_style = prefix.contains('%'); if has_icon {
if icon_style != style {
out.push_str(&icon_style);
out.push_str(&icon);
out.push_str(&style);
} else {
if need_style {
out.push_str(&style); }
out.push_str(&icon); }
if !middle.is_empty() && has_content {
out.push_str(&middle);
}
} else if need_style {
out.push_str(&style); }
out.push_str(&content); out.push_str(&style);
} else {
let prefix = getkeystring(&seg_param(seg, "PREFIX", "")).0; out.push_str(&prefix);
if prefix.contains('%') {
out.push_str(&style); }
out.push_str(&content); out.push_str(&style);
if has_icon {
let mut need_style = false; if !middle.is_empty() {
if has_content {
out.push_str(&middle); }
need_style = middle.contains('%'); }
if icon_style != style || need_style {
out.push_str(&icon_style); }
out.push_str(&icon); }
}
let suffix = getkeystring(&seg_param(seg, "SUFFIX", "")).0;
out.push_str(&suffix);
if suffix.contains('%') && !right_space.is_empty() {
out.push_str(&style); }
out.push_str(&right_space);
let end_sep = get_icon(Some(seg), "LEFT_PROMPT_LAST_SEGMENT_END_SYMBOL", &sep);
st.sep = format!("{}{sep}", fg_seq(&bg_color));
st.subsep = subsep;
st.sss = format!("{}{end_sep}", fg_seq(&bg_color));
st.bg = Some(bg_color);
true
}
fn render_left_line(segs: &[Segment]) -> String {
let default_sep = get_icon(None, "LEFT_SEGMENT_SEPARATOR", "");
let empty_line = Segment { name: "empty_line".to_string(), ..Default::default() };
let default_end =
get_icon(Some(&empty_line), "LEFT_PROMPT_LAST_SEGMENT_END_SYMBOL", &default_sep);
let mut st = LeftState {
bg: None,
sep: String::new(),
subsep: String::new(),
sss: format!("%f{default_end}"),
};
let mut body = String::new();
let mut group_start = 0usize;
let mut last_rendered: Option<usize> = None;
for (i, seg) in segs.iter().enumerate() {
if !is_joined_name(&seg.name).1 {
group_start = i;
}
let joined = last_rendered.is_some_and(|li| li >= group_start);
if render_left_segment(seg, joined, &mut st, &mut body) {
last_rendered = Some(i);
}
}
let body = fix_backspace_hack(&body); format!("{body}%b%k{}%b%k%f", st.sss)
}
struct RightState {
bg: Option<String>,
w: String,
sss: String,
}
fn render_right_segment(
seg: &Segment,
joined: bool,
st: &mut RightState,
out: &mut String,
) -> bool {
let stripped;
let seg = match is_joined_name(&seg.name) {
(base, true) => {
stripped = Segment { name: base.to_string(), ..seg.clone() };
&stripped
}
_ => seg,
};
let content = resolved_content(seg);
let icon = resolved_icon(seg);
let has_content = visibly_nonempty(&content);
let has_icon = !icon.is_empty();
if !has_content && !has_icon {
return false;
}
let bg_color = resolve_color(seg, "BACKGROUND", &seg.bg);
let fg_color = resolve_color(seg, "FOREGROUND", &seg.fg);
let bg = bg_seq(&bg_color);
let fg = fg_seq(&fg_color);
let style = format!("%b{bg}{fg}");
let sep = get_icon(Some(seg), "RIGHT_SEGMENT_SEPARATOR", "");
let mut subsep = get_icon(Some(seg), "RIGHT_SUBSEGMENT_SEPARATOR", "");
if subsep.contains('%') {
subsep.push_str(&style); }
let space = get_icon(Some(seg), "WHITESPACE_BETWEEN_RIGHT_SEGMENTS", " ");
let mut left_space = get_icon(Some(seg), "RIGHT_LEFT_WHITESPACE", &space);
if left_space.contains('%') {
left_space.push_str(&style); }
let mut right_space = get_icon(Some(seg), "RIGHT_RIGHT_WHITESPACE", &space);
if right_space.contains('%') {
right_space.push_str(&style); }
match &st.bg {
None => {
let start = get_icon(Some(seg), "RIGHT_PROMPT_FIRST_SEGMENT_START_SYMBOL", &sep);
if !start.is_empty() {
out.push_str(&format!("%b%k{}{start}", fg_seq(&bg_color)));
}
out.push_str(&style);
out.push_str(&left_space);
}
Some(_) if joined => {
out.push_str(&st.w);
out.push_str(&style);
}
Some(prev_bg) => {
let same_bg = *prev_bg == bg_color || (bg_color.is_empty() && prev_bg == "0");
out.push_str(&st.w); if same_bg {
out.push_str(&style);
out.push_str(&subsep);
out.push_str(&left_space);
} else {
out.push_str(&format!("{}{sep}", fg_seq(&bg_color)));
out.push_str(&style);
out.push_str(&left_space);
}
}
}
let vi_color = resolve_color(seg, "VISUAL_IDENTIFIER_COLOR", &fg_color);
let icon_style = format!("%b{bg}{}", fg_seq(&vi_color));
let middle = get_icon(Some(seg), "RIGHT_MIDDLE_WHITESPACE", " ");
if seg_param(seg, "ICON_BEFORE_CONTENT", "") != "true" {
let prefix = getkeystring(&seg_param(seg, "PREFIX", "")).0; out.push_str(&prefix);
if prefix.contains('%') {
out.push_str(&style); }
out.push_str(&content); out.push_str(&style);
if has_icon {
let mut need_style = false; if !middle.is_empty() {
if has_content {
out.push_str(&middle); }
need_style = middle.contains('%'); }
if icon_style != style || need_style {
out.push_str(&icon_style); }
out.push_str(&icon); }
} else {
let prefix = getkeystring(&seg_param(seg, "PREFIX", "")).0;
out.push_str(&prefix);
let need_style = prefix.contains('%'); if has_icon {
if icon_style != style {
out.push_str(&icon_style); out.push_str(&icon);
out.push_str(&style);
} else {
if need_style {
out.push_str(&style); }
out.push_str(&icon); }
if !middle.is_empty() && has_content {
out.push_str(&middle);
}
} else if need_style {
out.push_str(&style); }
out.push_str(&content); out.push_str(&style);
}
let suffix = getkeystring(&seg_param(seg, "SUFFIX", "")).0;
out.push_str(&suffix);
let wfg = if !fg_color.is_empty() && fg_color == bg_color {
let (c1, c2) = scheme_colors();
fg_seq(if fg_color == c1 { c2 } else { c1 })
} else {
fg.clone() };
let mut w = String::new();
if !right_space.is_empty() {
w.push_str(&style); }
w.push_str(&right_space);
w.push_str(&format!("%b{bg}{wfg}"));
st.w = w;
let end_sep = get_icon(Some(seg), "RIGHT_PROMPT_LAST_SEGMENT_END_SYMBOL", "");
let mut sss = String::new();
sss.push_str(&style); sss.push_str(&right_space);
if right_space.contains('%') {
sss.push_str(&style); }
if !end_sep.is_empty() {
sss.push_str(&format!("%k{}{end_sep}{style}", fg_seq(&bg_color))); }
st.sss = sss;
st.bg = Some(bg_color); true
}
fn render_right_line(segs: &[Segment]) -> String {
let mut st = RightState { bg: None, w: String::new(), sss: String::new() };
let mut body = String::new();
let mut group_start = 0usize;
let mut last_rendered: Option<usize> = None;
for (i, seg) in segs.iter().enumerate() {
if !is_joined_name(&seg.name).1 {
group_start = i;
}
let joined = last_rendered.is_some_and(|li| li >= group_start);
if render_right_segment(seg, joined, &mut st, &mut body) {
last_rendered = Some(i);
}
}
if body.is_empty() {
return String::new();
}
let body = fix_backspace_hack(&body); format!("{body}{}%b%k%f", st.sss)
}
fn strip_ansi(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut it = s.chars().peekable();
while let Some(c) = it.next() {
if c != '\u{1b}' {
out.push(c);
continue;
}
match it.peek().copied() {
Some('[') => {
it.next();
for c in it.by_ref() {
if ('\u{40}'..='\u{7e}').contains(&c) {
break;
}
}
}
Some(']') => {
it.next();
let mut prev_esc = false;
for c in it.by_ref() {
if c == '\u{7}' || (prev_esc && c == '\\') {
break;
}
prev_esc = c == '\u{1b}';
}
}
Some(_) => {
it.next(); }
None => {}
}
}
out
}
fn prompt_visible_width(s: &str) -> usize {
let (expanded, _, _) = crate::ported::prompt::promptexpand(s, 0, None);
strip_ansi(&expanded)
.chars()
.map(|c| WCWIDTH(c).max(0) as usize)
.sum()
}
fn rprompt_indent() -> usize {
match getsparam("ZLE_RPROMPT_INDENT") {
Some(v) if !v.is_empty() => {
if v.starts_with('-') {
0
} else {
v.parse().unwrap_or(0)
}
}
_ => 1,
}
}
fn align_line(
left: &str,
right: &str,
line: usize,
columns: usize,
measure: &dyn Fn(&str) -> usize,
) -> String {
if right.is_empty() {
return format!("{left}\n");
}
let kind = if line == 0 { "FIRST" } else { "NEWLINE" };
let mut ch = get_icon(None, &format!("MULTILINE_{kind}_PROMPT_GAP_CHAR"), "");
if ch.is_empty() {
ch = " ".to_string();
}
if ch.chars().count() != 1 || ch.chars().next().map(WCWIDTH) != Some(1) {
tracing::warn!(target: "p10k", gap_char = %ch, kind,
"MULTILINE_*_PROMPT_GAP_CHAR is not one character long; using ' '");
ch = " ".to_string();
}
let scope = format!("multiline_{}_prompt_gap", kind.to_ascii_lowercase());
let mut style = String::new();
let bgc = translate_color(&p9k_param(&scope, None, "BACKGROUND", ""));
if !bgc.is_empty() {
style.push_str(&bg_seq(&bgc));
}
let fgc = translate_color(&p9k_param(&scope, None, "FOREGROUND", ""));
if !fgc.is_empty() {
style.push_str(&fg_seq(&fgc));
}
let reset = if style.is_empty() { "" } else { "%b%k%f" }; let ind = rprompt_indent();
let m = columns as i64 - (measure(left) + measure(right)) as i64 - ind as i64 - 1;
if m < 0 {
return format!("{left}{style}\n{reset}");
}
let term = if ind == 0 { "%{\n%}" } else { "\n" };
format!(
"{left}{style}{}{right}{term}{reset}",
ch.repeat(m as usize + 1) )
}
fn left_frame_prefix(line: usize, num_lines: usize) -> String {
if num_lines < 2 {
return String::new();
}
let key = if line == 0 {
"MULTILINE_FIRST_PROMPT_PREFIX" } else if line + 1 == num_lines {
"MULTILINE_LAST_PROMPT_PREFIX" } else {
"MULTILINE_NEWLINE_PROMPT_PREFIX" };
let prompt_on_newline = p9k_global("PROMPT_ON_NEWLINE", "") == "true";
if getsparam(&format!("POWERLEVEL9K_{key}")).is_none() && !prompt_on_newline {
return String::new();
}
get_icon(None, key, "")
}
fn right_frame_suffix(line: usize, num_lines: usize) -> String {
if num_lines < 2 {
return String::new();
}
let key = if line == 0 {
"MULTILINE_FIRST_PROMPT_SUFFIX"
} else if line + 1 == num_lines {
"MULTILINE_LAST_PROMPT_SUFFIX"
} else {
"MULTILINE_NEWLINE_PROMPT_SUFFIX"
};
get_icon(None, key, "")
}
pub fn render_prompt(
left_lines: &[Vec<Segment>],
right_lines: &[Vec<Segment>],
) -> (String, String) {
static EMPTY: Vec<Segment> = Vec::new();
let disable_rprompt = p9k_global("DISABLE_RPROMPT", "") == "true";
let num_lines = left_lines.len().max(right_lines.len()).max(1);
let rprompt_on_newline = p9k_global("RPROMPT_ON_NEWLINE", "") == "true";
let line_pair = |i: usize| -> (&Vec<Segment>, &Vec<Segment>) {
let left = if num_lines > left_lines.len() {
let pad = num_lines - left_lines.len();
if i < pad { &EMPTY } else { &left_lines[i - pad] }
} else {
left_lines.get(i).unwrap_or(&EMPTY)
};
let right = if num_lines > right_lines.len() && rprompt_on_newline {
let pad = num_lines - right_lines.len();
if i < pad { &EMPTY } else { &right_lines[i - pad] }
} else {
right_lines.get(i).unwrap_or(&EMPTY)
};
(left, right)
};
let mut prompt = String::new();
prompt.push_str("%b%k%f");
if p9k_global("PROMPT_ADD_NEWLINE", "") == "true" {
let count = p9k_global("PROMPT_ADD_NEWLINE_COUNT", "1")
.parse::<i64>()
.unwrap_or(1)
.max(0);
for _ in 0..count {
prompt.push('\n');
}
}
let columns = match getiparam("COLUMNS") {
c if c > 0 => c as usize,
_ => 80,
};
let end_sep_line = {
let v = get_icon(None, "LEFT_SEGMENT_END_SEPARATOR", "");
if v.is_empty() {
usize::MAX } else if p9k_global("PROMPT_ON_NEWLINE", "") == "true" && num_lines >= 2 {
num_lines - 2
} else {
num_lines - 1
}
};
let end_sep = get_icon(None, "LEFT_SEGMENT_END_SEPARATOR", "");
let mut rprompt = String::new();
for i in 0..num_lines {
let (lsegs, rsegs) = line_pair(i);
let right = if disable_rprompt { String::new() } else { render_right_line(rsegs) };
let frame_suffix = right_frame_suffix(i, num_lines);
let mut line = left_frame_prefix(i, num_lines);
line.push_str(&render_left_line(lsegs));
if i == end_sep_line {
line.push_str(&end_sep);
line.push_str("%b%k%f");
}
if i + 1 == num_lines {
prompt.push_str(&line);
if !right.is_empty() || !frame_suffix.is_empty() {
rprompt = format!("{right}{frame_suffix}");
}
} else {
let right_full = format!("{right}{frame_suffix}");
prompt.push_str(&align_line(&line, &right_full, i, columns, &prompt_visible_width));
}
}
(prompt, rprompt)
}
#[cfg(test)]
mod tests {
use super::*;
fn seg(name: &str, content: &str, fg: &str, bg: &str) -> Segment {
Segment {
name: name.to_string(),
state: None,
content: content.to_string(),
icon: None,
fg: fg.to_string(),
bg: bg.to_string(),
}
}
fn locked<F: FnOnce()>(body: F) {
let _g = crate::test_util::global_state_lock();
body();
}
#[test]
fn left_same_bg_uses_subsegment_separator() {
locked(|| {
let a = seg("aseg", "AAA", "000", "004");
let b = seg("bseg", "BBB", "001", "004");
let line = render_left_line(&[a, b]);
assert!(line.contains("%b%K{004}%F{000}"), "line: {line}");
assert!(
line.contains("%b%K{004}\u{E0B1}%b%K{004}%F{001}"),
"subsegment boundary missing: {line}"
);
let boundary = &line[..line.find("BBB").expect("BBB rendered")];
assert!(
!boundary.contains('\u{E0B0}'),
"full separator leaked into same-bg boundary: {line}"
);
assert!(
line.ends_with("%b%k%F{004}\u{E0B0}%b%k%f"),
"line closer wrong: {line}"
);
});
}
#[test]
fn left_diff_bg_full_separator_prev_fg_next_bg() {
locked(|| {
let a = seg("aseg", "AAA", "000", "004");
let b = seg("bseg", "BBB", "007", "002");
let line = render_left_line(&[a, b]);
assert!(
line.contains("%b%K{002}%F{004}\u{E0B0}%b%K{002}%F{007}"),
"diff-bg boundary (fg=prev_bg on next_bg) missing: {line}"
);
assert!(
line.ends_with("%b%k%F{002}\u{E0B0}%b%k%f"),
"line closer wrong: {line}"
);
});
}
#[test]
fn right_diff_bg_separator_new_fg_on_prev_bg() {
locked(|| {
let a = seg("aseg", "AAA", "000", "004");
let b = seg("bseg", "BBB", "007", "002");
let line = render_right_line(&[a, b]);
assert!(
line.contains("%b%K{004}%F{000}%F{002}\u{E0B2}%b%K{002}%F{007}"),
"right diff-bg boundary missing: {line}"
);
assert!(line.ends_with("%b%k%f"), "right closer wrong: {line}");
});
}
#[test]
fn right_same_bg_uses_subsegment_separator() {
locked(|| {
let a = seg("aseg", "AAA", "000", "004");
let b = seg("bseg", "BBB", "001", "004");
let line = render_right_line(&[a, b]);
assert!(
line.contains("%b%K{004}%F{000}%b%K{004}%F{001}\u{E0B3}"),
"right same-bg subseparator missing: {line}"
);
let boundary = &line[..line.find("BBB").expect("BBB rendered")];
assert_eq!(
boundary.matches('\u{E0B2}').count(),
1,
"full right separator leaked into same-bg boundary: {line}"
);
});
}
#[test]
fn empty_segment_is_skipped_entirely() {
locked(|| {
let a = seg("aseg", "AAA", "000", "004");
let hidden = seg("hseg", "", "007", "001");
let b = seg("bseg", "BBB", "001", "004");
let line = render_left_line(&[a, hidden, b]);
assert!(line.contains('\u{E0B1}'), "line: {line}");
assert!(!line.contains("%K{001}"), "hidden segment leaked: {line}");
});
}
#[test]
fn render_prompt_single_line_shape() {
locked(|| {
let left = vec![vec![seg("aseg", "AAA", "000", "004")]];
let right = vec![vec![seg("bseg", "BBB", "007", "002")]];
let (prompt, rprompt) = render_prompt(&left, &right);
assert!(prompt.starts_with("%b%k%f"), "prompt: {prompt}");
assert!(prompt.contains("AAA"), "prompt: {prompt}");
assert!(!prompt.contains("BBB"), "right leaked into PROMPT: {prompt}");
assert!(rprompt.contains("BBB"), "rprompt: {rprompt}");
assert!(rprompt.ends_with("%b%k%f"), "rprompt: {rprompt}");
});
}
#[test]
fn render_prompt_multiline_last_right_is_rprompt() {
locked(|| {
let left = vec![
vec![seg("aseg", "AAA", "000", "004")],
vec![seg("cseg", "CCC", "000", "006")],
];
let right = vec![Vec::new(), vec![seg("bseg", "BBB", "007", "002")]];
let (prompt, rprompt) = render_prompt(&left, &right);
assert_eq!(prompt.matches('\n').count(), 1, "prompt: {prompt}");
let (line1, line2) = prompt.split_once('\n').expect("two lines");
assert!(line1.contains("AAA"), "line1: {line1}");
assert!(line2.contains("CCC"), "line2: {line2}");
assert!(rprompt.contains("BBB"), "rprompt: {rprompt}");
});
}
#[test]
fn translate_color_forms() {
assert_eq!(translate_color("4"), "004");
assert_eq!(translate_color("255"), "255");
assert_eq!(translate_color("#FFAA00"), "#ffaa00");
assert_eq!(translate_color("red"), "001");
assert_eq!(translate_color("bg-red"), "001");
assert_eq!(translate_color("grey50"), "244");
assert_eq!(translate_color(""), "");
assert_eq!(translate_color("nosuchcolor"), "");
}
#[test]
fn visibly_nonempty_prompt_escapes() {
assert!(!visibly_nonempty(""));
assert!(!visibly_nonempty("%f%k%b"));
assert!(!visibly_nonempty("%F{004}%K{002}"));
assert!(!visibly_nonempty("%{zero width%}"));
assert!(visibly_nonempty(" "));
assert!(visibly_nonempty("%F{004}x"));
assert!(visibly_nonempty("%%"));
}
#[test]
fn backspace_hack_rewrite() {
assert_eq!(fix_backspace_hack(" %{\u{8}X%}"), "%{%GX%}");
assert_eq!(fix_backspace_hack("a \u{8}b"), "ab");
assert_eq!(fix_backspace_hack("plain"), "plain");
}
#[test]
fn segment_scoped_empty_end_symbol_suppresses_closer() {
locked(|| {
use crate::ported::params::{setsparam, unsetparam};
setsparam("POWERLEVEL9K_LEFT_PROMPT_LAST_SEGMENT_END_SYMBOL", "\u{E0BC}");
setsparam("POWERLEVEL9K_PROMPT_CHAR_LEFT_PROMPT_LAST_SEGMENT_END_SYMBOL", "");
assert_eq!(
crate::ported::params::getsparam(
"POWERLEVEL9K_PROMPT_CHAR_LEFT_PROMPT_LAST_SEGMENT_END_SYMBOL"
),
Some(String::new()),
"set-empty scalar must read back as Some(\"\")"
);
assert_eq!(
p9k_param("prompt_char", None, "LEFT_PROMPT_LAST_SEGMENT_END_SYMBOL", "MISS"),
"",
"scoped-empty must win the probe chain"
);
let pc = seg("prompt_char", "\u{276F}", "076", "");
let line = render_left_line(&[pc]);
unsetparam("POWERLEVEL9K_LEFT_PROMPT_LAST_SEGMENT_END_SYMBOL");
unsetparam("POWERLEVEL9K_PROMPT_CHAR_LEFT_PROMPT_LAST_SEGMENT_END_SYMBOL");
assert!(
!line.contains('\u{E0BC}'),
"scoped-empty end symbol must beat the global: {:?}",
line.escape_debug().to_string()
);
});
}
#[test]
fn empty_color_spec_emits_reset_not_empty_braces() {
assert_eq!(fg_seq(""), "%f");
assert_eq!(bg_seq(""), "%k");
locked(|| {
let a = seg("aseg", "AAA", "004", "");
let b = seg("bseg", "BBB", "007", "002");
let line = render_left_line(&[a.clone(), b]);
assert!(!line.contains("%F{}"), "empty %F brace leaked: {line}");
assert!(!line.contains("%K{}"), "empty %K brace leaked: {line}");
assert!(
line.contains("%b%K{002}%f\u{E0B0}"),
"empty-bg separator not reset via %f: {line}"
);
let solo = render_left_line(&[a]);
assert!(
solo.ends_with("%b%k%f\u{E0B0}%b%k%f"),
"empty-bg line closer wrong: {solo}"
);
let ra = seg("aseg", "AAA", "004", "");
let rline = render_right_line(&[ra]);
assert!(!rline.contains("%F{}"), "empty %F brace leaked: {rline}");
assert!(!rline.contains("%K{}"), "empty %K brace leaked: {rline}");
assert!(
rline.starts_with("%b%k%f\u{E0B2}"),
"empty-bg right start symbol not reset via %f: {rline}"
);
});
}
#[test]
fn right_empty_segment_is_skipped_entirely() {
locked(|| {
let a = seg("aseg", "AAA", "000", "004");
let mut hidden = seg("hseg", "", "007", "001");
hidden.icon = Some(String::new());
let b = seg("bseg", "BBB", "001", "004");
let line = render_right_line(&[a, hidden, b]);
assert!(line.contains('\u{E0B3}'), "line: {line}");
assert!(!line.contains("%K{001}"), "hidden segment leaked: {line}");
});
}
#[test]
fn all_empty_segments_render_no_body() {
locked(|| {
let h1 = seg("hseg", "", "007", "001");
let h2 = seg("iseg", "", "003", "002");
let left = render_left_line(&[h1.clone(), h2.clone()]);
assert_eq!(left, "%b%k%f\u{E0B0}%b%k%f", "left: {left}");
assert_eq!(render_right_line(&[h1, h2]), "", "right must stay empty");
});
}
#[test]
fn is_joined_name_suffix() {
assert_eq!(is_joined_name("dir"), ("dir", false));
assert_eq!(is_joined_name("vcs_joined"), ("vcs", true));
assert_eq!(is_joined_name("_joined"), ("", true));
assert_eq!(is_joined_name("joined"), ("joined", false));
}
#[test]
fn left_joined_segment_has_no_separator() {
locked(|| {
let a = seg("aseg", "AAA", "000", "004");
let b = seg("bseg_joined", "BBB", "007", "002");
let line = render_left_line(&[a, b]);
let boundary = {
let s = line.find("AAA").expect("AAA rendered") + 3;
let e = line.find("BBB").expect("BBB rendered");
&line[s..e]
};
assert!(
!boundary.contains('\u{E0B0}') && !boundary.contains('\u{E0B1}'),
"separator leaked into joined boundary: {line}"
);
assert!(
boundary.ends_with(" %b%K{002}%F{007}"),
"joined boundary must be prev right_space + style: {line}"
);
assert!(line.ends_with("%b%k%F{002}\u{E0B0}%b%k%f"), "closer: {line}");
});
}
#[test]
fn left_joined_falls_back_when_anchor_skipped() {
locked(|| {
let a = seg("aseg", "AAA", "000", "004");
let anchor = seg("hseg", "", "007", "001"); let c = seg("cseg_joined", "CCC", "007", "002");
let line = render_left_line(&[a, anchor, c]);
assert!(
line.contains("%b%K{002}%F{004}\u{E0B0}%b%K{002}%F{007}"),
"fallback separator missing: {line}"
);
});
}
#[test]
fn right_joined_segment_has_no_separator() {
locked(|| {
let a = seg("aseg", "AAA", "000", "004");
let b = seg("bseg_joined", "BBB", "007", "002");
let line = render_right_line(&[a, b]);
let boundary = {
let s = line.find("AAA").expect("AAA rendered") + 3;
let e = line.find("BBB").expect("BBB rendered");
&line[s..e]
};
assert!(
!boundary.contains('\u{E0B2}') && !boundary.contains('\u{E0B3}'),
"separator leaked into joined right boundary: {line}"
);
assert!(
boundary.contains("%b%K{004}%F{000}%b%K{002}%F{007}"),
"joined right boundary must be w + style: {line}"
);
});
}
#[test]
fn align_line_padding_math() {
locked(|| {
let measure = |s: &str| s.chars().count();
assert_eq!(align_line("LL", "RRR", 0, 10, &measure), "LL RRR\n");
assert_eq!(align_line("LLL", "RRRRR", 0, 10, &measure), "LLL RRRRR\n");
assert_eq!(align_line("LLLL", "RRRRR", 0, 10, &measure), "LLLL\n");
assert_eq!(align_line("LLLLLLLL", "RRRRR", 0, 10, &measure), "LLLLLLLL\n");
assert_eq!(align_line("LL", "", 0, 10, &measure), "LL\n");
});
}
#[test]
fn align_line_gap_char_style_and_indent() {
locked(|| {
use crate::ported::params::{setsparam, unsetparam};
setsparam("POWERLEVEL9K_MULTILINE_FIRST_PROMPT_GAP_CHAR", "─");
setsparam("POWERLEVEL9K_MULTILINE_FIRST_PROMPT_GAP_FOREGROUND", "244");
setsparam("ZLE_RPROMPT_INDENT", "0");
let measure = |s: &str| s.chars().count();
let first = align_line("LL", "RRR", 0, 10, &measure);
let newline = align_line("LL", "RRR", 1, 10, &measure);
let overflow = align_line("LLLLLLLL", "RRRRR", 0, 10, &measure);
unsetparam("POWERLEVEL9K_MULTILINE_FIRST_PROMPT_GAP_CHAR");
unsetparam("POWERLEVEL9K_MULTILINE_FIRST_PROMPT_GAP_FOREGROUND");
unsetparam("ZLE_RPROMPT_INDENT");
assert_eq!(first, "LL%F{244}─────RRR%{\n%}%b%k%f");
assert_eq!(newline, "LL RRR%{\n%}");
assert_eq!(overflow, "LLLLLLLL%F{244}\n%b%k%f");
});
}
#[test]
fn strip_ansi_sequences() {
assert_eq!(strip_ansi("plain"), "plain");
assert_eq!(strip_ansi("\u{1b}[38;5;4mabc\u{1b}[0m"), "abc");
assert_eq!(strip_ansi("\u{1b}]0;title\u{7}abc"), "abc");
assert_eq!(strip_ansi("a\u{1b}7b"), "ab");
}
#[test]
fn prompt_visible_width_measures_display_columns() {
locked(|| {
assert_eq!(prompt_visible_width(""), 0);
assert_eq!(prompt_visible_width("abc"), 3);
assert_eq!(prompt_visible_width("%%"), 1);
assert_eq!(prompt_visible_width("%F{004}abc%f"), 3);
assert_eq!(prompt_visible_width("%K{002}%B x %b%k"), 3);
});
}
}