Skip to main content

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}