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