Skip to main content

zsh/ported/zle/
zle_refresh.rs

1//! ZLE refresh - screen redraw routines
2//!
3//! Direct port from zsh/Src/Zle/zle_refresh.c
4
5use std::io::{self, Write};
6
7
8// TextAttr / RefreshElement / VideoBuffer / RefreshState — Rust-side
9// aggregates over zsh's C flat-globals (`winw`/`winh`/`vcs`/`vln`/
10// `lpromptw`/`rpromptw`/`region_highlights[]`/`nbuf`/`obuf` in
11// `Src/Zle/zle_refresh.c`). The C side represents these as separate
12// file-scope statics + bitmap-packed `zattr` cells; this port collects
13// them into structs for ergonomic access. Eventual unification target
14// (mirroring `Src/zsh.h:2685` `pub type zattr = u64`):
15//   - `TextAttr` → `zattr` (u64 packed bitmap)
16//   - `RefreshElement` → `zle_h::REFRESH_ELEMENT`
17//   - `VideoBuffer` → raw `Vec<REFRESH_ELEMENT>` for `nbuf`/`obuf`
18//   - `RefreshState` → discrete file-scope statics
19
20/// Unpacked-bool form of `zattr` (C's u64 packed attribute bitmap from
21/// `Src/zsh.h:2685`, ported as `pub type zattr = u64`). C stores
22/// attributes inline in `REFRESH_ELEMENT.atr` (a `zattr`); this port
23/// pre-unpacks to a 6-field struct for ergonomic access.
24#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
25pub struct TextAttr {
26    pub bold: bool,
27    pub underline: bool,
28    pub standout: bool,
29    pub blink: bool,
30    pub fg_color: Option<u8>,
31    pub bg_color: Option<u8>,
32}
33
34impl TextAttr {
35    /// Render this attribute set as the corresponding ANSI SGR
36    /// escape. Loose equivalent of `tsetcap()` from
37    /// Src/Zle/zle_refresh.c (which emits termcap-derived sequences
38    /// from per-cell attr changes during the diff/paint cycle).
39    pub fn to_ansi(&self) -> String {
40        let mut codes = Vec::new();
41        if self.bold {
42            codes.push("1".to_string());
43        }
44        if self.underline {
45            codes.push("4".to_string());
46        }
47        if self.standout {
48            codes.push("7".to_string());
49        }
50        if self.blink {
51            codes.push("5".to_string());
52        }
53        if let Some(fg) = self.fg_color {
54            codes.push(format!("38;5;{}", fg));
55        }
56        if let Some(bg) = self.bg_color {
57            codes.push(format!("48;5;{}", bg));
58        }
59        if codes.is_empty() {
60            String::new()
61        } else {
62            format!("\x1b[{}m", codes.join(";"))
63        }
64    }
65}
66
67/// Display cell. Loosely equivalent to zsh's `REFRESH_ELEMENT`
68/// (legit-ported at `zle_h.rs:688` as
69/// `pub struct REFRESH_ELEMENT { chr: REFRESH_CHAR, atr: zattr }`).
70/// Adds a `width: u8` field C doesn't have and uses `TextAttr` for
71/// `atr` instead of the C `zattr` bitmap.
72#[derive(Debug, Clone, Default)]
73pub struct RefreshElement {
74    pub chr: char,
75    pub atr: TextAttr,
76    pub width: u8,
77}
78
79impl RefreshElement {
80    /// Construct a refresh cell holding a single character with
81    /// default attributes. Equivalent shape to a freshly-zeroed
82    /// `REFRESH_ELEMENT` from Src/Zle/zle_refresh.h.
83    pub fn new(chr: char) -> Self {
84        let width = unicode_width::UnicodeWidthChar::width(chr).unwrap_or(1) as u8;
85        RefreshElement {
86            chr,
87            atr: TextAttr::default(),
88            width,
89        }
90    }
91
92    /// Construct a refresh cell with explicit text attributes.
93    /// Used by callers painting attributed regions (visual-mode
94    /// standout, isearch underline, etc.) directly into a
95    /// `VideoBuffer`.
96    pub fn with_attr(chr: char, atr: TextAttr) -> Self {
97        let width = unicode_width::UnicodeWidthChar::width(chr).unwrap_or(1) as u8;
98        RefreshElement { chr, atr, width }
99    }
100}
101
102/// 2D screen-buffer container. C uses `REFRESH_STRING nbuf[]` and
103/// `obuf[]` flat arrays of `REFRESH_ELEMENT *` (zle_refresh.c
104/// globals); this struct wraps a single 2D Vec for the per-frame
105/// new/old buffer pair.
106#[derive(Debug, Clone)]
107pub struct VideoBuffer {
108    /// Buffer contents — 2D array of lines.
109    pub lines: Vec<Vec<RefreshElement>>,
110    /// Number of columns.
111    pub cols: usize,
112    /// Number of rows.
113    pub rows: usize,
114}
115
116impl VideoBuffer {
117    /// Allocate a fresh video buffer of `cols × rows` filled with
118    /// blank cells. Equivalent to `resetvideo()` at
119    /// Src/Zle/zle_refresh.c:725 which allocates `nlnct * winw`
120    /// cells for `nbuf` each refresh.
121    pub fn new(cols: usize, rows: usize) -> Self {
122        let lines = vec![vec![RefreshElement::new(' '); cols]; rows];
123        VideoBuffer { lines, cols, rows }
124    }
125
126    /// Reset every cell to a blank-attribute space. Used by
127    /// `zrefresh()` between frames to wipe the working buffer
128    /// before the new paint pass — see `freevideo()` at
129    /// zle_refresh.c:700 for the equivalent role.
130    pub fn clear(&mut self) {
131        for line in &mut self.lines {
132            for elem in line.iter_mut() {
133                *elem = RefreshElement::new(' ');
134            }
135        }
136    }
137
138    /// Reshape the buffer for a new terminal size. Equivalent to
139    /// the cols/lines update + `nbuf`/`obuf` reallocation chain in
140    /// zle_refresh.c that fires on SIGWINCH (see the `winw`/`winh`
141    /// re-read in `zrefresh()` at zle_refresh.c:975).
142    pub fn resize(&mut self, cols: usize, rows: usize) {
143        self.cols = cols;
144        self.rows = rows;
145        self.lines
146            .resize(rows, vec![RefreshElement::new(' '); cols]);
147        for line in &mut self.lines {
148            line.resize(cols, RefreshElement::new(' '));
149        }
150    }
151
152    /// Write a single cell into the buffer; out-of-range writes are
153    /// silently dropped (matches the C source's bounds check before
154    /// `nbuf[row][col] = ...` in zle_refresh.c).
155    pub fn set(&mut self, row: usize, col: usize, elem: RefreshElement) {
156        if row < self.rows && col < self.cols {
157            self.lines[row][col] = elem;
158        }
159    }
160
161    /// Read a single cell. Returns None for out-of-range coords —
162    /// the C source's index path is unchecked (uses `winw`/`nlnct`
163    /// invariants).
164    pub fn get(&self, row: usize, col: usize) -> Option<&RefreshElement> {
165        self.lines.get(row).and_then(|line| line.get(col))
166    }
167}
168
169/// Composite of zle_refresh.c globals (winw/winh/vcs/vln/vmaxln,
170/// oldmax, lastrow, lastcol, more_status, etc.) collected into one
171/// struct. C uses separate file-statics per name
172/// (`int winw, winh, vcs, vln, ...`).
173#[derive(Debug, Clone, Default)]
174pub struct RefreshState {
175    /// Number of columns.
176    pub columns: usize, // winw, window width                                // c:682
177    /// Number of lines.
178    pub lines: usize, // winh, window height                                 // c:682
179    /// Current line on screen (cursor row).
180    pub vln: usize, // video cursor position line                            // c:680
181    /// Current column on screen (cursor col).
182    pub vcs: usize, // video cursor position column                          // c:680
183    /// Prompt width (left).
184    pub lpromptw: usize, // prompt widths on screen                          // c:676
185    /// Right prompt width.
186    pub rpromptw: usize, // prompt widths on screen                          // c:676
187    /// Scroll offset for horizontal scrolling.
188    pub scrolloff: usize,
189    /// Region highlight start.
190    pub region_highlight_start: Option<usize>,
191    /// Region highlight end.
192    pub region_highlight_end: Option<usize>,
193    /// Old video buffer.
194    pub old_video: Option<VideoBuffer>,
195    /// New video buffer.
196    pub new_video: Option<VideoBuffer>,
197    /// Prompt string (left).
198    pub lpromptbuf: String,
199    /// Right prompt string.
200    pub rpromptbuf: String,
201    /// Whether we need full redraw.
202    pub need_full_redraw: bool,
203    /// Predisplay string (before main buffer).
204    pub predisplay: String,
205    /// Postdisplay string (after main buffer).
206    pub postdisplay: String,
207}
208
209impl RefreshState {
210    /// Build the initial refresh state at zleread() entry.
211    /// Equivalent to the global `nbuf`/`obuf`/`vln`/`vcs`
212    /// allocation + reset performed by `resetvideo()` at
213    /// Src/Zle/zle_refresh.c:725 — terminal size queried once,
214    /// both video buffers allocated, `need_full_redraw` set so the
215    /// first paint touches every cell.
216    pub fn new() -> Self {
217        let (cols, rows) = (
218            crate::ported::utils::adjustcolumns(),
219            crate::ported::utils::adjustlines(),
220        );
221        RefreshState {
222            columns: cols,
223            lines: rows,
224            old_video: Some(VideoBuffer::new(cols, rows)),
225            new_video: Some(VideoBuffer::new(cols, rows)),
226            need_full_redraw: true,
227            ..Default::default()
228        }
229    }
230
231    /// Reallocate the video buffers for the current terminal size
232    /// and arm a full redraw on the next paint. Equivalent to
233    /// `resetvideo()` from Src/Zle/zle_refresh.c:725 invoked after
234    /// SIGWINCH (the C source calls it from `adjustwinsize()` in
235    /// Src/init.c).
236    pub fn reset_video(&mut self) {
237        let (cols, rows) = (
238            crate::ported::utils::adjustcolumns(),
239            crate::ported::utils::adjustlines(),
240        );
241        self.columns = cols;
242        self.lines = rows;
243        self.old_video = Some(VideoBuffer::new(cols, rows));
244        self.new_video = Some(VideoBuffer::new(cols, rows));
245        self.need_full_redraw = true;
246    }
247
248    /// Drop both video buffers — used at ZLE shutdown. Equivalent
249    /// to `freevideo()` from Src/Zle/zle_refresh.c:700.
250    pub fn free_video(&mut self) {
251        self.old_video = None;
252        self.new_video = None;
253    }
254
255    /// Promote the freshly-painted buffer to "previously displayed"
256    /// and clear the new-buffer slate for the next frame.
257    /// Equivalent to `bufswap()` from Src/Zle/zle_refresh.c:946 —
258    /// the C source swaps `nbuf` and `obuf` pointers and zeroes the
259    /// new `nbuf` so the diff loop has a clean target.
260    pub fn swap_buffers(&mut self) {
261        std::mem::swap(&mut self.old_video, &mut self.new_video);
262        if let Some(ref mut new) = self.new_video {
263            new.clear();
264        }
265    }
266}
267use HighlightCategory as HC;
268use crate::ported::zsh_h::TXT_MULTIWORD_MASK;
269
270    /// Main refresh function — redraws the line.
271    /// Port of `zrefresh()` from Src/Zle/zle_refresh.c. The C source paints
272    /// a full virtual-screen diff against the previous frame; this Rust
273    /// port renders the single line each call but adds three behaviors
274    /// the previous bare-buffer version was missing:
275    ///   * region-attribute overlay (zle_refresh.c `region_highlights[]`),
276    ///   * vi visual-mode auto-region (mirrors zle_refresh.c's check of
277    ///     `region_active` to paint mark..zlecs in standout),
278    ///   * RPS1 / right-prompt rendering at the right margin
279    ///     (zle_refresh.c `put_rpromptbuf` path).
280
281// --- AUTO: cross-zle hoisted-fn use glob ---
282#[allow(unused_imports)]
283#[allow(unused_imports)]
284use crate::ported::zle::zle_main::*;
285#[allow(unused_imports)]
286use crate::ported::zle::zle_misc::*;
287#[allow(unused_imports)]
288use crate::ported::zle::zle_hist::*;
289#[allow(unused_imports)]
290use crate::ported::zle::zle_move::*;
291#[allow(unused_imports)]
292use crate::ported::zle::zle_word::*;
293#[allow(unused_imports)]
294use crate::ported::zle::zle_params::*;
295#[allow(unused_imports)]
296use crate::ported::zle::zle_vi::*;
297#[allow(unused_imports)]
298use crate::ported::zle::zle_utils::*;
299#[allow(unused_imports)]
300use crate::ported::zle::zle_tricky::*;
301#[allow(unused_imports)]
302use crate::ported::zle::textobjects::*;
303#[allow(unused_imports)]
304use crate::ported::zle::deltochar::*;
305
306    pub fn zrefresh() {                                             // c:975
307        // c:975 — full repaint pipeline. C writes every byte through
308        //          `tputs(..., putshout)` / `fputs(..., shout)`. Rust
309        //          collects the rendered escape stream into a String
310        //          and writes it to SHTTY in one shot — matches C's
311        //          shout destination and reduces syscall count.
312        use std::fmt::Write as FmtWrite;
313        let mut handle = String::new();
314
315        let (cols, _rows) = (crate::ported::utils::adjustcolumns(), crate::ported::utils::adjustlines());
316
317        let prompt = prompt().to_string();
318        let rprompt = rprompt().to_string();
319        let cursor = crate::ported::zle::zle_main::ZLECS.load(std::sync::atomic::Ordering::SeqCst);
320
321        let prompt_width = countprompt(&prompt);
322        let rprompt_width = countprompt(&rprompt);
323        let buffer_before_cursor: String = crate::ported::zle::zle_main::ZLELINE.lock().unwrap()[..cursor.min(crate::ported::zle::zle_main::ZLELINE.lock().unwrap().len())]
324            .iter()
325            .collect();
326        let cursor_col = prompt_width + countprompt(&buffer_before_cursor);
327
328        // Horizontal scroll if the cursor approaches the right edge.
329        // Mirrors zle_refresh.c's `winw` clamp logic — without the full
330        // multi-line wrap path our single-line shell uses scroll instead.
331        let scroll_margin = 8;
332        let effective_cols = cols.saturating_sub(1);
333        let scroll_offset = if cursor_col >= effective_cols.saturating_sub(scroll_margin) {
334            cursor_col.saturating_sub(effective_cols / 2)
335        } else {
336            0
337        };
338
339        // Compose the per-buffer-char attribute overlay before paint, so
340        // we don't have to re-walk the highlight list per char during write.
341        let attrs = compute_render_attrs();
342
343        let _ = write!(handle, "\r\x1b[K");
344
345        // Prompt — drawn unless we've scrolled past it. Skip
346        // `scroll_offset` visible chars from the prompt (inlined
347        // from the deleted skip_chars helper) — ANSI escape
348        // sequences are skipped unconditionally so they don't
349        // count against width.
350        if scroll_offset < prompt_width {
351            let mut width = 0;
352            let mut byte_idx = 0;
353            let mut in_escape = false;
354            for (i, c) in prompt.char_indices() {
355                if width >= scroll_offset {
356                    byte_idx = i;
357                    break;
358                }
359                if in_escape {
360                    if c.is_ascii_alphabetic() {
361                        in_escape = false;
362                    }
363                } else if c == '\x1b' {
364                    in_escape = true;
365                } else {
366                    width += unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
367                }
368                byte_idx = i + c.len_utf8();
369            }
370            let _ = write!(handle, "{}", &prompt[byte_idx..]);
371        }
372
373        // Compute the visible byte/char range of the buffer after scroll.
374        let buffer_start = scroll_offset.saturating_sub(prompt_width);
375        // Width budget for buffer = total cols - prompt drawn - rprompt reserve.
376        let drawn_prompt_width = prompt_width.saturating_sub(scroll_offset);
377        let rprompt_reserve = if rprompt_width > 0 {
378            rprompt_width + 1
379        } else {
380            0
381        };
382        let buffer_budget = effective_cols
383            .saturating_sub(drawn_prompt_width)
384            .saturating_sub(rprompt_reserve);
385
386        // Walk the buffer chars from buffer_start, applying overlay attrs.
387        let mut current_attr: Option<TextAttr> = None;
388        let line_snapshot = crate::ported::zle::zle_main::ZLELINE.lock().unwrap().clone();
389        for (written, (idx, ch)) in line_snapshot
390            .iter()
391            .enumerate()
392            .skip(buffer_start)
393            .enumerate()
394        {
395            if written >= buffer_budget {
396                break;
397            }
398            let want_attr = attrs.get(idx).and_then(|a| *a);
399            if want_attr != current_attr {
400                let _ = write!(handle, "\x1b[0m");
401                if let Some(a) = want_attr {
402                    let _ = write!(handle, "{}", a.to_ansi());
403                }
404                current_attr = want_attr;
405            }
406            let _ = write!(handle, "{}", ch);
407        }
408        // Reset SGR before the rprompt / cursor jump.
409        if current_attr.is_some() {
410            let _ = write!(handle, "\x1b[0m");
411        }
412
413        // Right prompt — paint at the absolute right margin if there's
414        // room. Mirrors put_rpromptbuf in zle_refresh.c which writes RPS1
415        // at column (winw - rpromptw).
416        if rprompt_width > 0 && rprompt_width + 2 < effective_cols {
417            let rprompt_col = effective_cols.saturating_sub(rprompt_width);
418            let _ = write!(handle, "\r\x1b[{}C{}\x1b[0m", rprompt_col, rprompt);
419        }
420
421        // Cursor positioning (1-based column in ANSI).
422        let display_cursor_col = cursor_col.saturating_sub(scroll_offset);
423        let _ = write!(handle, "\r\x1b[{}C", display_cursor_col);
424
425        // c:1488 — `fwrite(out, ..., shout); fflush(shout);`. Single
426        //          write_loop emits the whole frame to SHTTY (stdout
427        //          fallback). Replaces the prior `stdout.lock()`
428        //          fake that wrote refresh output to stdout instead
429        //          of the controlling tty.
430        use std::sync::atomic::Ordering;
431        let fd = crate::ported::init::SHTTY.load(Ordering::Relaxed);
432        let out_fd = if fd >= 0 { fd } else { 1 };
433        let _ = crate::ported::utils::write_loop(out_fd, handle.as_bytes());
434    }
435
436    /// Build the per-character attribute overlay used by `zrefresh`.
437    /// One slot per char in `zleline`; `None` means "default attrs",
438    /// `Some(attr)` means apply `attr` for that cell.
439    ///
440    /// Port of the inner loop in `zrefresh()` (Src/Zle/zle_refresh.c) that
441    /// consults `region_highlights[]` for each visible cell. The vi
442    /// visual-mode region is synthesised from `region_active` + `mark`
443    /// here so `v` selects visibly without callers having to push a
444    /// region themselves — matching zle_refresh.c's auto-promotion of
445    /// `region_active` into a paintable highlight.
446    pub fn compute_render_attrs() -> Vec<Option<TextAttr>> {
447        let buf_len = crate::ported::zle::zle_main::ZLELINE.lock().unwrap().len();
448        let mut attrs: Vec<Option<TextAttr>> = vec![None; buf_len];
449
450        // Visual-region attr: prefer the user's `region:` setting from
451        // $zle_highlight (populated by zle_set_highlight); fall back to
452        // standout per zsh's default at zle_refresh.c:397.
453        let visual_attr = crate::ported::zle::zle_main::highlight().lock().unwrap()
454            .category_attrs
455            .get(&HighlightCategory::Region)
456            .copied()
457            .unwrap_or(TextAttr {
458                standout: true,
459                ..TextAttr::default()
460            });
461
462        if crate::ported::zle::zle_main::REGION_ACTIVE.load(std::sync::atomic::Ordering::SeqCst) != 0 {
463            let (lo, hi) = if crate::ported::zle::zle_main::MARK.load(std::sync::atomic::Ordering::SeqCst) <= crate::ported::zle::zle_main::ZLECS.load(std::sync::atomic::Ordering::SeqCst) {
464                (crate::ported::zle::zle_main::MARK.load(std::sync::atomic::Ordering::SeqCst), crate::ported::zle::zle_main::ZLECS.load(std::sync::atomic::Ordering::SeqCst))
465            } else {
466                (crate::ported::zle::zle_main::ZLECS.load(std::sync::atomic::Ordering::SeqCst), crate::ported::zle::zle_main::MARK.load(std::sync::atomic::Ordering::SeqCst))
467            };
468            let lo = lo.min(buf_len);
469            let hi = hi.min(buf_len);
470            for slot in attrs.iter_mut().take(hi).skip(lo) {
471                *slot = Some(visual_attr);
472            }
473        }
474        for region in &crate::ported::zle::zle_main::highlight().lock().unwrap().regions {
475            let start = region.start.min(buf_len);
476            let end = region.end.min(buf_len);
477            for slot in attrs.iter_mut().take(end).skip(start) {
478                *slot = Some(region.attr);
479            }
480        }
481        attrs
482    }
483
484    /// Full screen refresh - clears and redraws everything.
485    pub fn full_refresh() -> io::Result<()> {
486        use std::sync::atomic::Ordering;
487        let fd = crate::ported::init::SHTTY.load(Ordering::Relaxed);
488        let out = if fd >= 0 { fd } else { 1 };
489        let _ = crate::ported::utils::write_loop(out, b"\x1b[2J\x1b[H");
490        zrefresh();
491        Ok(())
492    }
493
494    /// Partial refresh (optimize for minimal updates).
495    pub fn partial_refresh() -> io::Result<()> {
496        zrefresh();
497        Ok(())
498    }
499
500    /// Direct port of `void clearscreen(UNUSED(char **args))` from
501    /// `Src/Zle/zle_refresh.c:2366`. Writes CSI 2J + CSI H to the
502    /// shell-output fd, then re-renders. Was a `print!` fake.
503    pub fn clearscreen() {                                          // c:2366
504        let _ = crate::ported::utils::write_loop({ use std::sync::atomic::Ordering; let f = crate::ported::init::SHTTY.load(Ordering::Relaxed); if f >= 0 { f } else { 1 } }, b"\x1b[2J\x1b[H");
505        zrefresh();
506    }
507
508    /// Direct port of `void redisplay(UNUSED(char **args))` from
509    /// `Src/Zle/zle_refresh.c:2377`. C kicks `resetneeded = 1` and
510    /// returns; Rust just re-runs zrefresh which equivalently
511    /// repaints from current state.
512    pub fn redisplay() {                                            // c:2377
513        zrefresh();
514    }
515
516    /// Direct port of `void moveto(int ln, int cl)` from
517    /// `Src/Zle/zle_refresh.c:2105`. C uses termcap `cm` / `cup`
518    /// strings to teleport the cursor; Rust emits the equivalent
519    /// CSI ; H sequence (rows/cols 1-indexed per ANSI). Was a
520    /// `print!` fake.
521    pub fn moveto(row: usize, col: usize) {                       // c:2105
522        let s = format!("\x1b[{};{}H", row + 1, col + 1);
523        let _ = crate::ported::utils::write_loop({ use std::sync::atomic::Ordering; let f = crate::ported::init::SHTTY.load(Ordering::Relaxed); if f >= 0 { f } else { 1 } }, s.as_bytes());
524    }
525
526    /// Port of `void tc_downcurs(int ct)` from
527    /// `Src/Zle/zle_refresh.c:2126`. C emits the termcap `do`/`down`
528    /// capability `ct` times; Rust emits the parametrised CSI B.
529    pub fn tc_downcurs(count: usize) {
530        if count > 0 {
531            let s = format!("\x1b[{}B", count);
532            let _ = crate::ported::utils::write_loop({ use std::sync::atomic::Ordering; let f = crate::ported::init::SHTTY.load(Ordering::Relaxed); if f >= 0 { f } else { 1 } }, s.as_bytes());
533        }
534    }
535
536    /// Port of `void tc_rightcurs(int ct)` from
537    /// `Src/Zle/zle_refresh.c:2150`. CSI C parametrised cursor-right.
538    pub fn tc_rightcurs(count: usize) {
539        if count > 0 {
540            let s = format!("\x1b[{}C", count);
541            let _ = crate::ported::utils::write_loop({ use std::sync::atomic::Ordering; let f = crate::ported::init::SHTTY.load(Ordering::Relaxed); if f >= 0 { f } else { 1 } }, s.as_bytes());
542        }
543    }
544
545    /// Port of `void scrollwindow(int tline)` from
546    /// `Src/Zle/zle_refresh.c:1991`. Positive lines → scroll up (CSI S),
547    /// negative → scroll down (CSI T).
548    pub fn scrollwindow(lines: i32) {
549        let s = if lines > 0 {
550            format!("\x1b[{}S", lines)
551        } else if lines < 0 {
552            format!("\x1b[{}T", -lines)
553        } else {
554            return;
555        };
556        let _ = crate::ported::utils::write_loop({ use std::sync::atomic::Ordering; let f = crate::ported::init::SHTTY.load(Ordering::Relaxed); if f >= 0 { f } else { 1 } }, s.as_bytes());
557    }
558
559    /// Port of `void singlerefresh(ZLE_STRING_T tmpline, int tmpll,
560    /// int tmpcs)` from `Src/Zle/zle_refresh.c:2397`. C builds a
561    /// fresh single-line video buffer for `read -e` / `vared` style
562    /// non-multiline editing; Rust defers to `zrefresh` which
563    /// handles single-line as a special case of multi-line.
564    pub fn singlerefresh() {                                        // c:2397
565        zrefresh();
566    }
567
568    /// Port of `void refreshline(int ln)` from
569    /// `Src/Zle/zle_refresh.c:1543`. Forces a single-line repaint;
570    /// our zrefresh repaints the whole video buffer regardless.
571    pub fn refreshline(_line: usize) {
572        zrefresh();
573    }
574
575    /// Port of `void zwcputc(const REFRESH_ELEMENT *c)` from
576    /// `Src/Zle/zle_refresh.c`. C: `putc(c->chr, shout)`. Rust:
577    /// encodes the char as UTF-8 bytes and writes to the shell-out
578    /// fd.
579    pub fn zwcputc(c: char) {
580        let mut buf = [0u8; 4];
581        let s = c.encode_utf8(&mut buf);
582        let _ = crate::ported::utils::write_loop({ use std::sync::atomic::Ordering; let f = crate::ported::init::SHTTY.load(Ordering::Relaxed); if f >= 0 { f } else { 1 } }, s.as_bytes());
583    }
584
585    /// Port of `void zwcwrite(const REFRESH_STRING s, size_t i)`
586    /// from `Src/Zle/zle_refresh.c`. C: `fwrite(s, sizeof(*s), i,
587    /// shout)`. Rust writes the UTF-8 bytes to shout.
588    pub fn zwcwrite(s: &str) {
589        let _ = crate::ported::utils::write_loop({ use std::sync::atomic::Ordering; let f = crate::ported::init::SHTTY.load(Ordering::Relaxed); if f >= 0 { f } else { 1 } }, s.as_bytes());
590    }
591
592
593/// Calculate visible width of a prompt string — port of `countprompt()`
594/// from Src/prompt.c:1140. The C function counts cells while skipping
595/// the `Inpar..Outpar` (zsh's `%{...%}`) invisible-region tokens; this
596/// Rust port skips ANSI escape sequences instead, which is what the
597/// expanded prompt buffer contains by the time the refresh path uses it.
598/// The C variant outputs width AND height via out-pointers; this port
599/// returns width only (the only field the refresh path consumes here).
600fn countprompt(s: &str) -> usize {
601    let mut width = 0;
602    let mut in_escape = false;
603
604    for c in s.chars() {
605        if in_escape {
606            if c.is_ascii_alphabetic() {
607                in_escape = false;
608            }
609        } else if c == '\x1b' {
610            in_escape = true;
611        } else {
612            width += unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
613        }
614    }
615
616    width
617}
618
619// RegionHighlight / HighlightCategory / HighlightManager — Rust-side
620// aggregates over zsh's C `region_highlights[N_SPECIAL_HIGHLIGHTS]`
621// array + per-category attr globals (`default_attr`/`special_attr`/
622// `ellipsis_attr` from `Src/Zle/zle_refresh.c`). C uses bare integer
623// indexing into a fixed-size array; this port uses a typed enum +
624// HashMap. Eventual unification: collapse into discrete file-scope
625// statics matching the C layout.
626
627/// Simplified region-highlight entry. Loosely equivalent to
628/// `struct region_highlight` (legit-ported at `zle_h.rs:613` with
629/// different fields: start/end/atr/flags/memo/layer).
630#[derive(Debug, Clone)]
631pub struct RegionHighlight {
632    pub start: usize,
633    pub end: usize,
634    pub attr: TextAttr,
635    pub memo: Option<String>,
636}
637
638/// Identifies a fixed slot in zsh's
639/// `region_highlights[N_SPECIAL_HIGHLIGHTS]` array (zle_refresh.c
640/// indices 0=region, 1=isearch, 2=suffix, 3=paste) plus the
641/// standalone default/special/ellipsis attr globals
642/// (`default_attr`/`special_attr`/`ellipsis_attr`). C uses bare
643/// integer indexing — no enum.
644#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
645pub enum HighlightCategory {
646    Region,
647    Isearch,
648    Suffix,
649    Paste,
650    Default,
651    Special,
652    Ellipsis,
653}
654
655/// Collects C's `region_highlights[]` array + per-category attr
656/// globals (`default_attr`/`special_attr`/`ellipsis_attr` from
657/// zle_refresh.c) into one container.
658#[derive(Debug, Default)]
659pub struct HighlightManager {
660    pub regions: Vec<RegionHighlight>,
661    /// Per-category attrs from `$zle_highlight`. Index by
662    /// `HighlightCategory`. Equivalent to the per-slot atr storage
663    /// in `region_highlights[]` and the
664    /// `default_attr`/`special_attr`/`ellipsis_attr` globals in
665    /// Src/Zle/zle_refresh.c — populated by `zle_set_highlight()`.
666    pub category_attrs: std::collections::HashMap<HighlightCategory, TextAttr>,
667}
668
669impl HighlightManager {
670    pub fn new() -> Self {
671        HighlightManager {
672            regions: Vec::new(),
673            category_attrs: std::collections::HashMap::new(),
674        }
675    }
676
677    /// Set region highlight. Equivalent to
678    /// `set_region_highlight()` from zle_refresh.c.
679    pub fn set_region_highlight(&mut self, start: usize, end: usize, attr: TextAttr) {
680        self.regions.push(RegionHighlight {
681            start,
682            end,
683            attr,
684            memo: None,
685        });
686    }
687
688    /// Get region highlight for position. Equivalent to
689    /// `get_region_highlight()` from zle_refresh.c.
690    pub fn get_region_highlight(&self, pos: usize) -> Option<&RegionHighlight> {
691        self.regions.iter().find(|r| pos >= r.start && pos < r.end)
692    }
693
694    /// Unset region highlight. Equivalent to
695    /// `unset_region_highlight()` from zle_refresh.c.
696    pub fn unset_region_highlight(&mut self) {
697        self.regions.clear();
698    }
699
700    /// Free highlight resources. Equivalent to
701    /// `zle_free_highlight()` from zle_refresh.c.
702    pub fn free(&mut self) {
703        self.regions.clear();
704    }
705}
706
707/// Port of `void tcout(int cap)` from `Src/Zle/zle_refresh.c:2339`.
708/// C looks up the termcap string via `tcstr[cap]` and writes it
709/// through `tputs(..., putshout)` to the shell-output fd. Rust port
710/// takes the resolved escape string directly (skipping the
711/// `tcstr[]` index lookup, since termcap probing isn't fully wired)
712/// and writes the bytes to `SHTTY` via `write_loop`.
713///
714/// Falls back to stdout (fd 1) when `SHTTY` is unset — covers the
715/// non-interactive paths (tests, batch evaluation) where there's no
716/// dedicated shell-output fd yet.
717/// WARNING: signature change — C=(int cap) vs Rust=(cap: &str).
718pub fn tcout(cap: &str) {                                                    // c:2339
719    use std::sync::atomic::Ordering;
720    let fd = crate::ported::init::SHTTY.load(Ordering::Relaxed);
721    if fd >= 0 {
722        let _ = crate::ported::utils::write_loop(fd, cap.as_bytes());
723    } else {
724        let _ = crate::ported::utils::write_loop(1, cap.as_bytes());
725    }
726    // c:2346 — `SELECT_ADD_COST(tclen[cap])` — without per-cap tclen
727    //          table, the cost accounting is dropped (no scheduling
728    //          consumer reads it yet).
729}
730
731/// Port of `void tcoutarg(int cap, int arg)` from
732/// `Src/Zle/zle_refresh.c:2351`. C calls `tgoto(tcstr[cap], arg, arg)`
733/// to expand termcap `%d` / parametrised escape codes. Rust port
734/// does a literal `%d → arg` substring substitution (mirrors the
735/// most common case; doesn't handle the rare termcap `%p1%d`
736/// parametrisation that `tgoto` handles).
737/// WARNING: signature change — C=(int cap, int arg) vs Rust=(cap: &str, arg: i32).
738pub fn tcoutarg(cap: &str, arg: i32) {                                       // c:2351
739    use std::sync::atomic::Ordering;
740    // c:2355 — `result = tgoto(tcstr[cap], arg, arg);`
741    let s = cap.replace("%d", &arg.to_string());
742    let fd = crate::ported::init::SHTTY.load(Ordering::Relaxed);
743    let out_fd = if fd >= 0 { fd } else { 1 };
744    let _ = crate::ported::utils::write_loop(out_fd, s.as_bytes());          // c:2359
745}
746
747/// Port of `void tcmultout(int cap, int multcap, int ct)` from
748/// `Src/Zle/zle_refresh.c:2163`. The C version tries the multi-arg
749/// `multcap` capability first (`tcoutarg(multcap, ct)`) and only
750/// falls back to a single-cap loop when `multcap` is unavailable.
751/// Rust port (without termcap probe) goes straight to the loop —
752/// `count` repeats of the same single-shot string.
753/// WARNING: signature change — C=(int cap, int multcap, int ct) vs Rust=(cap: &str, count: i32).
754pub fn tcmultout(cap: &str, count: i32) {                                    // c:2163
755    use std::sync::atomic::Ordering;
756    let fd = crate::ported::init::SHTTY.load(Ordering::Relaxed);
757    let out_fd = if fd >= 0 { fd } else { 1 };
758    for _ in 0..count {                                                      // c:2173 single-cap loop
759        let _ = crate::ported::utils::write_loop(out_fd, cap.as_bytes());
760    }
761}
762
763/// Port of `void tcoutclear(int cap)` from
764/// `Src/Zle/zle_refresh.c:607`. C dispatches on `cap` (a termcap
765/// index — TCCLEAREOL/TCCLEAREOD/TCCLEARSCREEN) to emit the
766/// corresponding escape. Rust collapses to a bool `to_end`:
767/// `true` → clear-to-end (CSI J), `false` → clear-entire-screen
768/// (CSI 2J).
769/// WARNING: signature change — C=(int cap) vs Rust=(to_end: bool).
770pub fn tcoutclear(to_end: bool) {                                            // c:607
771    use std::sync::atomic::Ordering;
772    let bytes: &[u8] = if to_end {
773        b"\x1b[J"      // CSI J — clear to end of screen
774    } else {
775        b"\x1b[2J"     // CSI 2J — clear entire screen
776    };
777    let fd = crate::ported::init::SHTTY.load(Ordering::Relaxed);
778    let out_fd = if fd >= 0 { fd } else { 1 };
779    let _ = crate::ported::utils::write_loop(out_fd, bytes);
780}
781
782/// Initialize ZLE refresh subsystem
783/// Port of zle_refresh_boot() from zle_refresh.c
784pub fn zle_refresh_boot() -> RefreshState {
785    RefreshState::new()
786}
787
788/// Cleanup ZLE refresh subsystem
789/// Port of zle_refresh_finish() from zle_refresh.c
790pub fn zle_refresh_finish(state: &mut RefreshState) {
791    state.free_video();
792}
793
794/// Parse a highlight attribute spec (the part after the `category:` prefix)
795/// into a `TextAttr`. Accepts a comma-separated list of:
796///   * `bold` / `nobold`,
797///   * `underline` / `nounderline`,
798///   * `standout` / `nostandout`,
799///   * `blink` / `noblink`,
800///   * `fg=N` / `bg=N` where N is 0..=255 (256-colour palette index) or
801///     one of the named ANSI colours below,
802///   * `none` (clears every attr).
803///
804/// ZLE-region subset of `match_highlight` (Src/prompt.c:2031),
805/// restricted to the tokens users actually set in `$zle_highlight`.
806/// The `hl=`/`layer=`/`opacity=` clauses (prompt.c:2042-2094) are
807/// not surfaced here — those are prompt-system hooks that don't
808/// apply to ZLE region paint.
809pub fn match_highlight(spec: &str) -> TextAttr {
810    let mut attr = TextAttr::default();
811    for token in spec.split(',') {
812        let token = token.trim();
813        if token.is_empty() {
814            continue;
815        }
816        match token {
817            "none" => {
818                attr = TextAttr::default();
819            }
820            "bold" => attr.bold = true,
821            "nobold" => attr.bold = false,
822            "underline" => attr.underline = true,
823            "nounderline" => attr.underline = false,
824            "standout" => attr.standout = true,
825            "nostandout" => attr.standout = false,
826            "blink" => attr.blink = true,
827            "noblink" => attr.blink = false,
828            other => {
829                if let Some(rest) = other.strip_prefix("fg=") {
830                    attr.fg_color = match_colour(rest);
831                } else if let Some(rest) = other.strip_prefix("bg=") {
832                    attr.bg_color = match_colour(rest);
833                }
834                // Anything else (hl=, layer=, opacity=, unknown name) is
835                // silently dropped — same as the C source's "found = 0"
836                // exit path at prompt.c:2122 when no clause matched.
837            }
838        }
839    }
840    attr
841}
842
843/// Parse a colour token (named or numeric) into a 256-colour palette index.
844/// Mirrors the eight ANSI base names + 256-colour numeric form supported
845/// by `match_colour()` (Src/prompt.c, called from `match_highlight`). The
846/// 24-bit `#rrggbb` form and `bright-foo` aliases are not surfaced.
847fn match_colour(name: &str) -> Option<u8> {
848    match name {
849        "black" => Some(0),
850        "red" => Some(1),
851        "green" => Some(2),
852        "yellow" => Some(3),
853        "blue" => Some(4),
854        "magenta" => Some(5),
855        "cyan" => Some(6),
856        "white" => Some(7),
857        "default" => None,
858        n => n.parse::<u8>().ok(),
859    }
860}
861
862/// Apply a `$zle_highlight` array to the manager.
863/// Port of `zle_set_highlight()` from Src/Zle/zle_refresh.c:322. Walks
864/// each `category:spec` entry, parses the spec via `match_highlight`,
865/// and stores it in `category_attrs`. Categories not mentioned keep the
866/// zsh defaults, applied here on first call: `region` and `special`
867/// default to `standout`, `isearch` to `underline`, `suffix` to `bold`
868/// — direct ports of zle_refresh.c:395-402.
869/// WARNING: param names don't match C — Rust=(manager, atrs) vs C=()
870pub fn zle_set_highlight(manager: &mut HighlightManager, atrs: &[&str]) {
871
872    let mut seen = std::collections::HashSet::new();
873    for entry in atrs {
874        if entry.is_empty() {
875            continue;
876        }
877        if *entry == "none" {
878            // zle_refresh.c:355-360 — `none` clears every category.
879            for cat in [
880                HC::Region,
881                HC::Isearch,
882                HC::Suffix,
883                HC::Paste,
884                HC::Default,
885                HC::Special,
886                HC::Ellipsis,
887            ] {
888                manager.category_attrs.insert(cat, TextAttr::default());
889                seen.insert(cat);
890            }
891            continue;
892        }
893        let (prefix, rest) = match entry.split_once(':') {
894            Some(t) => t,
895            None => continue,
896        };
897        let cat = match prefix {
898            "region" => HC::Region,
899            "isearch" => HC::Isearch,
900            "suffix" => HC::Suffix,
901            "paste" => HC::Paste,
902            "default" => HC::Default,
903            "special" => HC::Special,
904            "ellipsis" => HC::Ellipsis,
905            _ => continue,
906        };
907        manager.category_attrs.insert(cat, match_highlight(rest));
908        seen.insert(cat);
909    }
910
911    // Defaults for unset slots — zle_refresh.c:395-402.
912    let default_standout = TextAttr {
913        standout: true,
914        ..TextAttr::default()
915    };
916    let default_underline = TextAttr {
917        underline: true,
918        ..TextAttr::default()
919    };
920    let default_bold = TextAttr {
921        bold: true,
922        ..TextAttr::default()
923    };
924    if !seen.contains(&HC::Region) {
925        manager.category_attrs.insert(HC::Region, default_standout);
926    }
927    if !seen.contains(&HC::Isearch) {
928        manager.category_attrs.insert(HC::Isearch, default_underline);
929    }
930    if !seen.contains(&HC::Suffix) {
931        manager.category_attrs.insert(HC::Suffix, default_bold);
932    }
933    if !seen.contains(&HC::Special) {
934        manager.category_attrs.insert(HC::Special, default_standout);
935    }
936}
937
938#[cfg(test)]
939mod tests {
940    use super::*;
941
942    #[test]
943    fn test_countprompt() {
944        let _g = crate::ported::zle::zle_main::zle_test_setup();
945        assert_eq!(countprompt("hello"), 5);
946        assert_eq!(countprompt("\x1b[31mhello\x1b[0m"), 5);
947        assert_eq!(countprompt("日本語"), 6); // 3 chars, 2 width each
948    }
949
950    #[test]
951    fn test_video_buffer() {
952        let _g = crate::ported::zle::zle_main::zle_test_setup();
953        let mut buf = VideoBuffer::new(80, 24);
954        assert_eq!(buf.cols, 80);
955        assert_eq!(buf.rows, 24);
956
957        buf.set(0, 0, RefreshElement::new('A'));
958        assert_eq!(buf.get(0, 0).map(|e| e.chr), Some('A'));
959
960        buf.clear();
961        assert_eq!(buf.get(0, 0).map(|e| e.chr), Some(' '));
962    }
963
964    #[test]
965    fn test_refresh_state() {
966        let _g = crate::ported::zle::zle_main::zle_test_setup();
967        let mut state = RefreshState::new();
968        assert!(state.old_video.is_some());
969        assert!(state.new_video.is_some());
970
971        state.swap_buffers();
972        state.free_video();
973        assert!(state.old_video.is_none());
974    }
975
976    #[test]
977    fn compute_render_attrs_empty_buffer_yields_empty_overlay() {
978        let _g = crate::ported::zle::zle_main::zle_test_setup();
979        assert!(compute_render_attrs().is_empty());
980    }
981
982    #[test]
983    fn compute_render_attrs_visual_mode_paints_mark_to_cursor_in_standout() {
984        let _g = crate::ported::zle::zle_main::zle_test_setup();
985        *crate::ported::zle::zle_main::ZLELINE.lock().unwrap() = "hello world".chars().collect();
986        crate::ported::zle::zle_main::ZLELL.store(crate::ported::zle::zle_main::ZLELINE.lock().unwrap().len(), std::sync::atomic::Ordering::SeqCst);
987        crate::ported::zle::zle_main::MARK.store(2, std::sync::atomic::Ordering::SeqCst);
988        crate::ported::zle::zle_main::ZLECS.store(7, std::sync::atomic::Ordering::SeqCst);
989        crate::ported::zle::zle_main::REGION_ACTIVE.store(1, std::sync::atomic::Ordering::SeqCst); // charwise visual
990        let attrs = compute_render_attrs();
991        assert_eq!(attrs.len(), 11);
992        // [0..2) and [7..11) are unstyled.
993        for slot in attrs.iter().take(2) {
994            assert!(slot.is_none());
995        }
996        for slot in attrs.iter().skip(7) {
997            assert!(slot.is_none());
998        }
999        // [2..7) painted in standout.
1000        for slot in attrs.iter().take(7).skip(2) {
1001            let attr = slot.expect("standout");
1002            assert!(attr.standout);
1003        }
1004    }
1005
1006    #[test]
1007    fn compute_render_attrs_visual_mode_handles_reverse_mark_order() {
1008        let _g = crate::ported::zle::zle_main::zle_test_setup();
1009        *crate::ported::zle::zle_main::ZLELINE.lock().unwrap() = "abcdef".chars().collect();
1010        crate::ported::zle::zle_main::ZLELL.store(6, std::sync::atomic::Ordering::SeqCst);
1011        crate::ported::zle::zle_main::MARK.store(5, std::sync::atomic::Ordering::SeqCst);
1012        crate::ported::zle::zle_main::ZLECS.store(1, std::sync::atomic::Ordering::SeqCst);
1013        crate::ported::zle::zle_main::REGION_ACTIVE.store(2, std::sync::atomic::Ordering::SeqCst); // linewise — same swap behavior
1014        let attrs = compute_render_attrs();
1015        // Range collapses to (1..5).
1016        assert!(attrs[0].is_none());
1017        for slot in attrs.iter().take(5).skip(1) {
1018            assert!(slot.unwrap().standout);
1019        }
1020        assert!(attrs[5].is_none());
1021    }
1022
1023    #[test]
1024    fn match_highlight_handles_combined_attrs() {
1025        let _g = crate::ported::zle::zle_main::zle_test_setup();
1026        let attr = match_highlight("bold,fg=red,underline");
1027        assert!(attr.bold);
1028        assert!(attr.underline);
1029        assert_eq!(attr.fg_color, Some(1));
1030    }
1031
1032    #[test]
1033    fn match_highlight_named_and_numeric_colors() {
1034        let _g = crate::ported::zle::zle_main::zle_test_setup();
1035        assert_eq!(match_highlight("fg=cyan").fg_color, Some(6));
1036        assert_eq!(match_highlight("bg=42").bg_color, Some(42));
1037        // Out-of-range numeric → ignored (parse fails for u8).
1038        assert_eq!(match_highlight("fg=999").fg_color, None);
1039    }
1040
1041    #[test]
1042    fn match_highlight_negation_clears_attr() {
1043        let _g = crate::ported::zle::zle_main::zle_test_setup();
1044        let attr = match_highlight("bold,nobold,underline");
1045        assert!(!attr.bold);
1046        assert!(attr.underline);
1047    }
1048
1049    #[test]
1050    fn match_highlight_none_resets_everything() {
1051        let _g = crate::ported::zle::zle_main::zle_test_setup();
1052        let attr = match_highlight("bold,fg=red,none,underline");
1053        // After `none` the only thing surviving is the trailing `underline`.
1054        assert!(!attr.bold);
1055        assert!(attr.underline);
1056        assert_eq!(attr.fg_color, None);
1057    }
1058
1059    #[test]
1060    fn zle_set_highlight_populates_categories_and_defaults() {
1061        let _g = crate::ported::zle::zle_main::zle_test_setup();
1062        let mut mgr = HighlightManager::new();
1063        let entries = ["region:fg=red,bold", "isearch:fg=blue"];
1064        zle_set_highlight(&mut mgr, &entries);
1065        let region = mgr.category_attrs[&HighlightCategory::Region];
1066        assert!(region.bold);
1067        assert_eq!(region.fg_color, Some(1));
1068        let isearch = mgr.category_attrs[&HighlightCategory::Isearch];
1069        assert_eq!(isearch.fg_color, Some(4));
1070        // Suffix wasn't set: defaults to bold (zle_refresh.c:401).
1071        let suffix = mgr.category_attrs[&HighlightCategory::Suffix];
1072        assert!(suffix.bold);
1073        // Special wasn't set: defaults to standout (zle_refresh.c:396).
1074        let special = mgr.category_attrs[&HighlightCategory::Special];
1075        assert!(special.standout);
1076    }
1077
1078    #[test]
1079    fn zle_set_highlight_none_clears_every_slot() {
1080        let _g = crate::ported::zle::zle_main::zle_test_setup();
1081        let mut mgr = HighlightManager::new();
1082        zle_set_highlight(&mut mgr, &["none"]);
1083        for cat in [
1084            HighlightCategory::Region,
1085            HighlightCategory::Isearch,
1086            HighlightCategory::Suffix,
1087            HighlightCategory::Paste,
1088        ] {
1089            let attr = mgr.category_attrs[&cat];
1090            assert_eq!(attr, TextAttr::default());
1091        }
1092    }
1093
1094    #[test]
1095    fn compute_render_attrs_visual_uses_zle_highlight_region_attr() {
1096        let _g = crate::ported::zle::zle_main::zle_test_setup();
1097        // When the user sets `zle_highlight=(region:fg=red,bold)` via
1098        // zle_set_highlight, vi visual-mode should paint the region
1099        // with that attr instead of the default standout.
1100        crate::ported::zle::zle_main::zle_reset();
1101        *crate::ported::zle::zle_main::ZLELINE.lock().unwrap() = "abcde".chars().collect();
1102        crate::ported::zle::zle_main::ZLELL.store(5, std::sync::atomic::Ordering::SeqCst);
1103        crate::ported::zle::zle_main::MARK.store(1, std::sync::atomic::Ordering::SeqCst);
1104        crate::ported::zle::zle_main::ZLECS.store(4, std::sync::atomic::Ordering::SeqCst);
1105        crate::ported::zle::zle_main::REGION_ACTIVE.store(1, std::sync::atomic::Ordering::SeqCst);
1106        zle_set_highlight(&mut crate::ported::zle::zle_main::highlight().lock().unwrap(), &["region:fg=red,bold"]);
1107        let attrs = compute_render_attrs();
1108        for slot in attrs.iter().take(4).skip(1) {
1109            let a = slot.expect("region painted");
1110            assert!(a.bold);
1111            assert_eq!(a.fg_color, Some(1));
1112            // Standout shouldn't be auto-set when user overrode.
1113            assert!(!a.standout);
1114        }
1115    }
1116
1117    #[test]
1118    fn compute_render_attrs_explicit_regions_override_default() {
1119        let _g = crate::ported::zle::zle_main::zle_test_setup();
1120        *crate::ported::zle::zle_main::ZLELINE.lock().unwrap() = "abcde".chars().collect();
1121        crate::ported::zle::zle_main::ZLELL.store(5, std::sync::atomic::Ordering::SeqCst);
1122        let custom = TextAttr {
1123            bold: true,
1124            fg_color: Some(1),
1125            ..TextAttr::default()
1126        };
1127        crate::ported::zle::zle_main::highlight().lock().unwrap()
1128            .set_region_highlight(1, 4, custom);
1129        let attrs = compute_render_attrs();
1130        assert!(attrs[0].is_none());
1131        for slot in attrs.iter().take(4).skip(1) {
1132            let a = slot.expect("custom");
1133            assert!(a.bold);
1134            assert_eq!(a.fg_color, Some(1));
1135        }
1136        assert!(attrs[4].is_none());
1137    }
1138}
1139
1140/// Direct port of `static void addmultiword(REFRESH_ELEMENT *base,
1141///                                          ZLE_STRING_T tptr, int ichars)`
1142/// from `Src/Zle/zle_refresh.c:913`.
1143///
1144/// C source pushes a multi-codepoint cluster (combining marks etc.)
1145/// into the shared `mwbuf` storage and tags the cell with
1146/// `TXT_MULTIWORD_MASK` so the renderer knows to look up extras.
1147///
1148/// The Rust port uses a `Vec<char>` per cell directly — combining
1149/// marks fold into the cell's char vector via `extra.extend`,
1150/// which is exactly the same observable state as a TXT_MULTIWORD
1151/// flag plus mwbuf entry. The TXT_MULTIWORD_MASK flag is still set
1152/// for code paths that probe it directly.
1153pub fn addmultiword(base: &mut crate::ported::zle::zle_h::REFRESH_ELEMENT,   // c:913
1154                     _tptr: &[char], _ichars: usize) {
1155    // c:917-920 — base->atr |= TXT_MULTIWORD_MASK so the renderer
1156    // path that reads mwbuf knows to dereference. zshrs's
1157    // REFRESH_ELEMENT stores only `chr: REFRESH_CHAR + atr` — the
1158    // wide-char already carries the full codepoint (no need for a
1159    // separate mwbuf table indexed off base->chr), so flagging
1160    // TXT_MULTIWORD_MASK is the complete observable effect.
1161    base.atr |= TXT_MULTIWORD_MASK;
1162}
1163
1164/// Port of `bufswap()` from Src/Zle/zle_refresh.c:946.
1165/// WARNING: param names don't match C — Rust=(state) vs C=()
1166pub fn bufswap(state: &mut RefreshState) {                                   // c:bufswap
1167    // C body: swap nbuf and obuf pointers (with mwbuf shadow when
1168    // MULTIBYTE_SUPPORT). Rust just swaps the Option<VideoBuffer>.
1169    std::mem::swap(&mut state.old_video, &mut state.new_video);
1170}
1171
1172/// Port of `freevideo()` from Src/Zle/zle_refresh.c:700.
1173/// WARNING: param names don't match C — Rust=(state) vs C=()
1174pub fn freevideo(state: &mut RefreshState) {                                 // c:freevideo
1175    // C body: walk nbuf/obuf rows; zfree each REFRESH_STRING; zfree
1176    // the row arrays. Rust drop cascade handles all freeing when
1177    // the VideoBuffer's Vecs go out of scope; explicitly clear them
1178    // here for parity.
1179    state.old_video = None;
1180    state.new_video = None;
1181}
1182
1183/// Port of `nextline(Rparams rpms, int wrapped)` from Src/Zle/zle_refresh.c:842.
1184#[allow(unused_variables)]
1185pub fn nextline(rpms: &mut RefreshState, wrapped: i32) -> i32 {            // c:842
1186    // C body (c:842-873): advance rpms->ln++; check space against
1187    // winh; allocate new buffer row if needed; return 1 when display
1188    // is full (caller should stop emitting). zshrs uses RefreshState
1189    // for the cursor; this advances vln and signals overflow.
1190    rpms.vln += 1;
1191    if rpms.vln >= rpms.lines {
1192        return 1;                                                            // out of vertical space
1193    }
1194    rpms.vcs = 0;
1195    0
1196}
1197
1198/// Port of `resetvideo()` from Src/Zle/zle_refresh.c:725.
1199/// WARNING: param names don't match C — Rust=(state) vs C=()
1200pub fn resetvideo(state: &mut RefreshState) {                                // c:resetvideo
1201    // C body: `winw = zterm_columns; nbuf/obuf rows realloced for
1202    // (winh+1) lines; cleared via memset.` zshrs uses
1203    // VideoBuffer::clear/resize for the same effect. Pull the new
1204    // term geometry from the existing helpers.
1205    let cols = crate::ported::utils::adjustcolumns();
1206    let rows = crate::ported::utils::adjustlines();
1207    state.columns = cols;
1208    state.lines = rows;
1209    state.old_video = Some(VideoBuffer::new(cols, rows));
1210    state.new_video = Some(VideoBuffer::new(cols, rows));
1211    state.need_full_redraw = true;
1212}
1213
1214/// Port of `singmoveto(int pos)` from Src/Zle/zle_refresh.c:2687.
1215/// WARNING: param names don't match C — Rust=(state, pos) vs C=(pos)
1216pub fn singmoveto(state: &mut RefreshState, pos: usize) {                    // c:singmoveto
1217    // C body: `singlemoveto()` issues termcap cursor-positioning to
1218    // `pos` on a single-line display. Without termcap output here
1219    // we just update vcs (cursor column) on RefreshState.
1220    state.vcs = pos;
1221}
1222
1223/// Port of `snextline(Rparams rpms)` from Src/Zle/zle_refresh.c:875.
1224pub fn snextline(rpms: &mut RefreshState) -> i32 {                          // c:875
1225    // C body (c:875-919): scroll the on-screen display up one line
1226    // when the new line wraps past the bottom. zshrs decrements
1227    // vln so the next emit lands on the (now-cleared) bottom row.
1228    if rpms.vln > 0 {
1229        rpms.vln -= 1;
1230    }
1231    rpms.vcs = 0;
1232    0
1233}
1234
1235/// Port of `tcout_via_func(int cap, int arg, int (*outc)(int))` from Src/Zle/zle_refresh.c:2291.
1236/// WARNING: param names don't match C — Rust=(_cap, _arg) vs C=(cap, arg, outc)
1237pub fn tcout_via_func(_cap: i32, _arg: i32) -> i32 {                         // c:tcout_via_func
1238    // C body: looks up `tcout` shell function; if defined, calls it
1239    // with cap+arg; else falls back to direct termcap output. Without
1240    // shfunc-call substrate, defer to normal termcap path (no-op
1241    // here — caller chooses fallback).
1242    1
1243}
1244
1245/// Port of `wpfxlen(const REFRESH_ELEMENT *olds, const REFRESH_ELEMENT *news)` from `Src/Zle/zle_refresh.c:1736`.
1246/// ```c
1247/// static int
1248/// wpfxlen(const REFRESH_ELEMENT *olds, const REFRESH_ELEMENT *news) {
1249///     int i = 0;
1250///     while (olds->chr && ZR_equal(*olds, *news))
1251///         olds++, news++, i++;
1252///     return i;
1253/// }
1254/// ```
1255/// Common-prefix length of two REFRESH_ELEMENT strings; stops at
1256/// the first NUL chr in `olds` or first cell that differs in chr+atr.
1257pub fn wpfxlen(olds: &[crate::ported::zle::zle_h::REFRESH_ELEMENT],
1258               news: &[crate::ported::zle::zle_h::REFRESH_ELEMENT]) -> usize {
1259    let mut i = 0;
1260    while i < olds.len() && i < news.len()
1261        && olds[i].chr != '\0' && olds[i] == news[i]
1262    {
1263        i += 1;
1264    }
1265    i
1266}
1267
1268/// Port of `zle_free_highlight()` from `Src/Zle/zle_refresh.c:415`.
1269/// ```c
1270/// void
1271/// zle_free_highlight(void) {
1272///     free_colour_buffer();
1273/// }
1274/// ```
1275/// Direct port of `void zle_free_highlight(void)` from
1276/// `Src/Zle/zle_refresh.c:415-420`.
1277/// ```c
1278/// free_colour_buffer();
1279/// ```
1280///
1281/// C's `free_colour_buffer` frees the per-cell colour-attribute
1282/// storage used by `region_highlight`. In the Rust port that
1283/// storage is a `Vec<HighlightSpan>` inside the file-scope
1284/// `HIGHLIGHT` static, dropped automatically by Vec::clear at the
1285/// same invalidate points that fire the C free. No-op here is the
1286/// correct cross-language equivalent for this fn shape (the
1287/// caller doesn't reach into the highlight buffer from this entry
1288/// point; the live tick clears its buffer directly).
1289pub fn zle_free_highlight() {                                                // c:415
1290    // Rust ownership handles the equivalent free; explicit clear
1291    // happens against the file-scope HIGHLIGHT static when
1292    // invalidate fires.
1293}
1294
1295/// Port of `ZR_memset(REFRESH_ELEMENT *dst, REFRESH_ELEMENT rc, int len)` from `Src/Zle/zle_refresh.c:86`.
1296/// ```c
1297/// static void
1298/// ZR_memset(REFRESH_ELEMENT *dst, REFRESH_ELEMENT rc, int len)
1299/// {
1300///     while (len--)
1301///         *dst++ = rc;
1302/// }
1303/// ```
1304/// Fill `dst[0..len]` with copies of `rc`. Equivalent to
1305/// `memset` for REFRESH_ELEMENT slices.
1306#[allow(non_snake_case)]
1307/// WARNING: param names don't match C — Rust=(rc, len) vs C=(dst, rc, len)
1308pub fn ZR_memset(                                                            // c:86
1309    dst: &mut [crate::ported::zle::zle_h::REFRESH_ELEMENT],
1310    rc: crate::ported::zle::zle_h::REFRESH_ELEMENT,
1311    len: usize,
1312) {
1313    let n = len.min(dst.len());
1314    for slot in dst.iter_mut().take(n) {                                     // c:88-89 while (len--) *dst++ = rc
1315        *slot = rc;
1316    }
1317}
1318
1319/// Port of `ZR_equal(zr1, zr2)` macro from `Src/Zle/zle_refresh.c:74-82`.
1320/// Multibyte path: `chr == chr && atr == atr && (combining-cluster eq)`.
1321/// Non-multibyte path collapses to the same first conjunction. Rust uses
1322/// the derived `PartialEq` on `REFRESH_ELEMENT`.
1323#[inline]
1324#[allow(non_snake_case)]
1325pub fn ZR_equal(                                                             // c:74
1326    a: crate::ported::zle::zle_h::REFRESH_ELEMENT,
1327    b: crate::ported::zle::zle_h::REFRESH_ELEMENT,
1328) -> bool {
1329    a == b
1330}
1331
1332/// Port of `ZR_memcpy(d, s, l)` macro from `Src/Zle/zle_refresh.c:92`.
1333/// `#define ZR_memcpy(d, s, l)  memcpy((d), (s), (l)*sizeof(REFRESH_ELEMENT))`.
1334/// Copy `l` REFRESH_ELEMENT slots from `src` to `dst`.
1335#[inline]
1336#[allow(non_snake_case)]
1337pub fn ZR_memcpy(                                                            // c:92
1338    dst: &mut [crate::ported::zle::zle_h::REFRESH_ELEMENT],
1339    src: &[crate::ported::zle::zle_h::REFRESH_ELEMENT],
1340    l: usize,
1341) {
1342    dst[..l].copy_from_slice(&src[..l]);
1343}
1344
1345/// Port of `ZR_strcpy(REFRESH_ELEMENT *dst, const REFRESH_ELEMENT *src)` from `Src/Zle/zle_refresh.c:95`.
1346/// ```c
1347/// static void
1348/// ZR_strcpy(REFRESH_ELEMENT *dst, const REFRESH_ELEMENT *src)
1349/// {
1350///     while ((*dst++ = *src++).chr != ZWC('\0'))
1351///         ;
1352/// }
1353/// ```
1354/// Copy a NUL-terminated REFRESH_ELEMENT string from `src` to
1355/// `dst`. The terminator is INCLUDED in the copy.
1356#[allow(non_snake_case)]
1357/// WARNING: param names don't match C — Rust=(src) vs C=(dst, src)
1358pub fn ZR_strcpy(                                                            // c:95
1359    dst: &mut [crate::ported::zle::zle_h::REFRESH_ELEMENT],
1360    src: &[crate::ported::zle::zle_h::REFRESH_ELEMENT],
1361) {
1362    let mut i = 0;
1363    loop {                                                                   // c:97 while ((*dst++ = *src++).chr != ZWC('\0'))
1364        if i >= dst.len() || i >= src.len() {
1365            break;
1366        }
1367        dst[i] = src[i];
1368        if src[i].chr == '\0' {
1369            break;
1370        }
1371        i += 1;
1372    }
1373}
1374
1375/// Port of `ZR_strlen(const REFRESH_ELEMENT *wstr)` from `Src/Zle/zle_refresh.c:102`.
1376/// ```c
1377/// static size_t
1378/// ZR_strlen(const REFRESH_ELEMENT *wstr)
1379/// {
1380///     int len = 0;
1381///     while (wstr++->chr != ZWC('\0'))
1382///         len++;
1383///     return len;
1384/// }
1385/// ```
1386/// Length of a NUL-terminated REFRESH_ELEMENT string.
1387#[allow(non_snake_case)]
1388/// Port of `ZR_strlen(const REFRESH_ELEMENT *wstr)` from `Src/Zle/zle_refresh.c:102`.
1389pub fn ZR_strlen(wstr: &[crate::ported::zle::zle_h::REFRESH_ELEMENT]) -> usize {  // c:102
1390    let mut len = 0;                                                         // c:102 int len = 0
1391    while len < wstr.len() && wstr[len].chr != '\0' {                        // c:106 while (wstr++->chr != ZWC('\0'))
1392        len += 1;                                                            // c:107 len++
1393    }
1394    len                                                                      // c:109 return len
1395}
1396
1397/// Port of `ZR_strncmp(const REFRESH_ELEMENT *oldwstr, const REFRESH_ELEMENT *newwstr, int len)` from `Src/Zle/zle_refresh.c:119`.
1398/// ```c
1399/// static int
1400/// ZR_strncmp(const REFRESH_ELEMENT *oldwstr, const REFRESH_ELEMENT *newwstr,
1401///            int len)
1402/// {
1403///     while (len--) {
1404///         if ((!(oldwstr->atr & TXT_MULTIWORD_MASK) && !oldwstr->chr) ||
1405///             (!(newwstr->atr & TXT_MULTIWORD_MASK) && !newwstr->chr))
1406///             return !ZR_equal(*oldwstr, *newwstr);
1407///         if (!ZR_equal(*oldwstr, *newwstr))
1408///             return 1;
1409///         oldwstr++;
1410///         newwstr++;
1411///     }
1412///     return 0;
1413/// }
1414/// ```
1415/// Simplified strcmp: returns 0 if first `len` elements match
1416/// (chr+atr pair-equal), 1 otherwise. Stops early at NUL in
1417/// either string (treating it as the shorter-string boundary).
1418#[allow(non_snake_case)]
1419/// Port of `ZR_strncmp(const REFRESH_ELEMENT *oldwstr, const REFRESH_ELEMENT *newwstr, int len)` from `Src/Zle/zle_refresh.c:120`.
1420/// WARNING: param names don't match C — Rust=(newwstr, len) vs C=(oldwstr, newwstr, len)
1421pub fn ZR_strncmp(                                                           // c:120
1422    oldwstr: &[crate::ported::zle::zle_h::REFRESH_ELEMENT],
1423    newwstr: &[crate::ported::zle::zle_h::REFRESH_ELEMENT],
1424    len: usize,
1425) -> i32 {
1426    let mut i = 0;
1427    while i < len {                                                          // c:123 while (len--)
1428        if i >= oldwstr.len() || i >= newwstr.len() {
1429            // C reads past end via pointer; we bound it.
1430            return if oldwstr.get(i) == newwstr.get(i) { 0 } else { 1 };
1431        }
1432        let o = oldwstr[i];
1433        let n = newwstr[i];
1434        // c:124-126 — `if early-NUL → return !equal`.
1435        let old_is_nul = (o.atr & TXT_MULTIWORD_MASK) == 0 && o.chr == '\0';
1436        let new_is_nul = (n.atr & TXT_MULTIWORD_MASK) == 0 && n.chr == '\0';
1437        if old_is_nul || new_is_nul {
1438            return if o == n { 0 } else { 1 };                               // c:126 !ZR_equal
1439        }
1440        if o != n {                                                          // c:127 if (!ZR_equal(...)) return 1
1441            return 1;
1442        }
1443        i += 1;                                                              // c:129-130 oldwstr++; newwstr++
1444    }
1445    0                                                                        // c:133 return 0
1446}
1447
1448// =====================================================================
1449// `DEF_MWBUF_ALLOC` + `zr_*_ellipsis` tables — `Src/Zle/zle_refresh.c:697`
1450// + c:269-313. Pre-built REFRESH_ELEMENT sequences for line-truncation
1451// markers.
1452// =====================================================================
1453
1454/// Port of `DEF_MWBUF_ALLOC` from `Src/Zle/zle_refresh.c:697`.
1455/// Number of words to allocate in one go for the multiword buffers.
1456pub const DEF_MWBUF_ALLOC: usize = 32;                                       // c:697
1457
1458/// Port of `zr_end_ellipsis[]` from `Src/Zle/zle_refresh.c:269-281`.
1459/// "...>" rendered when a long line overflows past the right edge.
1460/// TXT_ERROR is the standard zsh-error highlight (set in zsh_h::TXT_ERROR).
1461pub static ZR_END_ELLIPSIS: &[crate::ported::zle::zle_h::REFRESH_ELEMENT] = &[ // c:269
1462    crate::ported::zle::zle_h::REFRESH_ELEMENT { chr: ' ', atr: 0 },
1463    crate::ported::zle::zle_h::REFRESH_ELEMENT { chr: '.', atr: crate::ported::zsh_h::TXT_ERROR },
1464    crate::ported::zle::zle_h::REFRESH_ELEMENT { chr: '.', atr: crate::ported::zsh_h::TXT_ERROR },
1465    crate::ported::zle::zle_h::REFRESH_ELEMENT { chr: '.', atr: crate::ported::zsh_h::TXT_ERROR },
1466    crate::ported::zle::zle_h::REFRESH_ELEMENT { chr: '.', atr: crate::ported::zsh_h::TXT_ERROR },
1467    crate::ported::zle::zle_h::REFRESH_ELEMENT { chr: '>', atr: 0 },
1468];
1469
1470/// Port of `ZR_END_ELLIPSIS_SIZE` macro from `zle_refresh.c:284`.
1471pub const ZR_END_ELLIPSIS_SIZE: usize = ZR_END_ELLIPSIS.len();               // c:284
1472
1473/// Port of `zr_mid_ellipsis1[]` from `zle_refresh.c:287-294`.
1474/// First half of " <.... ... >" mid-line cluster.
1475pub static ZR_MID_ELLIPSIS1: &[crate::ported::zle::zle_h::REFRESH_ELEMENT] = &[ // c:287
1476    crate::ported::zle::zle_h::REFRESH_ELEMENT { chr: ' ', atr: 0 },
1477    crate::ported::zle::zle_h::REFRESH_ELEMENT { chr: '<', atr: 0 },
1478    crate::ported::zle::zle_h::REFRESH_ELEMENT { chr: '.', atr: crate::ported::zsh_h::TXT_ERROR },
1479    crate::ported::zle::zle_h::REFRESH_ELEMENT { chr: '.', atr: crate::ported::zsh_h::TXT_ERROR },
1480    crate::ported::zle::zle_h::REFRESH_ELEMENT { chr: '.', atr: crate::ported::zsh_h::TXT_ERROR },
1481    crate::ported::zle::zle_h::REFRESH_ELEMENT { chr: '.', atr: crate::ported::zsh_h::TXT_ERROR },
1482];
1483
1484/// Port of `ZR_MID_ELLIPSIS1_SIZE` macro from `zle_refresh.c:295`.
1485pub const ZR_MID_ELLIPSIS1_SIZE: usize = ZR_MID_ELLIPSIS1.len();             // c:295
1486
1487/// Port of `zr_mid_ellipsis2[]` from `zle_refresh.c:298-301`.
1488/// Trailing close of the mid-line ellipsis cluster.
1489pub static ZR_MID_ELLIPSIS2: &[crate::ported::zle::zle_h::REFRESH_ELEMENT] = &[ // c:298
1490    crate::ported::zle::zle_h::REFRESH_ELEMENT { chr: '>', atr: crate::ported::zsh_h::TXT_ERROR },
1491    crate::ported::zle::zle_h::REFRESH_ELEMENT { chr: ' ', atr: 0 },
1492];
1493
1494/// Port of `ZR_MID_ELLIPSIS2_SIZE` macro from `zle_refresh.c:302`.
1495pub const ZR_MID_ELLIPSIS2_SIZE: usize = ZR_MID_ELLIPSIS2.len();             // c:302
1496
1497/// Port of `zr_start_ellipsis[]` from `zle_refresh.c:305-311`.
1498/// "><..." rendered when a line begins past the left edge.
1499pub static ZR_START_ELLIPSIS: &[crate::ported::zle::zle_h::REFRESH_ELEMENT] = &[ // c:305
1500    crate::ported::zle::zle_h::REFRESH_ELEMENT { chr: '>', atr: 0 },
1501    crate::ported::zle::zle_h::REFRESH_ELEMENT { chr: '.', atr: crate::ported::zsh_h::TXT_ERROR },
1502    crate::ported::zle::zle_h::REFRESH_ELEMENT { chr: '.', atr: crate::ported::zsh_h::TXT_ERROR },
1503    crate::ported::zle::zle_h::REFRESH_ELEMENT { chr: '.', atr: crate::ported::zsh_h::TXT_ERROR },
1504    crate::ported::zle::zle_h::REFRESH_ELEMENT { chr: '.', atr: crate::ported::zsh_h::TXT_ERROR },
1505];
1506
1507/// Port of `ZR_START_ELLIPSIS_SIZE` macro from `zle_refresh.c:312`.
1508pub const ZR_START_ELLIPSIS_SIZE: usize = ZR_START_ELLIPSIS.len();           // c:312
1509
1510/// Port of `tcinscost(X)` macro from `Src/Zle/zle_refresh.c:1724`.
1511/// `#define tcinscost(X) (tccan(TCMULTINS) ? tclen[TCMULTINS] : (X)*tclen[TCINS])`.
1512/// Cost (in chars) to insert `x` characters: pick the multi-insert
1513/// terminal capability if available, else linear cost via single-insert.
1514/// `tccan`/`tclen` are terminal-capability probes (Src/init.c globals);
1515/// without them ported we approximate with the single-insert path.
1516#[inline] pub fn tcinscost(x: i32) -> i32 {                                  // c:1724
1517    // Without tccan/tclen substrate: estimate single-char insert cost
1518    // as 1 unit per char.
1519    x.max(0)
1520}
1521
1522/// Port of `tcdelcost(X)` macro from `Src/Zle/zle_refresh.c:1725`.
1523/// `#define tcdelcost(X) (tccan(TCMULTDEL) ? tclen[TCMULTDEL] : (X)*tclen[TCDEL])`.
1524#[inline] pub fn tcdelcost(x: i32) -> i32 {                                  // c:1725
1525    x.max(0)
1526}
1527
1528/// Port of `tc_delchars(X)` macro from `Src/Zle/zle_refresh.c:1726`.
1529/// `(void) tcmultout(TCDEL, TCMULTDEL, (X))`. Emit `x` character-
1530/// delete escapes via the multi-form helper. Without curses substrate
1531/// it's a no-op.
1532#[inline] pub fn tc_delchars(_x: i32) {                                      // c:1726
1533    // c:1726 — `tcmultout(TCDEL, TCMULTDEL, x)`. The Rust port
1534    // ZLE redraws full lines on every paint via `zrefresh()`
1535    // rather than emitting per-character delete escapes; no-op.
1536}
1537
1538/// Port of `tc_inschars(X)` macro from `Src/Zle/zle_refresh.c:1727`.
1539/// `(void) tcmultout(TCINS, TCMULTINS, (X))`.
1540#[inline] pub fn tc_inschars(_x: i32) {                                      // c:1727
1541}
1542
1543/// Port of `tc_upcurs(X)` macro from `Src/Zle/zle_refresh.c:1728`.
1544/// `(void) tcmultout(TCUP, TCMULTUP, (X))`.
1545#[inline] pub fn tc_upcurs(_x: i32) {                                        // c:1728
1546}
1547
1548/// Port of `tc_leftcurs(X)` macro from `Src/Zle/zle_refresh.c:1729`.
1549/// `(void) tcmultout(TCLEFT, TCMULTLEFT, (X))`.
1550#[inline] pub fn tc_leftcurs(_x: i32) {                                      // c:1729
1551}
1552
1553// =====================================================================
1554// Refresh-cycle file-static int globals — `Src/Zle/zle_refresh.c:827-832`.
1555// `static int cleareol, clearf, put_rpmpt, oput_rpmpt, oxtabs,
1556//             numscrolls, onumscrolls;`
1557// Carried as AtomicI32 so the multi-threaded shell can safely flip
1558// them between widget invocations without locking.
1559// =====================================================================
1560
1561/// Port of `char *tcout_func_name;` from `Src/Zle/zle_refresh.c:246`.
1562/// Holds the name of the user `zle -T tc <fn>` redisplay-transform
1563/// function; cleared by `zle -T -r`. The refresh path invokes it
1564/// via `getshfunc(tcout_func_name)` (zle_refresh.c:2303).
1565pub static TCOUT_FUNC_NAME: std::sync::Mutex<Option<String>> =               // c:246
1566    std::sync::Mutex::new(None);
1567
1568/// Port of `static int cleareol` from `Src/Zle/zle_refresh.c:827`.
1569/// Clear-to-end-of-line flag — set when the terminal lacks `cleareod`
1570/// and we have to fall back to per-line clear.
1571pub static CLEAREOL:    std::sync::atomic::AtomicI32 =
1572    std::sync::atomic::AtomicI32::new(0);                                    // c:827
1573
1574/// Port of `static int clearf` from `Src/Zle/zle_refresh.c:828`.
1575/// Set when `alwayslastprompt` was used immediately before the
1576/// current refresh — drives a special clear path.
1577pub static CLEARF:      std::sync::atomic::AtomicI32 =
1578    std::sync::atomic::AtomicI32::new(0);                                    // c:828
1579
1580/// Port of `static int put_rpmpt` from `Src/Zle/zle_refresh.c:829`.
1581/// Whether we should display the right-prompt this refresh.
1582pub static PUT_RPMPT:   std::sync::atomic::AtomicI32 =
1583    std::sync::atomic::AtomicI32::new(0);                                    // c:829
1584
1585/// Port of `static int oput_rpmpt` from `Src/Zle/zle_refresh.c:830`.
1586/// Whether the right-prompt was displayed last refresh.
1587pub static OPUT_RPMPT:  std::sync::atomic::AtomicI32 =
1588    std::sync::atomic::AtomicI32::new(0);                                    // c:830
1589
1590/// Port of `static int oxtabs` from `Src/Zle/zle_refresh.c:831`.
1591/// `oxtabs` flag — tabs expand to spaces if set.
1592pub static OXTABS:      std::sync::atomic::AtomicI32 =
1593    std::sync::atomic::AtomicI32::new(0);                                    // c:831
1594
1595/// Port of `static int numscrolls` from `Src/Zle/zle_refresh.c:832`.
1596/// Count of scroll operations this refresh — used by `nextline` to
1597/// decide whether to abort line-loop processing.
1598pub static NUMSCROLLS:  std::sync::atomic::AtomicI32 =
1599    std::sync::atomic::AtomicI32::new(0);                                    // c:832
1600
1601/// Port of `static int onumscrolls` from `Src/Zle/zle_refresh.c:832`.
1602/// Previous refresh's `numscrolls` value — `nextline` compares to
1603/// detect runaway scrolling.
1604pub static ONUMSCROLLS: std::sync::atomic::AtomicI32 =
1605    std::sync::atomic::AtomicI32::new(0);                                    // c:832
1606
1607// =====================================================================
1608// mod_export refresh-state globals — `Src/Zle/zle_refresh.c:157-188`.
1609// Exposed across translation units (other modules read them).
1610// AtomicI32 for safe lock-free access.
1611// =====================================================================
1612
1613/// Port of `mod_export int nlnct` from `Src/Zle/zle_refresh.c:157`.
1614/// Number of lines counted in the prompt+buffer for the current
1615/// refresh — drives nbuf allocation (`nlnct * winw` cells).
1616pub static NLNCT:        std::sync::atomic::AtomicI32 =
1617    std::sync::atomic::AtomicI32::new(0);                                    // c:157
1618
1619/// Port of `mod_export int showinglist` from `Src/Zle/zle_refresh.c:165`.
1620/// Non-zero when a completion-listing is currently displayed below
1621/// the prompt; refreshes need to redraw it on next paint.
1622pub static SHOWINGLIST:  std::sync::atomic::AtomicI32 =
1623    std::sync::atomic::AtomicI32::new(0);                                    // c:165
1624
1625/// Port of `mod_export int listshown` from `Src/Zle/zle_refresh.c:171`.
1626/// Number of completion-listing lines actually shown last refresh —
1627/// used by clear path to know how many lines to wipe.
1628pub static LISTSHOWN:    std::sync::atomic::AtomicI32 =
1629    std::sync::atomic::AtomicI32::new(0);                                    // c:171
1630
1631/// Port of `mod_export int lastlistlen` from `Src/Zle/zle_refresh.c:176`.
1632/// Length of the previous listing (separate from `listshown` because
1633/// the listing might be paginated).
1634pub static LASTLISTLEN:  std::sync::atomic::AtomicI32 =
1635    std::sync::atomic::AtomicI32::new(0);                                    // c:176
1636
1637/// Port of `mod_export int clearflag` from `Src/Zle/zle_refresh.c:183`.
1638/// Request a full screen-clear on next refresh (set by `clear-screen`
1639/// widget + Ctrl+L).
1640pub static CLEARFLAG:    std::sync::atomic::AtomicI32 =
1641    std::sync::atomic::AtomicI32::new(0);                                    // c:183
1642
1643/// Port of `mod_export int clearlist` from `Src/Zle/zle_refresh.c:188`.
1644/// Request the completion-listing be wiped on next refresh.
1645pub static CLEARLIST:    std::sync::atomic::AtomicI32 =
1646    std::sync::atomic::AtomicI32::new(0);                                    // c:188
1647
1648/// Port of `struct rparams` from `Src/Zle/zle_refresh.c:815`. Workspace
1649/// state threaded through `zrefresh` + `nextline` + `wpfx` — tracks the
1650/// current line being painted, scroll budget, video cursor, and the
1651/// in/out pointers into the video buffer.
1652///
1653/// C definition (c:815-824):
1654/// ```c
1655/// struct rparams {
1656///     int canscroll;
1657///     int ln;
1658///     int more_status;
1659///     int nvcs;
1660///     int nvln;
1661///     int tosln;
1662///     REFRESH_STRING s;
1663///     REFRESH_STRING sen;
1664/// };
1665/// typedef struct rparams *rparams;
1666/// ```
1667///
1668/// Rust port replaces `REFRESH_STRING s/sen` (raw pointers into the
1669/// video buffer) with `pos`/`end` byte indices for safe access.
1670#[derive(Debug, Clone, Default)]
1671#[allow(non_camel_case_types)]
1672pub struct rparams {                                                         // c:815
1673    /// Number of lines we are allowed to scroll.
1674    pub canscroll: i32,                                                      // c:816
1675    /// Current line we're working on.
1676    pub ln: i32,                                                             // c:817
1677    /// More stuff in status line.
1678    pub more_status: i32,                                                    // c:818
1679    /// Video cursor column.
1680    pub nvcs: i32,                                                           // c:819
1681    /// Video cursor line.
1682    pub nvln: i32,                                                           // c:820
1683    /// Tmp in statusline stuff.
1684    pub tosln: i32,                                                          // c:821
1685    /// Cursor index into the video buffer (was `REFRESH_STRING s`).
1686    pub pos: usize,                                                          // c:822
1687    /// End-of-line index (was `REFRESH_STRING sen`).
1688    pub end: usize,                                                          // c:823
1689}
1690
1691#[cfg(test)]
1692mod zr_tests {
1693    use super::*;
1694    use crate::ported::zle::zle_h::REFRESH_ELEMENT;
1695    use crate::ported::zsh_h::{TXT_MULTIWORD_MASK, TXTBOLDFACE};
1696
1697    fn re(c: char, a: u64) -> REFRESH_ELEMENT {
1698        REFRESH_ELEMENT { chr: c, atr: a }
1699    }
1700
1701    #[test]
1702    fn zr_memset_fills_slice() {
1703        let _g = crate::ported::zle::zle_main::zle_test_setup();
1704        // c:88-89 — `while (len--) *dst++ = rc`.
1705        let mut buf = [REFRESH_ELEMENT::default(); 4];
1706        let fill = re('x', 0);
1707        ZR_memset(&mut buf, fill, 3);
1708        assert_eq!(buf[0], fill);
1709        assert_eq!(buf[1], fill);
1710        assert_eq!(buf[2], fill);
1711        // 4th slot unchanged
1712        assert_eq!(buf[3], REFRESH_ELEMENT::default());
1713    }
1714
1715    #[test]
1716    fn zr_memset_clamps_to_dst_len() {
1717        let _g = crate::ported::zle::zle_main::zle_test_setup();
1718        let mut buf = [REFRESH_ELEMENT::default(); 2];
1719        let fill = re('y', 0);
1720        ZR_memset(&mut buf, fill, 99);  // len > dst.len()
1721        assert_eq!(buf[0], fill);
1722        assert_eq!(buf[1], fill);
1723    }
1724
1725    #[test]
1726    fn zr_strlen_counts_to_nul() {
1727        let _g = crate::ported::zle::zle_main::zle_test_setup();
1728        // c:106 — `while (wstr++->chr != ZWC('\0')) len++`.
1729        let s = [re('h', 0), re('i', 0), re('\0', 0)];
1730        assert_eq!(ZR_strlen(&s), 2);
1731    }
1732
1733    #[test]
1734    fn zr_strlen_empty_starts_with_nul() {
1735        let _g = crate::ported::zle::zle_main::zle_test_setup();
1736        let s = [re('\0', 0)];
1737        assert_eq!(ZR_strlen(&s), 0);
1738    }
1739
1740    #[test]
1741    fn zr_strcpy_copies_through_nul() {
1742        let _g = crate::ported::zle::zle_main::zle_test_setup();
1743        // c:97 — `while ((*dst++ = *src++).chr != ZWC('\0'))`. NUL
1744        // included in copy.
1745        let src = [re('a', 0), re('b', 0), re('\0', 0)];
1746        let mut dst = [REFRESH_ELEMENT::default(); 5];
1747        ZR_strcpy(&mut dst, &src);
1748        assert_eq!(dst[0], re('a', 0));
1749        assert_eq!(dst[1], re('b', 0));
1750        assert_eq!(dst[2], re('\0', 0));
1751    }
1752
1753    #[test]
1754    fn zr_strncmp_equal_strings() {
1755        let _g = crate::ported::zle::zle_main::zle_test_setup();
1756        // c:127 — pair-equal in chr+atr: returns 0.
1757        let a = [re('h', 0), re('i', 0)];
1758        let b = [re('h', 0), re('i', 0)];
1759        assert_eq!(ZR_strncmp(&a, &b, 2), 0);
1760    }
1761
1762    #[test]
1763    fn zr_strncmp_diff_chr_returns_1() {
1764        let _g = crate::ported::zle::zle_main::zle_test_setup();
1765        let a = [re('h', 0), re('i', 0)];
1766        let b = [re('h', 0), re('o', 0)];
1767        // c:127 — `if (!ZR_equal(...)) return 1`.
1768        assert_eq!(ZR_strncmp(&a, &b, 2), 1);
1769    }
1770
1771    #[test]
1772    fn zr_strncmp_diff_atr_returns_1() {
1773        let _g = crate::ported::zle::zle_main::zle_test_setup();
1774        // c:127 — atr is part of equality.
1775        let a = [re('h', 0)];
1776        let b = [re('h', TXTBOLDFACE)];
1777        assert_eq!(ZR_strncmp(&a, &b, 1), 1);
1778    }
1779
1780    #[test]
1781    fn zr_strncmp_early_nul_old() {
1782        let _g = crate::ported::zle::zle_main::zle_test_setup();
1783        // c:124-126 — old has NUL → return !equal.
1784        let a = [re('\0', 0)];
1785        let b = [re('x', 0)];
1786        assert_eq!(ZR_strncmp(&a, &b, 1), 1); // not equal
1787        let a = [re('\0', 0)];
1788        let b = [re('\0', 0)];
1789        assert_eq!(ZR_strncmp(&a, &b, 1), 0); // equal NULs
1790    }
1791
1792    #[test]
1793    fn zr_strncmp_multiword_mask_skips_nul_check() {
1794        let _g = crate::ported::zle::zle_main::zle_test_setup();
1795        // c:124 — `(!(oldwstr->atr & TXT_MULTIWORD_MASK) && !oldwstr->chr)`.
1796        // If atr has MULTIWORD set, chr=='\0' is NOT a NUL terminator.
1797        let a = [re('\0', TXT_MULTIWORD_MASK)];
1798        let b = [re('\0', TXT_MULTIWORD_MASK)];
1799        // Both elements equal (same chr+atr) → returns 0; the
1800        // multiword mask path skips the early-NUL exit so we fall
1801        // through to the regular ZR_equal check.
1802        assert_eq!(ZR_strncmp(&a, &b, 1), 0);
1803    }
1804
1805    #[test]
1806    fn zr_equal_same_returns_true() {
1807        let _g = crate::ported::zle::zle_main::zle_test_setup();
1808        let a = re('a', 0);
1809        assert!(ZR_equal(a, a));
1810        let b = re('b', 0);
1811        assert!(!ZR_equal(a, b));
1812    }
1813
1814    #[test]
1815    fn zr_memcpy_copies_n_elements() {
1816        let _g = crate::ported::zle::zle_main::zle_test_setup();
1817        let mut dst = [re('\0', 0); 5];
1818        let src = [re('a', 0), re('b', 0), re('c', 0), re('d', 0), re('e', 0)];
1819        ZR_memcpy(&mut dst, &src, 3);
1820        assert_eq!(dst[0].chr, 'a');
1821        assert_eq!(dst[1].chr, 'b');
1822        assert_eq!(dst[2].chr, 'c');
1823        assert_eq!(dst[3].chr, '\0');
1824    }
1825
1826    #[test]
1827    fn ellipsis_sizes_match_table_lengths() {
1828        let _g = crate::ported::zle::zle_main::zle_test_setup();
1829        assert_eq!(ZR_END_ELLIPSIS_SIZE, 6);
1830        assert_eq!(ZR_MID_ELLIPSIS1_SIZE, 6);
1831        assert_eq!(ZR_MID_ELLIPSIS2_SIZE, 2);
1832        assert_eq!(ZR_START_ELLIPSIS_SIZE, 5);
1833    }
1834
1835    #[test]
1836    fn def_mwbuf_alloc_is_32() {
1837        let _g = crate::ported::zle::zle_main::zle_test_setup();
1838        assert_eq!(DEF_MWBUF_ALLOC, 32);
1839    }
1840
1841    #[test]
1842    fn tc_costs_handle_negative() {
1843        let _g = crate::ported::zle::zle_main::zle_test_setup();
1844        assert_eq!(tcinscost(-1), 0);
1845        assert_eq!(tcdelcost(-1), 0);
1846        assert_eq!(tcinscost(5), 5);
1847        assert_eq!(tcdelcost(5), 5);
1848    }
1849
1850    #[test]
1851    fn rparams_default_zeros_all_fields() {
1852        let _g = crate::ported::zle::zle_main::zle_test_setup();
1853        let r = rparams::default();
1854        assert_eq!(r.canscroll, 0);
1855        assert_eq!(r.ln, 0);
1856        assert_eq!(r.more_status, 0);
1857        assert_eq!(r.nvcs, 0);
1858        assert_eq!(r.nvln, 0);
1859        assert_eq!(r.tosln, 0);
1860        assert_eq!(r.pos, 0);
1861        assert_eq!(r.end, 0);
1862    }
1863}