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