use std::io::{self, Write};
use super::main::Zle;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct TextAttr {
pub bold: bool,
pub underline: bool,
pub standout: bool,
pub blink: bool,
pub fg_color: Option<u8>,
pub bg_color: Option<u8>,
}
impl TextAttr {
pub fn to_ansi(&self) -> String {
let mut codes = Vec::new();
if self.bold {
codes.push("1".to_string());
}
if self.underline {
codes.push("4".to_string());
}
if self.standout {
codes.push("7".to_string());
}
if self.blink {
codes.push("5".to_string());
}
if let Some(fg) = self.fg_color {
codes.push(format!("38;5;{}", fg));
}
if let Some(bg) = self.bg_color {
codes.push(format!("48;5;{}", bg));
}
if codes.is_empty() {
String::new()
} else {
format!("\x1b[{}m", codes.join(";"))
}
}
}
#[derive(Debug, Clone, Default)]
pub struct RefreshElement {
pub chr: char,
pub atr: TextAttr,
pub width: u8,
}
impl RefreshElement {
pub fn new(chr: char) -> Self {
let width = unicode_width::UnicodeWidthChar::width(chr).unwrap_or(1) as u8;
RefreshElement {
chr,
atr: TextAttr::default(),
width,
}
}
pub fn with_attr(chr: char, atr: TextAttr) -> Self {
let width = unicode_width::UnicodeWidthChar::width(chr).unwrap_or(1) as u8;
RefreshElement { chr, atr, width }
}
}
#[derive(Debug, Clone)]
pub struct VideoBuffer {
pub lines: Vec<Vec<RefreshElement>>,
pub cols: usize,
pub rows: usize,
}
impl VideoBuffer {
pub fn new(cols: usize, rows: usize) -> Self {
let lines = vec![vec![RefreshElement::new(' '); cols]; rows];
VideoBuffer { lines, cols, rows }
}
pub fn clear(&mut self) {
for line in &mut self.lines {
for elem in line.iter_mut() {
*elem = RefreshElement::new(' ');
}
}
}
pub fn resize(&mut self, cols: usize, rows: usize) {
self.cols = cols;
self.rows = rows;
self.lines
.resize(rows, vec![RefreshElement::new(' '); cols]);
for line in &mut self.lines {
line.resize(cols, RefreshElement::new(' '));
}
}
pub fn set(&mut self, row: usize, col: usize, elem: RefreshElement) {
if row < self.rows && col < self.cols {
self.lines[row][col] = elem;
}
}
pub fn get(&self, row: usize, col: usize) -> Option<&RefreshElement> {
self.lines.get(row).and_then(|line| line.get(col))
}
}
#[derive(Debug, Clone, Default)]
pub struct RefreshState {
pub columns: usize,
pub lines: usize,
pub vln: usize,
pub vcs: usize,
pub lpromptw: usize,
pub rpromptw: usize,
pub scrolloff: usize,
pub region_highlight_start: Option<usize>,
pub region_highlight_end: Option<usize>,
pub old_video: Option<VideoBuffer>,
pub new_video: Option<VideoBuffer>,
pub lpromptbuf: String,
pub rpromptbuf: String,
pub need_full_redraw: bool,
pub predisplay: String,
pub postdisplay: String,
}
impl RefreshState {
pub fn new() -> Self {
let (cols, rows) = get_terminal_size();
RefreshState {
columns: cols,
lines: rows,
old_video: Some(VideoBuffer::new(cols, rows)),
new_video: Some(VideoBuffer::new(cols, rows)),
need_full_redraw: true,
..Default::default()
}
}
pub fn reset_video(&mut self) {
let (cols, rows) = get_terminal_size();
self.columns = cols;
self.lines = rows;
self.old_video = Some(VideoBuffer::new(cols, rows));
self.new_video = Some(VideoBuffer::new(cols, rows));
self.need_full_redraw = true;
}
pub fn free_video(&mut self) {
self.old_video = None;
self.new_video = None;
}
pub fn swap_buffers(&mut self) {
std::mem::swap(&mut self.old_video, &mut self.new_video);
if let Some(ref mut new) = self.new_video {
new.clear();
}
}
}
impl Zle {
pub fn zrefresh(&mut self) {
let stdout = io::stdout();
let mut handle = stdout.lock();
let (cols, _rows) = get_terminal_size();
let prompt = self.prompt().to_string();
let rprompt = self.rprompt().to_string();
let cursor = self.zlecs;
let prompt_width = visible_width(&prompt);
let rprompt_width = visible_width(&rprompt);
let buffer_before_cursor: String = self.zleline[..cursor.min(self.zleline.len())]
.iter()
.collect();
let cursor_col = prompt_width + visible_width(&buffer_before_cursor);
let scroll_margin = 8;
let effective_cols = cols.saturating_sub(1);
let scroll_offset = if cursor_col >= effective_cols.saturating_sub(scroll_margin) {
cursor_col.saturating_sub(effective_cols / 2)
} else {
0
};
let attrs = self.compute_render_attrs();
let _ = write!(handle, "\r\x1b[K");
if scroll_offset < prompt_width {
let visible_prompt = skip_chars(&prompt, scroll_offset);
let _ = write!(handle, "{}", visible_prompt);
}
let buffer_start = scroll_offset.saturating_sub(prompt_width);
let drawn_prompt_width = prompt_width.saturating_sub(scroll_offset);
let rprompt_reserve = if rprompt_width > 0 {
rprompt_width + 1
} else {
0
};
let buffer_budget = effective_cols
.saturating_sub(drawn_prompt_width)
.saturating_sub(rprompt_reserve);
let mut current_attr: Option<TextAttr> = None;
for (written, (idx, ch)) in self
.zleline
.iter()
.enumerate()
.skip(buffer_start)
.enumerate()
{
if written >= buffer_budget {
break;
}
let want_attr = attrs.get(idx).and_then(|a| *a);
if want_attr != current_attr {
let _ = write!(handle, "\x1b[0m");
if let Some(a) = want_attr {
let _ = write!(handle, "{}", a.to_ansi());
}
current_attr = want_attr;
}
let _ = write!(handle, "{}", ch);
}
if current_attr.is_some() {
let _ = write!(handle, "\x1b[0m");
}
if rprompt_width > 0 && rprompt_width + 2 < effective_cols {
let rprompt_col = effective_cols.saturating_sub(rprompt_width);
let _ = write!(handle, "\r\x1b[{}C{}\x1b[0m", rprompt_col, rprompt);
}
let display_cursor_col = cursor_col.saturating_sub(scroll_offset);
let _ = write!(handle, "\r\x1b[{}C", display_cursor_col);
let _ = handle.flush();
}
pub fn compute_render_attrs(&self) -> Vec<Option<TextAttr>> {
let buf_len = self.zleline.len();
let mut attrs: Vec<Option<TextAttr>> = vec![None; buf_len];
let visual_attr = self
.highlight
.category_attrs
.get(&HighlightCategory::Region)
.copied()
.unwrap_or(TextAttr {
standout: true,
..TextAttr::default()
});
if self.region_active != 0 {
let (lo, hi) = if self.mark <= self.zlecs {
(self.mark, self.zlecs)
} else {
(self.zlecs, self.mark)
};
let lo = lo.min(buf_len);
let hi = hi.min(buf_len);
for slot in attrs.iter_mut().take(hi).skip(lo) {
*slot = Some(visual_attr);
}
}
for region in &self.highlight.regions {
let start = region.start.min(buf_len);
let end = region.end.min(buf_len);
for slot in attrs.iter_mut().take(end).skip(start) {
*slot = Some(region.attr);
}
}
attrs
}
pub fn full_refresh(&mut self) -> io::Result<()> {
print!("\x1b[2J\x1b[H");
self.zrefresh();
io::stdout().flush()
}
pub fn partial_refresh(&mut self) -> io::Result<()> {
self.zrefresh();
io::stdout().flush()
}
pub fn clearscreen(&mut self) {
print!("\x1b[2J\x1b[H");
let _ = io::stdout().flush();
self.zrefresh();
}
pub fn redisplay(&mut self) {
self.zrefresh();
}
pub fn moveto(&mut self, row: usize, col: usize) {
print!("\x1b[{};{}H", row + 1, col + 1);
let _ = io::stdout().flush();
}
pub fn tc_downcurs(&mut self, count: usize) {
if count > 0 {
print!("\x1b[{}B", count);
let _ = io::stdout().flush();
}
}
pub fn tc_rightcurs(&mut self, count: usize) {
if count > 0 {
print!("\x1b[{}C", count);
let _ = io::stdout().flush();
}
}
pub fn scrollwindow(&mut self, lines: i32) {
if lines > 0 {
print!("\x1b[{}S", lines);
} else if lines < 0 {
print!("\x1b[{}T", -lines);
}
let _ = io::stdout().flush();
}
pub fn singlerefresh(&mut self) {
self.zrefresh();
}
pub fn refreshline(&mut self, _line: usize) {
self.zrefresh();
}
pub fn zwcputc(&self, c: char) {
print!("{}", c);
}
pub fn zwcwrite(&self, s: &str) {
print!("{}", s);
}
}
pub fn get_terminal_size() -> (usize, usize) {
unsafe {
let mut ws: libc::winsize = std::mem::zeroed();
if libc::ioctl(0, libc::TIOCGWINSZ, &mut ws) == 0 {
(ws.ws_col as usize, ws.ws_row as usize)
} else {
(80, 24) }
}
}
fn visible_width(s: &str) -> usize {
let mut width = 0;
let mut in_escape = false;
for c in s.chars() {
if in_escape {
if c.is_ascii_alphabetic() {
in_escape = false;
}
} else if c == '\x1b' {
in_escape = true;
} else {
width += unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
}
}
width
}
fn skip_chars(s: &str, n: usize) -> &str {
let mut width = 0;
let mut byte_idx = 0;
let mut in_escape = false;
for (i, c) in s.char_indices() {
if width >= n {
byte_idx = i;
break;
}
if in_escape {
if c.is_ascii_alphabetic() {
in_escape = false;
}
} else if c == '\x1b' {
in_escape = true;
} else {
width += unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
}
byte_idx = i + c.len_utf8();
}
&s[byte_idx..]
}
fn truncate_to_width(s: &str, max_width: usize) -> &str {
let mut width = 0;
let mut byte_idx = s.len();
let mut in_escape = false;
for (i, c) in s.char_indices() {
if in_escape {
if c.is_ascii_alphabetic() {
in_escape = false;
}
} else if c == '\x1b' {
in_escape = true;
} else {
let char_width = unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
if width + char_width > max_width {
byte_idx = i;
break;
}
width += char_width;
}
}
&s[..byte_idx]
}
#[derive(Debug, Clone)]
pub struct RegionHighlight {
pub start: usize,
pub end: usize,
pub attr: TextAttr,
pub memo: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum HighlightCategory {
Region,
Isearch,
Suffix,
Paste,
Default,
Special,
Ellipsis,
}
#[derive(Debug, Default)]
pub struct HighlightManager {
pub regions: Vec<RegionHighlight>,
pub category_attrs: std::collections::HashMap<HighlightCategory, TextAttr>,
}
impl HighlightManager {
pub fn new() -> Self {
HighlightManager {
regions: Vec::new(),
category_attrs: std::collections::HashMap::new(),
}
}
pub fn set_region_highlight(&mut self, start: usize, end: usize, attr: TextAttr) {
self.regions.push(RegionHighlight {
start,
end,
attr,
memo: None,
});
}
pub fn get_region_highlight(&self, pos: usize) -> Option<&RegionHighlight> {
self.regions.iter().find(|r| pos >= r.start && pos < r.end)
}
pub fn unset_region_highlight(&mut self) {
self.regions.clear();
}
pub fn free(&mut self) {
self.regions.clear();
}
}
pub fn tcout(cap: &str) {
print!("{}", cap);
}
pub fn tcoutarg(cap: &str, arg: i32) {
let s = cap.replace("%d", &arg.to_string());
print!("{}", s);
}
pub fn tcmultout(cap: &str, count: i32) {
for _ in 0..count {
print!("{}", cap);
}
}
pub fn tcoutclear(to_end: bool) {
if to_end {
print!("\x1b[J"); } else {
print!("\x1b[2J"); }
}
pub fn zle_refresh_boot() -> RefreshState {
RefreshState::new()
}
pub fn zle_refresh_finish(state: &mut RefreshState) {
state.free_video();
}
pub fn parse_highlight_spec(spec: &str) -> TextAttr {
let mut attr = TextAttr::default();
for token in spec.split(',') {
let token = token.trim();
if token.is_empty() {
continue;
}
match token {
"none" => {
attr = TextAttr::default();
}
"bold" => attr.bold = true,
"nobold" => attr.bold = false,
"underline" => attr.underline = true,
"nounderline" => attr.underline = false,
"standout" => attr.standout = true,
"nostandout" => attr.standout = false,
"blink" => attr.blink = true,
"noblink" => attr.blink = false,
other => {
if let Some(rest) = other.strip_prefix("fg=") {
attr.fg_color = parse_color_token(rest);
} else if let Some(rest) = other.strip_prefix("bg=") {
attr.bg_color = parse_color_token(rest);
}
}
}
}
attr
}
fn parse_color_token(name: &str) -> Option<u8> {
match name {
"black" => Some(0),
"red" => Some(1),
"green" => Some(2),
"yellow" => Some(3),
"blue" => Some(4),
"magenta" => Some(5),
"cyan" => Some(6),
"white" => Some(7),
"default" => None,
n => n.parse::<u8>().ok(),
}
}
pub fn zle_set_highlight(manager: &mut HighlightManager, atrs: &[&str]) {
use HighlightCategory as HC;
let mut seen = std::collections::HashSet::new();
for entry in atrs {
if entry.is_empty() {
continue;
}
if *entry == "none" {
for cat in [
HC::Region,
HC::Isearch,
HC::Suffix,
HC::Paste,
HC::Default,
HC::Special,
HC::Ellipsis,
] {
manager.category_attrs.insert(cat, TextAttr::default());
seen.insert(cat);
}
continue;
}
let (prefix, rest) = match entry.split_once(':') {
Some(t) => t,
None => continue,
};
let cat = match prefix {
"region" => HC::Region,
"isearch" => HC::Isearch,
"suffix" => HC::Suffix,
"paste" => HC::Paste,
"default" => HC::Default,
"special" => HC::Special,
"ellipsis" => HC::Ellipsis,
_ => continue,
};
manager.category_attrs.insert(cat, parse_highlight_spec(rest));
seen.insert(cat);
}
let default_standout = TextAttr {
standout: true,
..TextAttr::default()
};
let default_underline = TextAttr {
underline: true,
..TextAttr::default()
};
let default_bold = TextAttr {
bold: true,
..TextAttr::default()
};
if !seen.contains(&HC::Region) {
manager.category_attrs.insert(HC::Region, default_standout);
}
if !seen.contains(&HC::Isearch) {
manager.category_attrs.insert(HC::Isearch, default_underline);
}
if !seen.contains(&HC::Suffix) {
manager.category_attrs.insert(HC::Suffix, default_bold);
}
if !seen.contains(&HC::Special) {
manager.category_attrs.insert(HC::Special, default_standout);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_visible_width() {
assert_eq!(visible_width("hello"), 5);
assert_eq!(visible_width("\x1b[31mhello\x1b[0m"), 5);
assert_eq!(visible_width("日本語"), 6); }
#[test]
fn test_video_buffer() {
let mut buf = VideoBuffer::new(80, 24);
assert_eq!(buf.cols, 80);
assert_eq!(buf.rows, 24);
buf.set(0, 0, RefreshElement::new('A'));
assert_eq!(buf.get(0, 0).map(|e| e.chr), Some('A'));
buf.clear();
assert_eq!(buf.get(0, 0).map(|e| e.chr), Some(' '));
}
#[test]
fn test_refresh_state() {
let mut state = RefreshState::new();
assert!(state.old_video.is_some());
assert!(state.new_video.is_some());
state.swap_buffers();
state.free_video();
assert!(state.old_video.is_none());
}
#[test]
fn compute_render_attrs_empty_buffer_yields_empty_overlay() {
let zle = Zle::new();
assert!(zle.compute_render_attrs().is_empty());
}
#[test]
fn compute_render_attrs_visual_mode_paints_mark_to_cursor_in_standout() {
let mut zle = Zle::new();
zle.zleline = "hello world".chars().collect();
zle.zlell = zle.zleline.len();
zle.mark = 2;
zle.zlecs = 7;
zle.region_active = 1; let attrs = zle.compute_render_attrs();
assert_eq!(attrs.len(), 11);
for slot in attrs.iter().take(2) {
assert!(slot.is_none());
}
for slot in attrs.iter().skip(7) {
assert!(slot.is_none());
}
for slot in attrs.iter().take(7).skip(2) {
let attr = slot.expect("standout");
assert!(attr.standout);
}
}
#[test]
fn compute_render_attrs_visual_mode_handles_reverse_mark_order() {
let mut zle = Zle::new();
zle.zleline = "abcdef".chars().collect();
zle.zlell = 6;
zle.mark = 5;
zle.zlecs = 1;
zle.region_active = 2; let attrs = zle.compute_render_attrs();
assert!(attrs[0].is_none());
for slot in attrs.iter().take(5).skip(1) {
assert!(slot.unwrap().standout);
}
assert!(attrs[5].is_none());
}
#[test]
fn parse_highlight_spec_handles_combined_attrs() {
let attr = parse_highlight_spec("bold,fg=red,underline");
assert!(attr.bold);
assert!(attr.underline);
assert_eq!(attr.fg_color, Some(1));
}
#[test]
fn parse_highlight_spec_named_and_numeric_colors() {
assert_eq!(parse_highlight_spec("fg=cyan").fg_color, Some(6));
assert_eq!(parse_highlight_spec("bg=42").bg_color, Some(42));
assert_eq!(parse_highlight_spec("fg=999").fg_color, None);
}
#[test]
fn parse_highlight_spec_negation_clears_attr() {
let attr = parse_highlight_spec("bold,nobold,underline");
assert!(!attr.bold);
assert!(attr.underline);
}
#[test]
fn parse_highlight_spec_none_resets_everything() {
let attr = parse_highlight_spec("bold,fg=red,none,underline");
assert!(!attr.bold);
assert!(attr.underline);
assert_eq!(attr.fg_color, None);
}
#[test]
fn zle_set_highlight_populates_categories_and_defaults() {
let mut mgr = HighlightManager::new();
let entries = ["region:fg=red,bold", "isearch:fg=blue"];
zle_set_highlight(&mut mgr, &entries);
let region = mgr.category_attrs[&HighlightCategory::Region];
assert!(region.bold);
assert_eq!(region.fg_color, Some(1));
let isearch = mgr.category_attrs[&HighlightCategory::Isearch];
assert_eq!(isearch.fg_color, Some(4));
let suffix = mgr.category_attrs[&HighlightCategory::Suffix];
assert!(suffix.bold);
let special = mgr.category_attrs[&HighlightCategory::Special];
assert!(special.standout);
}
#[test]
fn zle_set_highlight_none_clears_every_slot() {
let mut mgr = HighlightManager::new();
zle_set_highlight(&mut mgr, &["none"]);
for cat in [
HighlightCategory::Region,
HighlightCategory::Isearch,
HighlightCategory::Suffix,
HighlightCategory::Paste,
] {
let attr = mgr.category_attrs[&cat];
assert_eq!(attr, TextAttr::default());
}
}
#[test]
fn compute_render_attrs_visual_uses_zle_highlight_region_attr() {
let mut zle = Zle::new();
zle.zleline = "abcde".chars().collect();
zle.zlell = 5;
zle.mark = 1;
zle.zlecs = 4;
zle.region_active = 1;
zle_set_highlight(&mut zle.highlight, &["region:fg=red,bold"]);
let attrs = zle.compute_render_attrs();
for slot in attrs.iter().take(4).skip(1) {
let a = slot.expect("region painted");
assert!(a.bold);
assert_eq!(a.fg_color, Some(1));
assert!(!a.standout);
}
}
#[test]
fn compute_render_attrs_explicit_regions_override_default() {
let mut zle = Zle::new();
zle.zleline = "abcde".chars().collect();
zle.zlell = 5;
let custom = TextAttr {
bold: true,
fg_color: Some(1),
..TextAttr::default()
};
zle.highlight
.set_region_highlight(1, 4, custom);
let attrs = zle.compute_render_attrs();
assert!(attrs[0].is_none());
for slot in attrs.iter().take(4).skip(1) {
let a = slot.expect("custom");
assert!(a.bold);
assert_eq!(a.fg_color, Some(1));
}
assert!(attrs[4].is_none());
}
}