zsh/extensions/p10k/render.rs
1//! p10k prompt ASSEMBLY — port of `_p9k_left_prompt_segment`
2//! (p10k.zsh:614-850), `_p9k_right_prompt_segment` (p10k.zsh:853-1099)
3//! and the line assembly of `_p9k_set_prompt` (p10k.zsh:5815-5964) plus
4//! the line prefix/suffix construction from `_p9k_init_lines`
5//! (p10k.zsh:7946-8049) and prompt prefix bits (p10k.zsh:8097-8146).
6//!
7//! The zsh original does not render eagerly: each segment call compiles
8//! a deferred `${...}`-expansion TEMPLATE (cached in `_p9k_cache`) that
9//! zsh's prompt expander evaluates with a runtime state machine
10//! (`_p9k__bg`, `_p9k__i`, `_p9k__s`, `_p9k__ss`, `_p9k__sss`,
11//! `_p9k__w`, `_p9k_t` slot table). This Rust port renders EAGERLY once
12//! per prompt: the state machine collapses into a linear fold over the
13//! segment list, with the same case analysis and the same emitted
14//! prompt escapes. The `_p9k_cache_get`/`_p9k_cache_set` template cache
15//! (p10k:615/834) is therefore unnecessary and omitted.
16//!
17//! Phase-1 simplifications (each marked TODO at the code site):
18//! - the `p10k display` toggle layer (`${_p9k__<i>l-...}` wrappers)
19//! and instant prompt are not ported;
20//! - CONTENT_EXPANSION / VISUAL_IDENTIFIER_EXPANSION: default
21//! passthrough, empty (hide), and literal glyphs are honored;
22//! arbitrary `${...}` templates route through expansion.rs's
23//! singsub path (a zsh FUNCTION body like the user's
24//! `my_git_formatter` VCS formatter is still not evaluated —
25//! VCS falls back to p10k's default git format);
26//! - joined segments (`is_joined_name`, separator "case 2") join only
27//! within their own prompt LINE (p10k's `_p9k_left_join`,
28//! p10k:8414-8433, is built over the concatenated multi-line list,
29//! but a line always opens with `bg=NONE` so cross-line joins can
30//! only matter through skipped-anchor chains); SELF_JOINED
31//! (p10k:697-704) is not honored — each Segment renders once so the
32//! multi-sub-segment case it exists for cannot arise;
33//! - non-last-line right prompts are gap-aligned per
34//! `_p9k_build_gap_post` (gap char, gap fg/bg styling,
35//! ZLE_RPROMPT_INDENT, drop-right-on-overflow); only
36//! MULTILINE_*_PROMPT_GAP_EXPANSION (default passthrough), the
37//! `p10k display */gap` toggles and the `_p9k_prompt_overflow_bug`
38//! terminator variant (p10k:8055) are unported.
39
40use crate::extensions::p10k::config::{p9k_global, p9k_param};
41use crate::extensions::p10k::icons;
42use crate::ported::params::{getiparam, getsparam};
43use crate::ported::utils::getkeystring;
44use crate::ported::zsh_h::WCWIDTH;
45
46/// One rendered prompt segment, produced by segments_core/segments_env.
47/// `content` is already prompt-escaped; `icon` is the resolved glyph;
48/// `fg`/`bg` are the segment's DEFAULT color specs (the `$2`/`$3`
49/// arguments of `_p9k_left_prompt_segment`, p10k:607-608) which the
50/// user's `POWERLEVEL9K_*_FOREGROUND/BACKGROUND` params override.
51#[derive(Debug, Clone, Default)]
52pub struct Segment {
53 pub name: String,
54 pub state: Option<String>,
55 pub content: String,
56 pub icon: Option<String>,
57 pub fg: String,
58 pub bg: String,
59}
60
61/// p10k:5834/5849/5880/5895 —
62/// `${${(0)_p9k_line_segments_left[i]}%_joined}`: a prompt ELEMENT name
63/// may carry a `_joined` suffix. The suffix is stripped for segment
64/// dispatch (the segment function is `prompt_<base>`) and marks the
65/// element as joining the PREVIOUS element's group
66/// (`_p9k_left_join`/`_p9k_right_join`, p10k:8414-8433).
67///
68/// Contract with mod.rs: pass the element name to this fn, dispatch the
69/// segment builders on the returned base name, and put the ORIGINAL
70/// (suffixed) element name back into each built `Segment.name` — render
71/// strips the suffix again for all `POWERLEVEL9K_*` param lookups and
72/// uses it to select separator "case 2" (p10k:673/683/923).
73pub fn is_joined_name(name: &str) -> (&str, bool) {
74 match name.strip_suffix("_joined") {
75 Some(base) => (base, true),
76 None => (name, false),
77 }
78}
79
80// ---------------------------------------------------------------------------
81// Color helpers
82// ---------------------------------------------------------------------------
83
84/// p10k:532-541 — `_p9k_translate_color`: decimal codes are left-padded
85/// to 3 digits, `#hex` is lowercased, names go through the
86/// `__p9k_colors` table (p10k:59-110) after stripping `bg-`/`fg-`/`br`
87/// prefixes. Unknown names translate to "" (zsh empty-subscript miss).
88fn translate_color(c: &str) -> String {
89 if !c.is_empty() && c.bytes().all(|b| b.is_ascii_digit()) {
90 // p10k:534 — ${(l.3..0.)1}: pad to width 3 with zeros.
91 format!("{c:0>3}")
92 } else if c.len() > 1 && c.starts_with('#') && c[1..].bytes().all(|b| b.is_ascii_hexdigit()) {
93 // p10k:536 — hexadecimal color code: lowercased.
94 c.to_ascii_lowercase()
95 } else {
96 // p10k:539 — ${${${1#bg-}#fg-}#br}: strip prefixes, then table.
97 let key = c.strip_prefix("bg-").unwrap_or(c);
98 let key = key.strip_prefix("fg-").unwrap_or(key);
99 let key = key.strip_prefix("br").unwrap_or(key);
100 named_color(key).unwrap_or("").to_string()
101 }
102}
103
104/// p10k:59-110 — `typeset -grA __p9k_colors`: the xterm-256 name table.
105#[rustfmt::skip]
106fn named_color(name: &str) -> Option<&'static str> {
107 let code = match name {
108 "black" => "000", "red" => "001", "green" => "002", "yellow" => "003",
109 "blue" => "004", "magenta" => "005", "cyan" => "006", "white" => "007",
110 "grey" => "008", "maroon" => "009", "lime" => "010", "olive" => "011",
111 "navy" => "012", "fuchsia" => "013", "aqua" => "014", "teal" => "014",
112 "silver" => "015", "grey0" => "016", "navyblue" => "017", "darkblue" => "018",
113 "blue3" => "020", "blue1" => "021", "darkgreen" => "022", "deepskyblue4" => "025",
114 "dodgerblue3" => "026", "dodgerblue2" => "027", "green4" => "028", "springgreen4" => "029",
115 "turquoise4" => "030", "deepskyblue3" => "032", "dodgerblue1" => "033", "darkcyan" => "036",
116 "lightseagreen" => "037", "deepskyblue2" => "038", "deepskyblue1" => "039", "green3" => "040",
117 "springgreen3" => "041", "cyan3" => "043", "darkturquoise" => "044", "turquoise2" => "045",
118 "green1" => "046", "springgreen2" => "047", "springgreen1" => "048", "mediumspringgreen" => "049",
119 "cyan2" => "050", "cyan1" => "051", "purple4" => "055", "purple3" => "056",
120 "blueviolet" => "057", "grey37" => "059", "mediumpurple4" => "060", "slateblue3" => "062",
121 "royalblue1" => "063", "chartreuse4" => "064", "paleturquoise4" => "066", "steelblue" => "067",
122 "steelblue3" => "068", "cornflowerblue" => "069", "darkseagreen4" => "071", "cadetblue" => "073",
123 "skyblue3" => "074", "chartreuse3" => "076", "seagreen3" => "078", "aquamarine3" => "079",
124 "mediumturquoise" => "080", "steelblue1" => "081", "seagreen2" => "083", "seagreen1" => "085",
125 "darkslategray2" => "087", "darkred" => "088", "darkmagenta" => "091", "orange4" => "094",
126 "lightpink4" => "095", "plum4" => "096", "mediumpurple3" => "098", "slateblue1" => "099",
127 "wheat4" => "101", "grey53" => "102", "lightslategrey" => "103", "mediumpurple" => "104",
128 "lightslateblue" => "105", "yellow4" => "106", "darkseagreen" => "108", "lightskyblue3" => "110",
129 "skyblue2" => "111", "chartreuse2" => "112", "palegreen3" => "114", "darkslategray3" => "116",
130 "skyblue1" => "117", "chartreuse1" => "118", "lightgreen" => "120", "aquamarine1" => "122",
131 "darkslategray1" => "123", "deeppink4" => "125", "mediumvioletred" => "126", "darkviolet" => "128",
132 "purple" => "129", "mediumorchid3" => "133", "mediumorchid" => "134", "darkgoldenrod" => "136",
133 "rosybrown" => "138", "grey63" => "139", "mediumpurple2" => "140", "mediumpurple1" => "141",
134 "darkkhaki" => "143", "navajowhite3" => "144", "grey69" => "145", "lightsteelblue3" => "146",
135 "lightsteelblue" => "147", "darkolivegreen3" => "149", "darkseagreen3" => "150", "lightcyan3" => "152",
136 "lightskyblue1" => "153", "greenyellow" => "154", "darkolivegreen2" => "155", "palegreen1" => "156",
137 "darkseagreen2" => "157", "paleturquoise1" => "159", "red3" => "160", "deeppink3" => "162",
138 "magenta3" => "164", "darkorange3" => "166", "indianred" => "167", "hotpink3" => "168",
139 "hotpink2" => "169", "orchid" => "170", "orange3" => "172", "lightsalmon3" => "173",
140 "lightpink3" => "174", "pink3" => "175", "plum3" => "176", "violet" => "177",
141 "gold3" => "178", "lightgoldenrod3" => "179", "tan" => "180", "mistyrose3" => "181",
142 "thistle3" => "182", "plum2" => "183", "yellow3" => "184", "khaki3" => "185",
143 "lightyellow3" => "187", "grey84" => "188", "lightsteelblue1" => "189", "yellow2" => "190",
144 "darkolivegreen1" => "192", "darkseagreen1" => "193", "honeydew2" => "194", "lightcyan1" => "195",
145 "red1" => "196", "deeppink2" => "197", "deeppink1" => "199", "magenta2" => "200",
146 "magenta1" => "201", "orangered1" => "202", "indianred1" => "204", "hotpink" => "206",
147 "mediumorchid1" => "207", "darkorange" => "208", "salmon1" => "209", "lightcoral" => "210",
148 "palevioletred1" => "211", "orchid2" => "212", "orchid1" => "213", "orange1" => "214",
149 "sandybrown" => "215", "lightsalmon1" => "216", "lightpink1" => "217", "pink1" => "218",
150 "plum1" => "219", "gold1" => "220", "lightgoldenrod2" => "222", "navajowhite1" => "223",
151 "mistyrose1" => "224", "thistle1" => "225", "yellow1" => "226", "lightgoldenrod1" => "227",
152 "khaki1" => "228", "wheat1" => "229", "cornsilk1" => "230", "grey100" => "231",
153 "grey3" => "232", "grey7" => "233", "grey11" => "234", "grey15" => "235",
154 "grey19" => "236", "grey23" => "237", "grey27" => "238", "grey30" => "239",
155 "grey35" => "240", "grey39" => "241", "grey42" => "242", "grey46" => "243",
156 "grey50" => "244", "grey54" => "245", "grey58" => "246", "grey62" => "247",
157 "grey66" => "248", "grey70" => "249", "grey74" => "250", "grey78" => "251",
158 "grey82" => "252", "grey85" => "253", "grey89" => "254", "grey93" => "255",
159 _ => return None,
160 };
161 Some(code)
162}
163
164/// p10k:586-588 — `_p9k_background`: `%K{c}` or `%k` for default.
165fn bg_seq(color: &str) -> String {
166 if color.is_empty() {
167 "%k".to_string()
168 } else {
169 format!("%K{{{color}}}")
170 }
171}
172
173/// p10k:590-595 — `_p9k_foreground`: `%F{c}` or `%f` for default.
174/// (p10k's own comment: `%1F` would be faster but triggers a zsh
175/// percent-expansion bug, hence always the braced form.)
176fn fg_seq(color: &str) -> String {
177 if color.is_empty() {
178 "%f".to_string()
179 } else {
180 format!("%F{{{color}}}")
181 }
182}
183
184/// p10k:544-554 — `_p9k_color`: param probe chain, then translate.
185fn resolve_color(seg: &Segment, which: &str, default: &str) -> String {
186 translate_color(&p9k_param(&seg.name, seg.state.as_deref(), which, default))
187}
188
189/// p10k:8390-8396 — `_p9k_color1`/`_p9k_color2` from COLOR_SCHEME:
190/// light → (7, 0), anything else → (0, 7). Used for the fg==bg
191/// subseparator conflict fallback (p10k:684-689, 1057-1062).
192fn scheme_colors() -> (&'static str, &'static str) {
193 if p9k_global("COLOR_SCHEME", "dark") == "light" {
194 ("7", "0")
195 } else {
196 ("0", "7")
197 }
198}
199
200// ---------------------------------------------------------------------------
201// Icon lookup
202// ---------------------------------------------------------------------------
203
204/// Raw-default sentinel: p10k marks non-user defaults with a leading
205/// `$'\1'` so they skip `${(g::)}` processing (p10k:517-522). The same
206/// trick discriminates "user param set" from "fall to default" here.
207const UNSET: &str = "\u{1}p9k-unset\u{1}";
208
209/// p10k:511-530 — `_p9k_get_icon $1 KEY default`: probe the
210/// `POWERLEVEL9K_*` param chain for KEY (segment-scoped when a segment
211/// style is given, bare global otherwise); user values get `${(g::)}`
212/// echo-escape processing (p10k:524) and the `\b?` zero-width wrap
213/// (p10k:525); misses fall to the mode icon table, then to `default`.
214fn get_icon(seg: Option<&Segment>, key: &str, default: &str) -> String {
215 let user = match seg {
216 // p10k:520 — _p9k_param "$1" "$2" …: segment-scoped chain.
217 Some(s) => {
218 let v = p9k_param(&s.name, s.state.as_deref(), key, UNSET);
219 if v == UNSET { None } else { Some(v) }
220 }
221 // p10k:499-505 — non-`prompt_*` arm: bare POWERLEVEL9K_<KEY>.
222 None => getsparam(&format!("POWERLEVEL9K_{key}")),
223 };
224 match user {
225 Some(v) => {
226 // p10k:524 — _p9k__ret=${(g::)_p9k__ret}
227 let decoded = getkeystring(&v).0;
228 // p10k:525 — [[ $_p9k__ret != $'\b'? ]] || _p9k__ret="%{$_p9k__ret%}"
229 // — "penance for past sins": backspace + exactly one char
230 // becomes a zero-width %{...%} group.
231 let mut it = decoded.chars();
232 if it.next() == Some('\u{8}') && it.next().is_some() && it.next().is_none() {
233 return format!("%{{{decoded}%}}");
234 }
235 decoded
236 }
237 None => {
238 // p10k:520 — ${icons[$2]-$'\1'$3}: table value if the key
239 // exists, else the caller default. icons::icon() returns ""
240 // for unknown keys; every table-backed key this module uses
241 // (LEFT/RIGHT_(SUB)SEGMENT_SEPARATOR) has a non-empty
242 // glyph, so empty == miss is a safe discriminator here.
243 let table = icons::icon(key);
244 if table.is_empty() {
245 default.to_string()
246 } else {
247 table.to_string()
248 }
249 }
250 }
251}
252
253// ---------------------------------------------------------------------------
254// Small shared helpers
255// ---------------------------------------------------------------------------
256
257/// Segment-scoped param with the segment's state in the probe chain.
258fn seg_param(seg: &Segment, param: &str, default: &str) -> String {
259 p9k_param(&seg.name, seg.state.as_deref(), param, default)
260}
261
262/// p10k:752 — `${${(%):-$_p9k__c%1(l.1.0)}[-1]}`: "does this string
263/// have prompt-expanded width >= 1?". Eager approximation: skip the
264/// zero-width prompt escapes (`%F{..} %K{..} %f %k %b %B %u %U %s %S
265/// %E %{...%}`) and report whether anything visible remains. `%%` is a
266/// literal percent and counts as visible.
267fn visibly_nonempty(s: &str) -> bool {
268 let chars: Vec<char> = s.chars().collect();
269 let mut i = 0;
270 while i < chars.len() {
271 if chars[i] != '%' {
272 return true; // any plain char (including space) has width
273 }
274 match chars.get(i + 1).copied() {
275 Some('F') | Some('K') if chars.get(i + 2) == Some(&'{') => {
276 // %F{..} / %K{..}
277 let mut j = i + 3;
278 while j < chars.len() && chars[j] != '}' {
279 j += 1;
280 }
281 i = j + 1;
282 }
283 Some('f') | Some('k') | Some('b') | Some('B') | Some('u') | Some('U')
284 | Some('s') | Some('S') | Some('E') => i += 2,
285 Some('{') => {
286 // %{...%} zero-width group
287 let mut j = i + 2;
288 while j + 1 < chars.len() && !(chars[j] == '%' && chars[j + 1] == '}') {
289 j += 1;
290 }
291 i = j + 2;
292 }
293 // %% literal percent, or an escape we don't model
294 // (%1(l.…), %n, %~, …) — conservatively visible.
295 _ => return true,
296 }
297 }
298 false
299}
300
301/// p10k:720-722 / 951-953 — VISUAL_IDENTIFIER_EXPANSION, default
302/// `${P9K_VISUAL_IDENTIFIER}` (the resolved icon). Empty hides the
303/// icon; a literal glyph ('✔'/'⭐') replaces it; arbitrary `${...}`
304/// templates evaluate through expansion.rs's singsub path.
305fn resolved_icon(seg: &Segment) -> String {
306 // p10k:720-722/951-953 — arbitrary templates evaluate through the
307 // shell expander (expansion.rs singsub path); the default
308 // `${P9K_VISUAL_IDENTIFIER}` and empty-string cases short-circuit
309 // inside apply_visual_identifier_expansion without shell cost.
310 let (base, _) = is_joined_name(&seg.name);
311 crate::p10k::expansion::apply_visual_identifier_expansion(
312 base,
313 seg.state.as_deref(),
314 &seg.icon.clone().unwrap_or_default(),
315 )
316}
317
318/// p10k:724-726 / 955-957 — CONTENT_EXPANSION, default `${P9K_CONTENT}`.
319fn resolved_content(seg: &Segment) -> String {
320 // p10k:730 — `P9K_VISUAL_IDENTIFIER` is pre-set before the content
321 // template evaluates so a CONTENT_EXPANSION referencing it reads
322 // the segment's resolved icon.
323 let _ = crate::ported::params::setsparam(
324 "P9K_VISUAL_IDENTIFIER",
325 &seg.icon.clone().unwrap_or_default(),
326 );
327 let (base, _) = is_joined_name(&seg.name);
328 let expanded = crate::p10k::expansion::apply_content_expansion(
329 base,
330 seg.state.as_deref(),
331 &seg.content,
332 );
333 // p10k:749 — ${_p9k__c//$'\r'}: strip carriage returns AFTER the
334 // template expands (the template may reintroduce them).
335 expanded.replace('\r', "")
336}
337
338/// p10k:5869/5915 —
339/// `_p9k__prompt=${${_p9k__prompt//$' %{\b'/'%{%G'}//$' \b'}`:
340/// the nerd-font double-width hack; ` %{\b` becomes `%{%G` and any
341/// remaining ` \b` pair is deleted.
342fn fix_backspace_hack(s: &str) -> String {
343 s.replace(" %{\u{8}", "%{%G").replace(" \u{8}", "")
344}
345
346// ---------------------------------------------------------------------------
347// Left prompt side — port of _p9k_left_prompt_segment (p10k:614-850)
348// ---------------------------------------------------------------------------
349
350/// Fold state replacing p10k's `_p9k__bg` / `_p9k__s` / `_p9k__ss` /
351/// `_p9k__sss` runtime globals (assigned per segment at p10k:826-829,
352/// consumed by the next segment's `_p9k_t` slot, p10k:681-694).
353struct LeftState {
354 /// `_p9k__bg` — background of the previous segment; None = "NONE"
355 /// (line start, p10k:7958 `${_p9k__bg::=NONE}`).
356 bg: Option<String>,
357 /// `_p9k__s` — `%F{prev_bg}<LEFT_SEGMENT_SEPARATOR>` (p10k:827).
358 sep: String,
359 /// `_p9k__ss` — previous segment's LEFT_SUBSEGMENT_SEPARATOR.
360 subsep: String,
361 /// `_p9k__sss` — end-of-line closer `%F{bg}<end symbol>`
362 /// (p10k:827), consumed by the line suffix (p10k:7959).
363 sss: String,
364}
365
366/// Render one left segment, appending to `out`. Mirrors the emission
367/// order of the template built at p10k:706-831. `joined` is the
368/// resolved case-2 predicate (p10k:696/708-710 — the previously
369/// RENDERED segment lies inside this segment's join group). Returns
370/// whether the segment rendered (drove `_p9k__i`, p10k:828).
371fn render_left_segment(seg: &Segment, joined: bool, st: &mut LeftState, out: &mut String) -> bool {
372 // p10k:5895 — `${..%_joined}`: param/icon lookups use the BASE name.
373 let stripped;
374 let seg = match is_joined_name(&seg.name) {
375 (base, true) => {
376 stripped = Segment { name: base.to_string(), ..seg.clone() };
377 &stripped
378 }
379 _ => seg,
380 };
381 let content = resolved_content(seg);
382 let icon = resolved_icon(seg);
383 let has_content = visibly_nonempty(&content);
384 let has_icon = !icon.is_empty();
385 // p10k:750-759 — `${${_p9k__e:#00}:+…}`: a segment whose content
386 // AND icon are both empty renders nothing (no separators, no state
387 // update). This is p10k's guarantee that an empty segment never
388 // paints background padding blocks.
389 if !has_content && !has_icon {
390 return false;
391 }
392
393 // p10k:616-624 — resolve bg/fg through the param chain with the
394 // segment's own defaults.
395 let bg_color = resolve_color(seg, "BACKGROUND", &seg.bg);
396 let fg_color = resolve_color(seg, "FOREGROUND", &seg.fg);
397 let bg = bg_seq(&bg_color);
398 let fg = fg_seq(&fg_color);
399 // p10k:626 — style=%b$bg$fg
400 let style = format!("%b{bg}{fg}");
401
402 // p10k:629-636 — this segment's separators (published for the NEXT
403 // segment via _p9k__s/_p9k__ss, p10k:827).
404 let sep = get_icon(Some(seg), "LEFT_SEGMENT_SEPARATOR", "");
405 let subsep = get_icon(Some(seg), "LEFT_SUBSEGMENT_SEPARATOR", "");
406
407 // p10k:653-663 — inter/intra-segment whitespace.
408 let space = get_icon(Some(seg), "WHITESPACE_BETWEEN_LEFT_SEGMENTS", " ");
409 let mut left_space = get_icon(Some(seg), "LEFT_LEFT_WHITESPACE", &space);
410 if left_space.contains('%') {
411 left_space.push_str(&style); // p10k:658
412 }
413 let mut right_space = get_icon(Some(seg), "LEFT_RIGHT_WHITESPACE", &space);
414 if right_space.contains('%') {
415 right_space.push_str(&style); // p10k:663
416 }
417
418 // Segment separator logic (p10k:669-694):
419 // if [[ $_p9k__bg == NONE ]]; then 1
420 // elif (( joined )); then 2
421 // elif same-bg; then 3
422 // else 4
423 match &st.bg {
424 None => {
425 // p10k:645-647 + 682 — first segment: optional start
426 // symbol drawn %b%k%F{bg}, then style + leading space.
427 // Empty bg_color → %f (fg_seq), never the `%F{}` form
428 // (zshrs's prompt expander paints `%F{}` as palette 0).
429 let start = get_icon(Some(seg), "LEFT_PROMPT_FIRST_SEGMENT_START_SYMBOL", "");
430 if !start.is_empty() {
431 out.push_str(&format!("%b%k{}{start}", fg_seq(&bg_color)));
432 }
433 out.push_str(&style);
434 out.push_str(&left_space);
435 }
436 Some(_) if joined => {
437 // Case 2 (p10k:683): joined segment — style only, no
438 // separator, no leading whitespace.
439 out.push_str(&style);
440 }
441 Some(prev_bg) => {
442 // p10k:675 — $bg_color == (${_p9k__bg}|${_p9k__bg:-0})
443 let same_bg = bg_color == *prev_bg || (prev_bg.is_empty() && bg_color == "0");
444 if same_bg {
445 // Case 3 (p10k:684-693): thin subseparator on the shared
446 // background; if fg would vanish into bg, use the scheme
447 // contrast color for the subseparator only.
448 if !fg_color.is_empty() && fg_color == bg_color {
449 let (c1, c2) = scheme_colors();
450 let alt = if fg_color == c1 { c2 } else { c1 };
451 // p10k:690 — %b$bg%F{alt}$ss$style$left_space
452 out.push_str(&format!("%b{bg}{}", fg_seq(alt)));
453 } else {
454 // p10k:692 — %b$bg$ss$style$left_space (subsep keeps
455 // the previous segment's foreground).
456 out.push_str(&format!("%b{bg}"));
457 }
458 out.push_str(&st.subsep);
459 out.push_str(&style);
460 out.push_str(&left_space);
461 } else {
462 // Case 4 (p10k:694): full powerline separator, drawn
463 // fg=prev_bg on the new background.
464 out.push_str(&format!("%b{bg}"));
465 out.push_str(&st.sep);
466 out.push_str(&style);
467 out.push_str(&left_space);
468 }
469 }
470 }
471
472 // p10k:770-772 / 810-812 — icon styling: VISUAL_IDENTIFIER_COLOR
473 // defaults to the segment fg; icon is drawn %b$bg%F{vi}.
474 let vi_color = resolve_color(seg, "VISUAL_IDENTIFIER_COLOR", &fg_color);
475 let icon_style = format!("%b{bg}{}", fg_seq(&vi_color));
476 // p10k:781/803 — LEFT_MIDDLE_WHITESPACE between icon and content,
477 // emitted only when BOTH render non-empty (the `e == 11` gate,
478 // p10k:785/807).
479 let middle = get_icon(Some(seg), "LEFT_MIDDLE_WHITESPACE", " ");
480
481 // p10k:761-762 — ICON_BEFORE_CONTENT: anything but the literal
482 // "false" keeps the left-side default of icon first.
483 if seg_param(seg, "ICON_BEFORE_CONTENT", "") != "false" {
484 // Icon-first branch (p10k:763-791).
485 let prefix = getkeystring(&seg_param(seg, "PREFIX", "")).0; // p10k:763-765 ${(g::)}
486 out.push_str(&prefix);
487 let need_style = prefix.contains('%'); // p10k:767
488 if has_icon {
489 if icon_style != style {
490 // p10k:774-775 — recolor icon, then restore style.
491 out.push_str(&icon_style);
492 out.push_str(&icon);
493 out.push_str(&style);
494 } else {
495 if need_style {
496 out.push_str(&style); // p10k:777
497 }
498 out.push_str(&icon); // p10k:778
499 }
500 if !middle.is_empty() && has_content {
501 // p10k:781-786. NOTE: p10k:784 `[[ _p9k__ret == *%* ]]`
502 // is missing the `$` — the style re-append after a
503 // %-containing middle whitespace never fires in the
504 // original; bug preserved for output parity.
505 out.push_str(&middle);
506 }
507 } else if need_style {
508 out.push_str(&style); // p10k:787-788
509 }
510 out.push_str(&content); // p10k:791
511 out.push_str(&style);
512 } else {
513 // Icon-last branch (p10k:792-817).
514 let prefix = getkeystring(&seg_param(seg, "PREFIX", "")).0; // p10k:793-796
515 out.push_str(&prefix);
516 if prefix.contains('%') {
517 out.push_str(&style); // p10k:797
518 }
519 out.push_str(&content); // p10k:799
520 out.push_str(&style);
521 if has_icon {
522 let mut need_style = false; // p10k:802
523 if !middle.is_empty() {
524 if has_content {
525 out.push_str(&middle); // p10k:807 (e == 11 gate)
526 }
527 need_style = middle.contains('%'); // p10k:806
528 }
529 if icon_style != style || need_style {
530 out.push_str(&icon_style); // p10k:814
531 }
532 out.push_str(&icon); // p10k:815 — NOTE: no style restore
533 // after a trailing icon (faithful).
534 }
535 }
536
537 // p10k:819-824 — SUFFIX then trailing whitespace.
538 let suffix = getkeystring(&seg_param(seg, "SUFFIX", "")).0;
539 out.push_str(&suffix);
540 if suffix.contains('%') && !right_space.is_empty() {
541 out.push_str(&style); // p10k:823
542 }
543 out.push_str(&right_space);
544
545 // p10k:826-829 — publish separator state for the next segment and
546 // the end-of-line closer. Empty bg_color publishes %f, not `%F{}`
547 // (p10k:827 interpolates `$bg_color` raw, but only ever with the
548 // resolved non-empty palette value on the themes that set one; the
549 // `%F{}` form must never reach the expander here).
550 // p10k:649 — LEFT_PROMPT_LAST_SEGMENT_END_SYMBOL defaults to the
551 // segment separator.
552 let end_sep = get_icon(Some(seg), "LEFT_PROMPT_LAST_SEGMENT_END_SYMBOL", &sep);
553 st.sep = format!("{}{sep}", fg_seq(&bg_color));
554 st.subsep = subsep;
555 st.sss = format!("{}{end_sep}", fg_seq(&bg_color));
556 st.bg = Some(bg_color);
557 true
558}
559
560/// Render one full LEFT prompt line (segments + closing separator).
561/// Line prefix state per p10k:7958 (`bg=NONE`, default `sss`); line
562/// suffix per p10k:7959 (`%b%k$_p9k__sss%b%k%f`).
563fn render_left_line(segs: &[Segment]) -> String {
564 // p10k:7955-7958 — default end-of-line closer for a line that
565 // renders no segments: `%f` + the empty_line-scoped end symbol
566 // (which itself defaults to LEFT_SEGMENT_SEPARATOR).
567 let default_sep = get_icon(None, "LEFT_SEGMENT_SEPARATOR", "");
568 let empty_line = Segment { name: "empty_line".to_string(), ..Default::default() };
569 let default_end =
570 get_icon(Some(&empty_line), "LEFT_PROMPT_LAST_SEGMENT_END_SYMBOL", &default_sep);
571 let mut st = LeftState {
572 bg: None,
573 sep: String::new(),
574 subsep: String::new(),
575 sss: format!("%f{default_end}"),
576 };
577 let mut body = String::new();
578 // p10k:8414-8422 — `_p9k_left_join`: each segment's join-group
579 // anchor is itself when not `_joined`, else the previous segment's
580 // anchor. p10k:696/710 — case 2 fires when `_p9k__i` (index of the
581 // last segment that actually RENDERED, p10k:828) is >= the anchor.
582 let mut group_start = 0usize;
583 let mut last_rendered: Option<usize> = None;
584 for (i, seg) in segs.iter().enumerate() {
585 if !is_joined_name(&seg.name).1 {
586 group_start = i;
587 }
588 let joined = last_rendered.is_some_and(|li| li >= group_start);
589 if render_left_segment(seg, joined, &mut st, &mut body) {
590 last_rendered = Some(i);
591 }
592 }
593 let body = fix_backspace_hack(&body); // p10k:5915
594 // p10k:7959 — '%b%k$_p9k__sss%b%k%f'
595 format!("{body}%b%k{}%b%k%f", st.sss)
596}
597
598// ---------------------------------------------------------------------------
599// Right prompt side — port of _p9k_right_prompt_segment (p10k:853-1099)
600// ---------------------------------------------------------------------------
601
602/// Right-side fold state. The right prompt DEFERS each segment's
603/// trailing whitespace + style into `_p9k__w` (p10k:1055-1067), which
604/// the NEXT segment's transition consumes (the `$w` prefix of `_p9k_t`
605/// cases 2-4, p10k:923-925); the LAST segment's `_p9k__sss`
606/// (p10k:1069-1075) closes the line.
607struct RightState {
608 bg: Option<String>,
609 /// `_p9k__w` — previous segment's deferred
610 /// `<style><right_space>%b%K{prev_bg}%F{prev_fg}`.
611 w: String,
612 /// `_p9k__sss` — line closer from the last rendered segment.
613 sss: String,
614}
615
616/// Render one right segment, appending to `out` (p10k:853-1099).
617/// `joined` is the resolved case-2 predicate (p10k:927/940-941);
618/// returns whether the segment rendered.
619fn render_right_segment(
620 seg: &Segment,
621 joined: bool,
622 st: &mut RightState,
623 out: &mut String,
624) -> bool {
625 // p10k:5849 — `${..%_joined}`: param/icon lookups use the BASE name.
626 let stripped;
627 let seg = match is_joined_name(&seg.name) {
628 (base, true) => {
629 stripped = Segment { name: base.to_string(), ..seg.clone() };
630 &stripped
631 }
632 _ => seg,
633 };
634 let content = resolved_content(seg);
635 let icon = resolved_icon(seg);
636 let has_content = visibly_nonempty(&content);
637 let has_icon = !icon.is_empty();
638 // p10k:980-990 — the e == 00 gate, as on the left: an empty segment
639 // renders nothing, never background padding.
640 if !has_content && !has_icon {
641 return false;
642 }
643
644 // p10k:855-867 — colors and style.
645 let bg_color = resolve_color(seg, "BACKGROUND", &seg.bg);
646 let fg_color = resolve_color(seg, "FOREGROUND", &seg.fg);
647 let bg = bg_seq(&bg_color);
648 let fg = fg_seq(&fg_color);
649 let style = format!("%b{bg}{fg}");
650
651 // p10k:869-876 — separators; the right subseparator gets the style
652 // appended when it contains prompt escapes.
653 let sep = get_icon(Some(seg), "RIGHT_SEGMENT_SEPARATOR", "");
654 let mut subsep = get_icon(Some(seg), "RIGHT_SUBSEGMENT_SEPARATOR", "");
655 if subsep.contains('%') {
656 subsep.push_str(&style); // p10k:876
657 }
658
659 // p10k:893-903 — whitespace.
660 let space = get_icon(Some(seg), "WHITESPACE_BETWEEN_RIGHT_SEGMENTS", " ");
661 let mut left_space = get_icon(Some(seg), "RIGHT_LEFT_WHITESPACE", &space);
662 if left_space.contains('%') {
663 left_space.push_str(&style); // p10k:898
664 }
665 let mut right_space = get_icon(Some(seg), "RIGHT_RIGHT_WHITESPACE", &space);
666 if right_space.contains('%') {
667 right_space.push_str(&style); // p10k:903
668 }
669
670 // Segment separator logic (p10k:909-925), mirrored for the right:
671 // 1 line start / 2 joined / 3 same bg / 4 different bg.
672 match &st.bg {
673 None => {
674 // p10k:885-887 + 922 — start symbol defaults to the
675 // segment separator, drawn %b%k%F{bg}. Empty bg_color →
676 // %f (fg_seq), never the `%F{}` form.
677 let start = get_icon(Some(seg), "RIGHT_PROMPT_FIRST_SEGMENT_START_SYMBOL", &sep);
678 if !start.is_empty() {
679 out.push_str(&format!("%b%k{}{start}", fg_seq(&bg_color)));
680 }
681 out.push_str(&style);
682 out.push_str(&left_space);
683 }
684 Some(_) if joined => {
685 // Case 2 (p10k:923) — $w$style: deferred whitespace, then
686 // style only; no separator, no leading whitespace.
687 out.push_str(&st.w);
688 out.push_str(&style);
689 }
690 Some(prev_bg) => {
691 // p10k:915 — $_p9k__bg == (${bg_color}|${bg_color:-0})
692 let same_bg = *prev_bg == bg_color || (bg_color.is_empty() && prev_bg == "0");
693 out.push_str(&st.w); // deferred prev whitespace+style ($w)
694 if same_bg {
695 // p10k:924 — $w$style$subsep$left_space
696 out.push_str(&style);
697 out.push_str(&subsep);
698 out.push_str(&left_space);
699 } else {
700 // p10k:925 — $w%F{$bg_color}$sep$style$left_space:
701 // separator drawn fg=NEW bg on the previous background
702 // ($w restored it). Empty bg_color → %f, not `%F{}`.
703 out.push_str(&format!("{}{sep}", fg_seq(&bg_color)));
704 out.push_str(&style);
705 out.push_str(&left_space);
706 }
707 }
708 }
709
710 let vi_color = resolve_color(seg, "VISUAL_IDENTIFIER_COLOR", &fg_color);
711 let icon_style = format!("%b{bg}{}", fg_seq(&vi_color));
712 let middle = get_icon(Some(seg), "RIGHT_MIDDLE_WHITESPACE", " ");
713
714 // p10k:992-993 — right-side default is content first (icon after)
715 // unless ICON_BEFORE_CONTENT is the literal "true".
716 if seg_param(seg, "ICON_BEFORE_CONTENT", "") != "true" {
717 // Content-first branch (p10k:994-1017).
718 let prefix = getkeystring(&seg_param(seg, "PREFIX", "")).0; // p10k:994-997
719 out.push_str(&prefix);
720 if prefix.contains('%') {
721 out.push_str(&style); // p10k:998
722 }
723 out.push_str(&content); // p10k:1000
724 out.push_str(&style);
725 if has_icon {
726 let mut need_style = false; // p10k:1003
727 if !middle.is_empty() {
728 if has_content {
729 out.push_str(&middle); // p10k:1008 (e == 11 gate)
730 }
731 need_style = middle.contains('%'); // p10k:1007
732 }
733 if icon_style != style || need_style {
734 out.push_str(&icon_style); // p10k:1015
735 }
736 out.push_str(&icon); // p10k:1016 — no style restore
737 }
738 } else {
739 // Icon-first branch (p10k:1019-1047).
740 let prefix = getkeystring(&seg_param(seg, "PREFIX", "")).0;
741 out.push_str(&prefix);
742 let need_style = prefix.contains('%'); // p10k:1023
743 if has_icon {
744 if icon_style != style {
745 out.push_str(&icon_style); // p10k:1030-1031
746 out.push_str(&icon);
747 out.push_str(&style);
748 } else {
749 if need_style {
750 out.push_str(&style); // p10k:1033
751 }
752 out.push_str(&icon); // p10k:1034
753 }
754 if !middle.is_empty() && has_content {
755 // p10k:1037-1042 (same missing-`$` bug at p10k:1040 —
756 // no style append after the middle whitespace).
757 out.push_str(&middle);
758 }
759 } else if need_style {
760 out.push_str(&style); // p10k:1043-1044
761 }
762 out.push_str(&content); // p10k:1047
763 out.push_str(&style);
764 }
765
766 // p10k:1050-1053 — SUFFIX (no style/space handling on the right).
767 let suffix = getkeystring(&seg_param(seg, "SUFFIX", "")).0;
768 out.push_str(&suffix);
769
770 // p10k:1055-1067 — the deferred `_p9k__w`: trailing whitespace +
771 // restore of THIS segment's %b, bg and fg (with the fg==bg
772 // conflict fallback of p10k:1057-1062).
773 let wfg = if !fg_color.is_empty() && fg_color == bg_color {
774 let (c1, c2) = scheme_colors();
775 fg_seq(if fg_color == c1 { c2 } else { c1 })
776 } else {
777 fg.clone() // p10k:1064
778 };
779 let mut w = String::new();
780 if !right_space.is_empty() {
781 w.push_str(&style); // p10k:1067 — ${right_space_:+$style_}
782 }
783 w.push_str(&right_space);
784 w.push_str(&format!("%b{bg}{wfg}"));
785 st.w = w;
786
787 // p10k:1069-1075 — the line closer `_p9k__sss`. Empty bg_color →
788 // %f, not `%F{}`.
789 let end_sep = get_icon(Some(seg), "RIGHT_PROMPT_LAST_SEGMENT_END_SYMBOL", "");
790 let mut sss = String::new();
791 sss.push_str(&style); // p10k:1070
792 sss.push_str(&right_space);
793 if right_space.contains('%') {
794 sss.push_str(&style); // p10k:1071
795 }
796 if !end_sep.is_empty() {
797 sss.push_str(&format!("%k{}{end_sep}{style}", fg_seq(&bg_color))); // p10k:1072-1074
798 }
799 st.sss = sss;
800
801 st.bg = Some(bg_color); // p10k:1077
802 true
803}
804
805/// Render one full RIGHT prompt line; "" when nothing rendered
806/// (p10k:5871 — `right=` stays empty unless the side produced output).
807fn render_right_line(segs: &[Segment]) -> String {
808 let mut st = RightState { bg: None, w: String::new(), sss: String::new() };
809 let mut body = String::new();
810 // p10k:8424-8433 — `_p9k_right_join`: same anchor fold as the left.
811 let mut group_start = 0usize;
812 let mut last_rendered: Option<usize> = None;
813 for (i, seg) in segs.iter().enumerate() {
814 if !is_joined_name(&seg.name).1 {
815 group_start = i;
816 }
817 let joined = last_rendered.is_some_and(|li| li >= group_start);
818 if render_right_segment(seg, joined, &mut st, &mut body) {
819 last_rendered = Some(i);
820 }
821 }
822 if body.is_empty() {
823 // TODO(phase-1): _p9k_line_never_empty_right (p10k:7962) —
824 // EMPTY_LINE_RIGHT_PROMPT_FIRST_SEGMENT_START_SYMBOL forcing a
825 // non-empty right line is not wired.
826 return String::new();
827 }
828 let body = fix_backspace_hack(&body); // p10k:5869
829 // p10k:7964 — '$_p9k__sss%b%k%f'
830 format!("{body}{}%b%k%f", st.sss)
831}
832
833// ---------------------------------------------------------------------------
834// Gap alignment — port of _p9k_gap_pre (p10k:8087-8095) +
835// _p9k_build_gap_post (p10k:7879-7922)
836// ---------------------------------------------------------------------------
837
838/// Strip raw ANSI escape sequences (CSI `ESC[...X`, OSC `ESC]...BEL/ST`,
839/// other two-byte `ESC x`) from an already prompt-EXPANDED string, so
840/// only printable text remains for width measurement.
841fn strip_ansi(s: &str) -> String {
842 let mut out = String::with_capacity(s.len());
843 let mut it = s.chars().peekable();
844 while let Some(c) = it.next() {
845 if c != '\u{1b}' {
846 out.push(c);
847 continue;
848 }
849 match it.peek().copied() {
850 Some('[') => {
851 // CSI: parameters/intermediates until a final byte @-~.
852 it.next();
853 for c in it.by_ref() {
854 if ('\u{40}'..='\u{7e}').contains(&c) {
855 break;
856 }
857 }
858 }
859 Some(']') => {
860 // OSC: until BEL or ST (ESC \).
861 it.next();
862 let mut prev_esc = false;
863 for c in it.by_ref() {
864 if c == '\u{7}' || (prev_esc && c == '\\') {
865 break;
866 }
867 prev_esc = c == '\u{1b}';
868 }
869 }
870 Some(_) => {
871 it.next(); // two-byte escape (ESC c, ESC 7, …)
872 }
873 None => {}
874 }
875 }
876 out
877}
878
879/// Visible display width of a zsh PROMPT-ESCAPE string: expand through
880/// `promptexpand` (src/ported/prompt.rs:407 — `ns=0` chucks the
881/// \x01/\x02 width-ignore markers, matching the `${(%):-...}` form
882/// p10k's own width probe uses at p10k:8090), strip the raw ANSI the
883/// expansion emitted, then sum `WCWIDTH` (src/ported/zsh_h.rs:4758)
884/// over the remainder, counting non-printing (< 0) as 0.
885fn prompt_visible_width(s: &str) -> usize {
886 let (expanded, _, _) = crate::ported::prompt::promptexpand(s, 0, None);
887 strip_ansi(&expanded)
888 .chars()
889 .map(|c| WCWIDTH(c).max(0) as usize)
890 .sum()
891}
892
893/// p10k:8126 — `_p9k__ind::=${${ZLE_RPROMPT_INDENT:-1}/#-*/0}`:
894/// ZLE_RPROMPT_INDENT, unset/empty → 1, leading `-` (negative) → 0.
895/// (The `_p9k_emulate_zero_rprompt_indent` branch at p10k:8111-8124 is
896/// a workaround for zsh < 5.7.2 only and is not ported.)
897fn rprompt_indent() -> usize {
898 match getsparam("ZLE_RPROMPT_INDENT") {
899 Some(v) if !v.is_empty() => {
900 if v.starts_with('-') {
901 0
902 } else {
903 // Non-numeric behaves like zsh arith on a bare word: 0.
904 v.parse().unwrap_or(0)
905 }
906 }
907 _ => 1,
908 }
909}
910
911/// Gap-align one non-last prompt line: left content, styled gap fill,
912/// right content, line terminator. Eager port of `_p9k_build_gap_post`
913/// (p10k:7878-7922) plus its consumption in `_p9k_set_prompt`
914/// (p10k:5936/5959-5960) and the gap math of `_p9k_gap_pre`
915/// (p10k:8087-8094).
916///
917/// p10k:8094 — `_p9k__m = _p9k__clm - x - _p9k__ind - 1`, where x is
918/// the combined visible width of `$_p9k__lprompt$_p9k__rprompt` and
919/// `_p9k__ind` is ZLE_RPROMPT_INDENT. p10k:7906-7917 — the emitted
920/// template is
921/// `$style${${${_p9k__m:#-*}:+<gap>$_p9k__rprompt<term>}:-\n}` plus a
922/// `%b%k%f` reset when the gap style is non-empty (p10k:7918):
923/// NEGATIVE m DROPS the right side of the line (the `:-\n` arm) —
924/// never an inline append. Otherwise the gap is m+1 copies of
925/// MULTILINE_{FIRST,NEWLINE}_PROMPT_GAP_CHAR (p10k:7884-7891,
926/// validated to display width 1, else ' ') styled with the
927/// `multiline_*_prompt_gap` BACKGROUND/FOREGROUND (p10k:7892-7898),
928/// and the terminator is `_p9k_t[1+!_p9k__ind]` (p10k:8054 —
929/// `_p9k_t=($'\n' $'%{\n%}' '')`: the newline is zero-width-marked
930/// when ind==0 because the right side then ends in the terminal's
931/// last column). Unported: MULTILINE_*_PROMPT_GAP_EXPANSION
932/// (p10k:7900-7910, default passthrough only), the `p10k display
933/// */gap` toggles (`_p9k__g`/`_p9k__<i>g`), and the
934/// `_p9k_prompt_overflow_bug` `%{%G\n%}` terminator variant
935/// (p10k:8055). `measure` is injectable so tests need no live shell
936/// state.
937fn align_line(
938 left: &str,
939 right: &str,
940 line: usize,
941 columns: usize,
942 measure: &dyn Fn(&str) -> usize,
943) -> String {
944 if right.is_empty() {
945 // p10k:5959-5960 — `[[ -n $right ]] || _p9k__prompt+=$'\n'`:
946 // no right side, no gap machinery.
947 return format!("{left}\n");
948 }
949 // p10k:7879-7883 — line 1 uses the FIRST params, later lines NEWLINE.
950 let kind = if line == 0 { "FIRST" } else { "NEWLINE" };
951 // p10k:7884-7886 — gap char; `${_p9k__ret:- }`.
952 let mut ch = get_icon(None, &format!("MULTILINE_{kind}_PROMPT_GAP_CHAR"), "");
953 if ch.is_empty() {
954 ch = " ".to_string();
955 }
956 // p10k:7887-7891 — must be exactly one char of display width 1;
957 // p10k warns to stderr, zshrs logs (no terminal chatter).
958 if ch.chars().count() != 1 || ch.chars().next().map(WCWIDTH) != Some(1) {
959 tracing::warn!(target: "p10k", gap_char = %ch, kind,
960 "MULTILINE_*_PROMPT_GAP_CHAR is not one character long; using ' '");
961 ch = " ".to_string();
962 }
963 // p10k:7892-7898 — gap style; an empty color contributes nothing
964 // (`[[ -n $_p9k__ret ]] && …`, no %k/%f reset in the style).
965 let scope = format!("multiline_{}_prompt_gap", kind.to_ascii_lowercase());
966 let mut style = String::new();
967 let bgc = translate_color(&p9k_param(&scope, None, "BACKGROUND", ""));
968 if !bgc.is_empty() {
969 style.push_str(&bg_seq(&bgc));
970 }
971 let fgc = translate_color(&p9k_param(&scope, None, "FOREGROUND", ""));
972 if !fgc.is_empty() {
973 style.push_str(&fg_seq(&fgc));
974 }
975 let reset = if style.is_empty() { "" } else { "%b%k%f" }; // p10k:7918
976 let ind = rprompt_indent();
977 // p10k:8094 — _p9k__m = _p9k__clm - x - _p9k__ind - 1.
978 let m = columns as i64 - (measure(left) + measure(right)) as i64 - ind as i64 - 1;
979 if m < 0 {
980 // p10k:7906 — `${_p9k__m:#-*}` misses → the `:-\n` arm: the
981 // right side of the line is DROPPED, never wrapped inline.
982 return format!("{left}{style}\n{reset}");
983 }
984 // p10k:8054/8060 — terminator `_p9k_t[1+!_p9k__ind]`.
985 let term = if ind == 0 { "%{\n%}" } else { "\n" };
986 format!(
987 "{left}{style}{}{right}{term}{reset}",
988 ch.repeat(m as usize + 1) // p10k:7907 — ${(pl.$((_p9k__m+1))..<char>.)}
989 )
990}
991
992// ---------------------------------------------------------------------------
993// Multiline assembly — port of _p9k_set_prompt (p10k:5815-5964) +
994// _p9k_init_lines frame handling (p10k:7986-8049)
995// ---------------------------------------------------------------------------
996
997/// Multiline frame connector for a left line (p10k:7992-8010,
998/// 8029-8038): MULTILINE_{FIRST,NEWLINE,LAST}_PROMPT_PREFIX, applied
999/// only when the user param is SET or PROMPT_ON_NEWLINE is on.
1000///
1001/// NOTE p10k:7995/8032 — `[[ _p9k__ret == *%* ]]` is missing the `$`
1002/// (it compares the literal string `_p9k__ret`), so the `%b%k%f`
1003/// append after a %-containing frame never fires in the original;
1004/// bug preserved for output parity.
1005fn left_frame_prefix(line: usize, num_lines: usize) -> String {
1006 if num_lines < 2 {
1007 return String::new();
1008 }
1009 let key = if line == 0 {
1010 "MULTILINE_FIRST_PROMPT_PREFIX" // p10k:7992-7999
1011 } else if line + 1 == num_lines {
1012 "MULTILINE_LAST_PROMPT_PREFIX" // p10k:8002-8009
1013 } else {
1014 "MULTILINE_NEWLINE_PROMPT_PREFIX" // p10k:8029-8037
1015 };
1016 let prompt_on_newline = p9k_global("PROMPT_ON_NEWLINE", "") == "true";
1017 // p10k:7992/8002/8029 — `$+_POWERLEVEL9K_<key> == 1`: the param
1018 // being SET (even to empty) enables the frame.
1019 if getsparam(&format!("POWERLEVEL9K_{key}")).is_none() && !prompt_on_newline {
1020 return String::new();
1021 }
1022 get_icon(None, key, "")
1023}
1024
1025/// Multiline right frame connector (p10k:8012-8026, 8040-8047):
1026/// MULTILINE_{FIRST,NEWLINE,LAST}_PROMPT_SUFFIX, appended after the
1027/// right side of the line. Unlike the prefixes these are NOT gated on
1028/// the param being set (p10k:8012/8019/8040 probe unconditionally).
1029fn right_frame_suffix(line: usize, num_lines: usize) -> String {
1030 if num_lines < 2 {
1031 return String::new();
1032 }
1033 let key = if line == 0 {
1034 "MULTILINE_FIRST_PROMPT_SUFFIX"
1035 } else if line + 1 == num_lines {
1036 "MULTILINE_LAST_PROMPT_SUFFIX"
1037 } else {
1038 "MULTILINE_NEWLINE_PROMPT_SUFFIX"
1039 };
1040 get_icon(None, key, "")
1041}
1042
1043/// Assemble PROMPT and RPROMPT from per-line segment lists.
1044///
1045/// p10k:5815-5964 — `_p9k_set_prompt`, rendered eagerly. Lines are
1046/// pre-split by the caller ("newline" elements); this fn aligns the
1047/// two sides (p10k:7932-7944), renders each line, and returns zsh
1048/// prompt-escape strings for the prompt expander (never raw ANSI).
1049pub fn render_prompt(
1050 left_lines: &[Vec<Segment>],
1051 right_lines: &[Vec<Segment>],
1052) -> (String, String) {
1053 static EMPTY: Vec<Segment> = Vec::new();
1054
1055 // p10k:5828 — _POWERLEVEL9K_DISABLE_RPROMPT kills the right side.
1056 let disable_rprompt = p9k_global("DISABLE_RPROMPT", "") == "true";
1057
1058 // p10k:7932-7944 — align line counts: extra right lines push empty
1059 // left lines on TOP; extra left lines get empty right lines
1060 // appended at the BOTTOM (or on top under RPROMPT_ON_NEWLINE).
1061 let num_lines = left_lines.len().max(right_lines.len()).max(1);
1062 let rprompt_on_newline = p9k_global("RPROMPT_ON_NEWLINE", "") == "true";
1063 let line_pair = |i: usize| -> (&Vec<Segment>, &Vec<Segment>) {
1064 let left = if num_lines > left_lines.len() {
1065 // p10k:7935 — left padded with LEADING newline lines.
1066 let pad = num_lines - left_lines.len();
1067 if i < pad { &EMPTY } else { &left_lines[i - pad] }
1068 } else {
1069 left_lines.get(i).unwrap_or(&EMPTY)
1070 };
1071 let right = if num_lines > right_lines.len() && rprompt_on_newline {
1072 // p10k:7939 — right padded with LEADING newline lines.
1073 let pad = num_lines - right_lines.len();
1074 if i < pad { &EMPTY } else { &right_lines[i - pad] }
1075 } else {
1076 // p10k:7941 — right padded with TRAILING newline lines.
1077 right_lines.get(i).unwrap_or(&EMPTY)
1078 };
1079 (left, right)
1080 };
1081
1082 let mut prompt = String::new();
1083 // p10k:8109 — _p9k_prompt_prefix_left ends with '%b%k%f'. (The
1084 // COLUMNS juggling at p10k:8097-8100 and the ZLE_RPROMPT_INDENT
1085 // workaround at p10k:8111-8126 are template-evaluation machinery
1086 // with no eager equivalent.)
1087 prompt.push_str("%b%k%f");
1088
1089 // p10k:8137-8146 + 6134 — empty line(s) before the prompt when
1090 // PROMPT_ADD_NEWLINE is on; count from PROMPT_ADD_NEWLINE_COUNT
1091 // (default 1, declared at p10k:7237).
1092 if p9k_global("PROMPT_ADD_NEWLINE", "") == "true" {
1093 let count = p9k_global("PROMPT_ADD_NEWLINE_COUNT", "1")
1094 .parse::<i64>()
1095 .unwrap_or(1)
1096 .max(0);
1097 for _ in 0..count {
1098 prompt.push('\n');
1099 }
1100 }
1101
1102 // p10k:8097 — `_p9k__clm::=$COLUMNS` snapshot feeding the gap math
1103 // (p10k:8094). Unset/zero COLUMNS falls back to 80.
1104 let columns = match getiparam("COLUMNS") {
1105 c if c > 0 => c as usize,
1106 _ => 80,
1107 };
1108
1109 // p10k:7972-7982 — LEFT_SEGMENT_END_SEPARATOR (icon-table default
1110 // ' ', icons.zsh:428): appended AFTER the last line's suffix with a
1111 // %b%k%f reset — the single space real p10k leaves between the
1112 // prompt char and the cursor. Under PROMPT_ON_NEWLINE it rides the
1113 // second-to-last line instead (p10k:7977-7980).
1114 let end_sep_line = {
1115 let v = get_icon(None, "LEFT_SEGMENT_END_SEPARATOR", "");
1116 if v.is_empty() {
1117 usize::MAX // p10k:7973 — empty icon appends nothing
1118 } else if p9k_global("PROMPT_ON_NEWLINE", "") == "true" && num_lines >= 2 {
1119 num_lines - 2
1120 } else {
1121 num_lines - 1
1122 }
1123 };
1124 let end_sep = get_icon(None, "LEFT_SEGMENT_END_SEPARATOR", "");
1125
1126 let mut rprompt = String::new();
1127 for i in 0..num_lines {
1128 let (lsegs, rsegs) = line_pair(i);
1129 let right = if disable_rprompt { String::new() } else { render_right_line(rsegs) };
1130 let frame_suffix = right_frame_suffix(i, num_lines);
1131
1132 // p10k:7998/8008/8035 — frame prefix goes BEFORE the line body.
1133 let mut line = left_frame_prefix(i, num_lines);
1134 line.push_str(&render_left_line(lsegs));
1135 if i == end_sep_line {
1136 // p10k:7974 — `_p9k__ret+=%b%k%f` after the icon.
1137 line.push_str(&end_sep);
1138 line.push_str("%b%k%f");
1139 }
1140
1141 if i + 1 == num_lines {
1142 prompt.push_str(&line);
1143 // p10k:5949-5952 — last line: right side becomes RPROMPT.
1144 // (The _p9k_prompt_prefix_right/_suffix_right COLUMNS
1145 // juggling, p10k:8098-8100, has no eager counterpart.)
1146 if !right.is_empty() || !frame_suffix.is_empty() {
1147 rprompt = format!("{right}{frame_suffix}");
1148 }
1149 } else {
1150 // p10k:5918-5947 — non-last lines with right content are
1151 // gap-aligned: left, styled gap fill, right, terminator
1152 // (p10k:8087-8095 + 7878-7922; align_line also emits the
1153 // bare-newline fallbacks of p10k:7906/5960). The right
1154 // frame connector rides with the right content, as in
1155 // p10k's `_p9k_line_suffix_right` (p10k:8015/8044).
1156 let right_full = format!("{right}{frame_suffix}");
1157 prompt.push_str(&align_line(&line, &right_full, i, columns, &prompt_visible_width));
1158 }
1159 }
1160
1161 (prompt, rprompt)
1162}
1163
1164// ---------------------------------------------------------------------------
1165// Tests
1166// ---------------------------------------------------------------------------
1167
1168#[cfg(test)]
1169mod tests {
1170 use super::*;
1171
1172 fn seg(name: &str, content: &str, fg: &str, bg: &str) -> Segment {
1173 Segment {
1174 name: name.to_string(),
1175 state: None,
1176 content: content.to_string(),
1177 icon: None,
1178 fg: fg.to_string(),
1179 bg: bg.to_string(),
1180 }
1181 }
1182
1183 /// Serialize against paramtab-touching tests (same pattern as the
1184 /// sibling config.rs test module). No params are set here — the
1185 /// icon lookups must fall through to the static nerdfont table.
1186 fn locked<F: FnOnce()>(body: F) {
1187 let _g = crate::test_util::global_state_lock();
1188 body();
1189 }
1190
1191 /// Two same-bg left segments: the boundary must use the thin
1192 /// LEFT_SUBSEGMENT_SEPARATOR (U+E0B1) and never a full
1193 /// %F{prev_bg} separator transition (p10k:684-693).
1194 #[test]
1195 fn left_same_bg_uses_subsegment_separator() {
1196 locked(|| {
1197 let a = seg("aseg", "AAA", "000", "004");
1198 let b = seg("bseg", "BBB", "001", "004");
1199 let line = render_left_line(&[a, b]);
1200
1201 // First segment opens with its style (bg 004, fg 000).
1202 assert!(line.contains("%b%K{004}%F{000}"), "line: {line}");
1203 // Same-bg boundary: %b%K{004} then U+E0B1 then B's style.
1204 assert!(
1205 line.contains("%b%K{004}\u{E0B1}%b%K{004}%F{001}"),
1206 "subsegment boundary missing: {line}"
1207 );
1208 // No full separator (U+E0B0) may appear BETWEEN the
1209 // segments — only the end-of-line closer uses it.
1210 let boundary = &line[..line.find("BBB").expect("BBB rendered")];
1211 assert!(
1212 !boundary.contains('\u{E0B0}'),
1213 "full separator leaked into same-bg boundary: {line}"
1214 );
1215 // p10k:7959 — line closes %b%k%F{004}<end sep>%b%k%f, end
1216 // symbol defaulting to LEFT_SEGMENT_SEPARATOR (p10k:649).
1217 assert!(
1218 line.ends_with("%b%k%F{004}\u{E0B0}%b%k%f"),
1219 "line closer wrong: {line}"
1220 );
1221 });
1222 }
1223
1224 /// Two different-bg left segments: the boundary is the full
1225 /// LEFT_SEGMENT_SEPARATOR drawn fg=prev_bg on the NEW background
1226 /// (p10k:694 + :827 — %b%K{new}%F{prev}<sep><new style>).
1227 #[test]
1228 fn left_diff_bg_full_separator_prev_fg_next_bg() {
1229 locked(|| {
1230 let a = seg("aseg", "AAA", "000", "004");
1231 let b = seg("bseg", "BBB", "007", "002");
1232 let line = render_left_line(&[a, b]);
1233
1234 assert!(
1235 line.contains("%b%K{002}%F{004}\u{E0B0}%b%K{002}%F{007}"),
1236 "diff-bg boundary (fg=prev_bg on next_bg) missing: {line}"
1237 );
1238 // Closer carries the LAST segment's background.
1239 assert!(
1240 line.ends_with("%b%k%F{002}\u{E0B0}%b%k%f"),
1241 "line closer wrong: {line}"
1242 );
1243 });
1244 }
1245
1246 /// Right side, different bg: separator (U+E0B2) drawn fg=NEW bg on
1247 /// the PREVIOUS background — the deferred `_p9k__w` restored the
1248 /// previous style first (p10k:925, 1067).
1249 #[test]
1250 fn right_diff_bg_separator_new_fg_on_prev_bg() {
1251 locked(|| {
1252 let a = seg("aseg", "AAA", "000", "004");
1253 let b = seg("bseg", "BBB", "007", "002");
1254 let line = render_right_line(&[a, b]);
1255
1256 // Deferred w of A: style + ' ' + %b%K{004}%F{000}, then B's
1257 // separator recolored to B's bg while still on A's bg.
1258 assert!(
1259 line.contains("%b%K{004}%F{000}%F{002}\u{E0B2}%b%K{002}%F{007}"),
1260 "right diff-bg boundary missing: {line}"
1261 );
1262 assert!(line.ends_with("%b%k%f"), "right closer wrong: {line}");
1263 });
1264 }
1265
1266 /// Right side, same bg: thin RIGHT_SUBSEGMENT_SEPARATOR (U+E0B3)
1267 /// after the deferred w, in the NEW segment's style (p10k:924).
1268 #[test]
1269 fn right_same_bg_uses_subsegment_separator() {
1270 locked(|| {
1271 let a = seg("aseg", "AAA", "000", "004");
1272 let b = seg("bseg", "BBB", "001", "004");
1273 let line = render_right_line(&[a, b]);
1274
1275 assert!(
1276 line.contains("%b%K{004}%F{000}%b%K{004}%F{001}\u{E0B3}"),
1277 "right same-bg subseparator missing: {line}"
1278 );
1279 let boundary = &line[..line.find("BBB").expect("BBB rendered")];
1280 // U+E0B2 appears once as the line-start symbol (default =
1281 // segment separator, p10k:885); it must NOT appear again
1282 // between the two same-bg segments.
1283 assert_eq!(
1284 boundary.matches('\u{E0B2}').count(),
1285 1,
1286 "full right separator leaked into same-bg boundary: {line}"
1287 );
1288 });
1289 }
1290
1291 /// Segments with empty content and no icon render NOTHING — no
1292 /// separators, no state change (the e == 00 gate, p10k:750-759).
1293 #[test]
1294 fn empty_segment_is_skipped_entirely() {
1295 locked(|| {
1296 let a = seg("aseg", "AAA", "000", "004");
1297 let hidden = seg("hseg", "", "007", "001");
1298 let b = seg("bseg", "BBB", "001", "004");
1299 let line = render_left_line(&[a, hidden, b]);
1300 // a and b are same-bg once hidden drops out: thin
1301 // subseparator boundary, and bg 001 never appears.
1302 assert!(line.contains('\u{E0B1}'), "line: {line}");
1303 assert!(!line.contains("%K{001}"), "hidden segment leaked: {line}");
1304 });
1305 }
1306
1307 /// render_prompt: single line → PROMPT gets the %b%k%f prefix and
1308 /// the left line; RPROMPT carries the right side.
1309 #[test]
1310 fn render_prompt_single_line_shape() {
1311 locked(|| {
1312 let left = vec![vec![seg("aseg", "AAA", "000", "004")]];
1313 let right = vec![vec![seg("bseg", "BBB", "007", "002")]];
1314 let (prompt, rprompt) = render_prompt(&left, &right);
1315 assert!(prompt.starts_with("%b%k%f"), "prompt: {prompt}");
1316 assert!(prompt.contains("AAA"), "prompt: {prompt}");
1317 assert!(!prompt.contains("BBB"), "right leaked into PROMPT: {prompt}");
1318 assert!(rprompt.contains("BBB"), "rprompt: {rprompt}");
1319 assert!(rprompt.ends_with("%b%k%f"), "rprompt: {rprompt}");
1320 });
1321 }
1322
1323 /// Multiline: lines joined with \n; only the LAST line's right
1324 /// side becomes RPROMPT (p10k:5949-5955).
1325 #[test]
1326 fn render_prompt_multiline_last_right_is_rprompt() {
1327 locked(|| {
1328 let left = vec![
1329 vec![seg("aseg", "AAA", "000", "004")],
1330 vec![seg("cseg", "CCC", "000", "006")],
1331 ];
1332 let right = vec![Vec::new(), vec![seg("bseg", "BBB", "007", "002")]];
1333 let (prompt, rprompt) = render_prompt(&left, &right);
1334 assert_eq!(prompt.matches('\n').count(), 1, "prompt: {prompt}");
1335 let (line1, line2) = prompt.split_once('\n').expect("two lines");
1336 assert!(line1.contains("AAA"), "line1: {line1}");
1337 assert!(line2.contains("CCC"), "line2: {line2}");
1338 assert!(rprompt.contains("BBB"), "rprompt: {rprompt}");
1339 });
1340 }
1341
1342 /// p10k:532-541 — color translation forms.
1343 #[test]
1344 fn translate_color_forms() {
1345 assert_eq!(translate_color("4"), "004");
1346 assert_eq!(translate_color("255"), "255");
1347 assert_eq!(translate_color("#FFAA00"), "#ffaa00");
1348 assert_eq!(translate_color("red"), "001");
1349 assert_eq!(translate_color("bg-red"), "001");
1350 assert_eq!(translate_color("grey50"), "244");
1351 assert_eq!(translate_color(""), "");
1352 assert_eq!(translate_color("nosuchcolor"), "");
1353 }
1354
1355 /// Zero-width prompt escapes don't count as visible content.
1356 #[test]
1357 fn visibly_nonempty_prompt_escapes() {
1358 assert!(!visibly_nonempty(""));
1359 assert!(!visibly_nonempty("%f%k%b"));
1360 assert!(!visibly_nonempty("%F{004}%K{002}"));
1361 assert!(!visibly_nonempty("%{zero width%}"));
1362 assert!(visibly_nonempty(" "));
1363 assert!(visibly_nonempty("%F{004}x"));
1364 assert!(visibly_nonempty("%%"));
1365 }
1366
1367 /// p10k:5869 — the nerd-font backspace hack rewrite.
1368 #[test]
1369 fn backspace_hack_rewrite() {
1370 assert_eq!(fix_backspace_hack(" %{\u{8}X%}"), "%{%GX%}");
1371 assert_eq!(fix_backspace_hack("a \u{8}b"), "ab");
1372 assert_eq!(fix_backspace_hack("plain"), "plain");
1373 }
1374
1375 /// p10k:649 — LEFT_PROMPT_LAST_SEGMENT_END_SYMBOL: a SEGMENT-SCOPED
1376 /// param set to EMPTY must suppress the closer glyph even when the
1377 /// bare global is set (the user config's
1378 /// `POWERLEVEL9K_PROMPT_CHAR_LEFT_PROMPT_LAST_SEGMENT_END_SYMBOL=`
1379 /// vs global `…=''` — real p10k paints nothing after `❯`).
1380 #[test]
1381 fn segment_scoped_empty_end_symbol_suppresses_closer() {
1382 locked(|| {
1383 use crate::ported::params::{setsparam, unsetparam};
1384 setsparam("POWERLEVEL9K_LEFT_PROMPT_LAST_SEGMENT_END_SYMBOL", "\u{E0BC}");
1385 setsparam("POWERLEVEL9K_PROMPT_CHAR_LEFT_PROMPT_LAST_SEGMENT_END_SYMBOL", "");
1386 assert_eq!(
1387 crate::ported::params::getsparam(
1388 "POWERLEVEL9K_PROMPT_CHAR_LEFT_PROMPT_LAST_SEGMENT_END_SYMBOL"
1389 ),
1390 Some(String::new()),
1391 "set-empty scalar must read back as Some(\"\")"
1392 );
1393 assert_eq!(
1394 p9k_param("prompt_char", None, "LEFT_PROMPT_LAST_SEGMENT_END_SYMBOL", "MISS"),
1395 "",
1396 "scoped-empty must win the probe chain"
1397 );
1398 let pc = seg("prompt_char", "\u{276F}", "076", "");
1399 let line = render_left_line(&[pc]);
1400 unsetparam("POWERLEVEL9K_LEFT_PROMPT_LAST_SEGMENT_END_SYMBOL");
1401 unsetparam("POWERLEVEL9K_PROMPT_CHAR_LEFT_PROMPT_LAST_SEGMENT_END_SYMBOL");
1402 assert!(
1403 !line.contains('\u{E0BC}'),
1404 "scoped-empty end symbol must beat the global: {:?}",
1405 line.escape_debug().to_string()
1406 );
1407 });
1408 }
1409
1410 /// Empty color specs must emit the reset escapes (%f/%k), never the
1411 /// empty-brace forms `%F{}`/`%K{}` (zshrs's expander paints those
1412 /// as palette 0; p10k never emits them).
1413 #[test]
1414 fn empty_color_spec_emits_reset_not_empty_braces() {
1415 assert_eq!(fg_seq(""), "%f");
1416 assert_eq!(bg_seq(""), "%k");
1417 locked(|| {
1418 // Left: transparent-bg segment then a colored one — the
1419 // published separator recolor and the line closer both
1420 // derive from the empty bg.
1421 let a = seg("aseg", "AAA", "004", "");
1422 let b = seg("bseg", "BBB", "007", "002");
1423 let line = render_left_line(&[a.clone(), b]);
1424 assert!(!line.contains("%F{}"), "empty %F brace leaked: {line}");
1425 assert!(!line.contains("%K{}"), "empty %K brace leaked: {line}");
1426 // Case-4 boundary: separator fg = prev (empty) bg → %f.
1427 assert!(
1428 line.contains("%b%K{002}%f\u{E0B0}"),
1429 "empty-bg separator not reset via %f: {line}"
1430 );
1431
1432 // Transparent-bg segment alone: closer sss = %f + end sep.
1433 let solo = render_left_line(&[a]);
1434 assert!(
1435 solo.ends_with("%b%k%f\u{E0B0}%b%k%f"),
1436 "empty-bg line closer wrong: {solo}"
1437 );
1438
1439 // Right: transparent bg start symbol + closer paths.
1440 let ra = seg("aseg", "AAA", "004", "");
1441 let rline = render_right_line(&[ra]);
1442 assert!(!rline.contains("%F{}"), "empty %F brace leaked: {rline}");
1443 assert!(!rline.contains("%K{}"), "empty %K brace leaked: {rline}");
1444 assert!(
1445 rline.starts_with("%b%k%f\u{E0B2}"),
1446 "empty-bg right start symbol not reset via %f: {rline}"
1447 );
1448 });
1449 }
1450
1451 /// The e == 00 gate on the RIGHT side, including an icon that is
1452 /// Some("") — no separators, no background, no state change
1453 /// (p10k:980-990).
1454 #[test]
1455 fn right_empty_segment_is_skipped_entirely() {
1456 locked(|| {
1457 let a = seg("aseg", "AAA", "000", "004");
1458 let mut hidden = seg("hseg", "", "007", "001");
1459 hidden.icon = Some(String::new());
1460 let b = seg("bseg", "BBB", "001", "004");
1461 let line = render_right_line(&[a, hidden, b]);
1462 assert!(line.contains('\u{E0B3}'), "line: {line}");
1463 assert!(!line.contains("%K{001}"), "hidden segment leaked: {line}");
1464 });
1465 }
1466
1467 /// A line of ONLY empty segments renders no segment body at all.
1468 #[test]
1469 fn all_empty_segments_render_no_body() {
1470 locked(|| {
1471 let h1 = seg("hseg", "", "007", "001");
1472 let h2 = seg("iseg", "", "003", "002");
1473 let left = render_left_line(&[h1.clone(), h2.clone()]);
1474 // Only the p10k:7959 empty-line closer remains.
1475 assert_eq!(left, "%b%k%f\u{E0B0}%b%k%f", "left: {left}");
1476 assert_eq!(render_right_line(&[h1, h2]), "", "right must stay empty");
1477 });
1478 }
1479
1480 /// p10k:5834/5849 — `_joined` suffix parsing.
1481 #[test]
1482 fn is_joined_name_suffix() {
1483 assert_eq!(is_joined_name("dir"), ("dir", false));
1484 assert_eq!(is_joined_name("vcs_joined"), ("vcs", true));
1485 assert_eq!(is_joined_name("_joined"), ("", true));
1486 assert_eq!(is_joined_name("joined"), ("joined", false));
1487 }
1488
1489 /// Joined left segment (case 2, p10k:683): style only — no full
1490 /// separator, no subseparator, no leading whitespace — even across
1491 /// DIFFERENT backgrounds.
1492 #[test]
1493 fn left_joined_segment_has_no_separator() {
1494 locked(|| {
1495 let a = seg("aseg", "AAA", "000", "004");
1496 let b = seg("bseg_joined", "BBB", "007", "002");
1497 let line = render_left_line(&[a, b]);
1498 let boundary = {
1499 let s = line.find("AAA").expect("AAA rendered") + 3;
1500 let e = line.find("BBB").expect("BBB rendered");
1501 &line[s..e]
1502 };
1503 assert!(
1504 !boundary.contains('\u{E0B0}') && !boundary.contains('\u{E0B1}'),
1505 "separator leaked into joined boundary: {line}"
1506 );
1507 // A's trailing whitespace, then B's bare style (case 2).
1508 assert!(
1509 boundary.ends_with(" %b%K{002}%F{007}"),
1510 "joined boundary must be prev right_space + style: {line}"
1511 );
1512 // Param/icon lookups used the BASE name: closer still the
1513 // stock separator glyph for bg 002.
1514 assert!(line.ends_with("%b%k%F{002}\u{E0B0}%b%k%f"), "closer: {line}");
1515 });
1516 }
1517
1518 /// p10k:696 — case 2 needs the last RENDERED segment inside the
1519 /// join group: when the group's anchor is skipped (empty), the
1520 /// joined segment falls back to the normal separator against the
1521 /// segment before the group.
1522 #[test]
1523 fn left_joined_falls_back_when_anchor_skipped() {
1524 locked(|| {
1525 let a = seg("aseg", "AAA", "000", "004");
1526 let anchor = seg("hseg", "", "007", "001"); // skipped
1527 let c = seg("cseg_joined", "CCC", "007", "002");
1528 let line = render_left_line(&[a, anchor, c]);
1529 // Full separator between AAA and CCC (case 4, diff bg).
1530 assert!(
1531 line.contains("%b%K{002}%F{004}\u{E0B0}%b%K{002}%F{007}"),
1532 "fallback separator missing: {line}"
1533 );
1534 });
1535 }
1536
1537 /// Joined right segment (case 2, p10k:923): deferred $w, then
1538 /// style only — no separator glyphs in the boundary.
1539 #[test]
1540 fn right_joined_segment_has_no_separator() {
1541 locked(|| {
1542 let a = seg("aseg", "AAA", "000", "004");
1543 let b = seg("bseg_joined", "BBB", "007", "002");
1544 let line = render_right_line(&[a, b]);
1545 let boundary = {
1546 let s = line.find("AAA").expect("AAA rendered") + 3;
1547 let e = line.find("BBB").expect("BBB rendered");
1548 &line[s..e]
1549 };
1550 assert!(
1551 !boundary.contains('\u{E0B2}') && !boundary.contains('\u{E0B3}'),
1552 "separator leaked into joined right boundary: {line}"
1553 );
1554 // $w (A's deferred space + %b bg fg restore) + B's style.
1555 assert!(
1556 boundary.contains("%b%K{004}%F{000}%b%K{002}%F{007}"),
1557 "joined right boundary must be w + style: {line}"
1558 );
1559 });
1560 }
1561
1562 /// Right-alignment padding math (p10k:8087-8094 + 7906-7917),
1563 /// injectable width fn. Default ZLE_RPROMPT_INDENT (unset → 1,
1564 /// p10k:8126) keeps the right edge one column in; overflow (m < 0)
1565 /// DROPS the right side (`:-\n` arm), never inline-appends.
1566 #[test]
1567 fn align_line_padding_math() {
1568 locked(|| {
1569 let measure = |s: &str| s.chars().count();
1570 // m = 10-5-1-1 = 3 → 4 gap chars, right ends at column 9.
1571 assert_eq!(align_line("LL", "RRR", 0, 10, &measure), "LL RRR\n");
1572 // m == 0 → exactly one pad char.
1573 assert_eq!(align_line("LLL", "RRRRR", 0, 10, &measure), "LLL RRRRR\n");
1574 // m == -1 → right dropped.
1575 assert_eq!(align_line("LLLL", "RRRRR", 0, 10, &measure), "LLLL\n");
1576 // Hard overflow → right dropped.
1577 assert_eq!(align_line("LLLLLLLL", "RRRRR", 0, 10, &measure), "LLLLLLLL\n");
1578 // Empty right → left + newline, no gap machinery
1579 // (p10k:5960).
1580 assert_eq!(align_line("LL", "", 0, 10, &measure), "LL\n");
1581 });
1582 }
1583
1584 /// Gap char + gap foreground + ZLE_RPROMPT_INDENT=0 — the shape the
1585 /// user config produces (GAP_CHAR '─', GAP_FOREGROUND 244): styled
1586 /// fill, zero-width-marked newline terminator (`_p9k_t[2]`,
1587 /// p10k:8054), trailing %b%k%f reset (p10k:7918).
1588 #[test]
1589 fn align_line_gap_char_style_and_indent() {
1590 locked(|| {
1591 use crate::ported::params::{setsparam, unsetparam};
1592 setsparam("POWERLEVEL9K_MULTILINE_FIRST_PROMPT_GAP_CHAR", "─");
1593 setsparam("POWERLEVEL9K_MULTILINE_FIRST_PROMPT_GAP_FOREGROUND", "244");
1594 setsparam("ZLE_RPROMPT_INDENT", "0");
1595 let measure = |s: &str| s.chars().count();
1596 let first = align_line("LL", "RRR", 0, 10, &measure);
1597 // Line 2+ uses the NEWLINE params (p10k:7879-7883), which
1598 // are unset here → plain ' ' fill, no style, no reset.
1599 let newline = align_line("LL", "RRR", 1, 10, &measure);
1600 // Overflow still emits the style + reset around the bare
1601 // newline (p10k:7906 — style precedes the `:-\n` arm).
1602 let overflow = align_line("LLLLLLLL", "RRRRR", 0, 10, &measure);
1603 unsetparam("POWERLEVEL9K_MULTILINE_FIRST_PROMPT_GAP_CHAR");
1604 unsetparam("POWERLEVEL9K_MULTILINE_FIRST_PROMPT_GAP_FOREGROUND");
1605 unsetparam("ZLE_RPROMPT_INDENT");
1606 // m = 10-5-0-1 = 4 → 5 fill chars, right flush at column 10.
1607 assert_eq!(first, "LL%F{244}─────RRR%{\n%}%b%k%f");
1608 assert_eq!(newline, "LL RRR%{\n%}");
1609 assert_eq!(overflow, "LLLLLLLL%F{244}\n%b%k%f");
1610 });
1611 }
1612
1613 /// ANSI stripping for the width probe.
1614 #[test]
1615 fn strip_ansi_sequences() {
1616 assert_eq!(strip_ansi("plain"), "plain");
1617 assert_eq!(strip_ansi("\u{1b}[38;5;4mabc\u{1b}[0m"), "abc");
1618 assert_eq!(strip_ansi("\u{1b}]0;title\u{7}abc"), "abc");
1619 assert_eq!(strip_ansi("a\u{1b}7b"), "ab");
1620 }
1621
1622 /// Visible width of prompt-escape strings: zero-width escapes drop,
1623 /// %% is one column, colored text keeps only its glyph widths.
1624 #[test]
1625 fn prompt_visible_width_measures_display_columns() {
1626 locked(|| {
1627 assert_eq!(prompt_visible_width(""), 0);
1628 assert_eq!(prompt_visible_width("abc"), 3);
1629 assert_eq!(prompt_visible_width("%%"), 1);
1630 assert_eq!(prompt_visible_width("%F{004}abc%f"), 3);
1631 assert_eq!(prompt_visible_width("%K{002}%B x %b%k"), 3);
1632 });
1633 }
1634}