use std::collections::HashMap;
use std::time::{Duration, Instant};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use crate::session::{fetch_authed_response_multi, read_session_key};
use crate::util::LayoutSimple;
pub type PreviewCache = HashMap<String, (String, Instant)>;
pub const PREVIEW_TTL: Duration = Duration::from_millis(1500);
const CONNECT_TIMEOUT: Duration = Duration::from_millis(150);
const READ_TIMEOUT: Duration = Duration::from_millis(400);
pub fn cache_key(sess: &str, win_id: usize, pane_id: usize) -> String {
format!("{}\t{}\t{}", sess, win_id, pane_id)
}
pub fn fetch_pane_preview(home: &str, sess: &str, win_id: usize, pane_id: usize) -> Option<String> {
let port_path = format!("{}\\.psmux\\{}.port", home, sess);
let port: u16 = std::fs::read_to_string(&port_path).ok()?.trim().parse().ok()?;
let key = read_session_key(sess).ok()?;
let target = if pane_id == usize::MAX {
format!(":@{}", win_id)
} else {
format!(":@{}.%{}", win_id, pane_id)
};
let cmd = format!("capture-pane -e -p -t {}\n", target);
let resp = fetch_authed_response_multi(
&format!("127.0.0.1:{}", port),
&key,
cmd.as_bytes(),
CONNECT_TIMEOUT,
READ_TIMEOUT,
)?;
if resp.trim().is_empty() {
None
} else {
Some(resp)
}
}
pub fn get_or_fetch(
cache: &mut PreviewCache,
home: &str,
sess: &str,
win_id: usize,
pane_id: usize,
) -> Option<String> {
let key = cache_key(sess, win_id, pane_id);
if let Some((text, ts)) = cache.get(&key) {
if ts.elapsed() < PREVIEW_TTL {
return Some(text.clone());
}
}
let text = fetch_pane_preview(home, sess, win_id, pane_id)?;
cache.insert(key, (text.clone(), Instant::now()));
Some(text)
}
pub fn clip_lines(text: &str, width: u16, height: u16) -> Vec<String> {
let max_w = width as usize;
let max_h = height as usize;
if max_h == 0 || max_w == 0 {
return Vec::new();
}
let raw: Vec<&str> = text.split('\n').collect();
let mut end = raw.len();
while end > 0 && raw[end - 1].trim_end().is_empty() {
end -= 1;
}
let slice = &raw[..end];
let start = slice.len().saturating_sub(max_h);
slice[start..]
.iter()
.map(|l| {
let t = l.trim_end_matches(['\r', ' ', '\t'][..].as_ref());
let mut out = String::new();
let mut w = 0;
for ch in t.chars() {
if w + 1 > max_w {
break;
}
out.push(ch);
w += 1;
}
out
})
.collect()
}
fn sgr_color_from_8bit(n: u8) -> Color {
match n {
0 => Color::Black, 1 => Color::Red, 2 => Color::Green, 3 => Color::Yellow,
4 => Color::Blue, 5 => Color::Magenta, 6 => Color::Cyan, 7 => Color::Gray,
8 => Color::DarkGray, 9 => Color::LightRed, 10 => Color::LightGreen,
11 => Color::LightYellow, 12 => Color::LightBlue, 13 => Color::LightMagenta,
14 => Color::LightCyan, 15 => Color::White,
n => Color::Indexed(n),
}
}
fn apply_sgr(style: &mut Style, params: &[u32]) {
let mut i = 0;
while i < params.len() {
let p = params[i];
match p {
0 => *style = Style::default(),
1 => *style = style.add_modifier(Modifier::BOLD),
2 => *style = style.add_modifier(Modifier::DIM),
3 => *style = style.add_modifier(Modifier::ITALIC),
4 => *style = style.add_modifier(Modifier::UNDERLINED),
5 | 6 => *style = style.add_modifier(Modifier::SLOW_BLINK),
7 => *style = style.add_modifier(Modifier::REVERSED),
9 => *style = style.add_modifier(Modifier::CROSSED_OUT),
22 => *style = style.remove_modifier(Modifier::BOLD | Modifier::DIM),
23 => *style = style.remove_modifier(Modifier::ITALIC),
24 => *style = style.remove_modifier(Modifier::UNDERLINED),
25 => *style = style.remove_modifier(Modifier::SLOW_BLINK | Modifier::RAPID_BLINK),
27 => *style = style.remove_modifier(Modifier::REVERSED),
29 => *style = style.remove_modifier(Modifier::CROSSED_OUT),
30..=37 => *style = style.fg(sgr_color_from_8bit((p - 30) as u8)),
39 => *style = style.fg(Color::Reset),
40..=47 => *style = style.bg(sgr_color_from_8bit((p - 40) as u8)),
49 => *style = style.bg(Color::Reset),
90..=97 => *style = style.fg(sgr_color_from_8bit((p - 90 + 8) as u8)),
100..=107 => *style = style.bg(sgr_color_from_8bit((p - 100 + 8) as u8)),
38 | 48 => {
if let Some(&kind) = params.get(i + 1) {
if kind == 5 {
if let Some(&n) = params.get(i + 2) {
let col = if n <= 255 { Color::Indexed(n as u8) } else { Color::Reset };
*style = if p == 38 { style.fg(col) } else { style.bg(col) };
i += 2;
}
} else if kind == 2 {
if let (Some(&r), Some(&g), Some(&b)) =
(params.get(i + 2), params.get(i + 3), params.get(i + 4))
{
let col = Color::Rgb(r as u8, g as u8, b as u8);
*style = if p == 38 { style.fg(col) } else { style.bg(col) };
i += 4;
}
}
}
}
_ => {}
}
i += 1;
}
}
fn parse_sgr_line(line: &str, max_width: usize, style: &mut Style) -> Vec<Span<'static>> {
let mut spans: Vec<Span<'static>> = Vec::new();
let mut buf = String::new();
let mut width = 0usize;
let mut chars = line.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\x1b' {
if chars.peek() == Some(&'[') {
chars.next();
let mut params_buf = String::new();
let mut final_byte = '\0';
while let Some(c) = chars.next() {
if c.is_ascii_digit() || c == ';' || c == ':' {
params_buf.push(c);
} else {
final_byte = c;
break;
}
}
if final_byte == 'm' {
if !buf.is_empty() {
spans.push(Span::styled(std::mem::take(&mut buf), *style));
}
let params: Vec<u32> = if params_buf.is_empty() {
vec![0]
} else {
params_buf
.split(|c| c == ';' || c == ':')
.map(|s| s.parse::<u32>().unwrap_or(0))
.collect()
};
apply_sgr(style, ¶ms);
}
} else {
let _ = chars.next();
}
continue;
}
if ch == '\r' { continue; }
if (ch as u32) < 0x20 { continue; }
if width + 1 > max_width { break; }
buf.push(ch);
width += 1;
}
if !buf.is_empty() {
spans.push(Span::styled(buf, *style));
}
spans
}
fn strip_ansi(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\x1b' {
if chars.peek() == Some(&'[') {
chars.next();
while let Some(c) = chars.next() {
if !(c.is_ascii_digit() || c == ';' || c == ':') { break; }
}
} else { let _ = chars.next(); }
continue;
}
out.push(ch);
}
out
}
pub fn parse_ansi_lines(text: &str, width: u16, height: u16) -> Vec<Line<'static>> {
let max_w = width as usize;
let max_h = height as usize;
if max_w == 0 || max_h == 0 { return Vec::new(); }
let raw: Vec<&str> = text.split('\n').collect();
let mut end = raw.len();
while end > 0 && strip_ansi(raw[end - 1]).trim_end().is_empty() {
end -= 1;
}
let slice = &raw[..end];
let start = slice.len().saturating_sub(max_h);
let mut style = Style::default();
for l in &slice[..start] {
let _ = parse_sgr_line(l, usize::MAX, &mut style);
}
slice[start..]
.iter()
.map(|l| Line::from(parse_sgr_line(l, max_w, &mut style)))
.collect()
}
pub type LayoutCache = HashMap<String, (LayoutSimple, Instant)>;
pub const LAYOUT_TTL: Duration = Duration::from_millis(2500);
pub fn fetch_window_layout(home: &str, sess: &str, win_id: usize) -> Option<LayoutSimple> {
let port_path = format!("{}\\.psmux\\{}.port", home, sess);
let port: u16 = std::fs::read_to_string(&port_path).ok()?.trim().parse().ok()?;
let key = read_session_key(sess).ok()?;
let cmd = format!("window-layout {}\n", win_id);
let resp = fetch_authed_response_multi(
&format!("127.0.0.1:{}", port),
&key,
cmd.as_bytes(),
CONNECT_TIMEOUT,
READ_TIMEOUT,
)?;
let trimmed = resp.trim();
if trimmed.is_empty() || trimmed == "{}" {
return None;
}
serde_json::from_str::<LayoutSimple>(trimmed).ok()
}
pub fn get_or_fetch_layout(
cache: &mut LayoutCache,
home: &str,
sess: &str,
win_id: usize,
) -> Option<LayoutSimple> {
let key = format!("{}\t{}", sess, win_id);
if let Some((layout, ts)) = cache.get(&key) {
if ts.elapsed() < LAYOUT_TTL {
return Some(layout.clone());
}
}
let layout = fetch_window_layout(home, sess, win_id)?;
cache.insert(key, (layout.clone(), Instant::now()));
Some(layout)
}
pub fn flatten_layout_to_rects(
layout: &LayoutSimple,
area: ratatui::layout::Rect,
) -> Vec<(usize, bool, ratatui::layout::Rect)> {
use ratatui::layout::Rect;
let mut out: Vec<(usize, bool, Rect)> = Vec::new();
fn rec(node: &LayoutSimple, area: Rect, out: &mut Vec<(usize, bool, Rect)>) {
match node {
LayoutSimple::Leaf { id, active } => {
if area.width > 0 && area.height > 0 {
out.push((*id, *active, area));
}
}
LayoutSimple::Split { kind, sizes, children } => {
if children.is_empty() {
return;
}
let total: u32 = sizes.iter().map(|s| *s as u32).sum::<u32>().max(1);
let is_horiz = kind.as_str() == "Horizontal";
let span = if is_horiz { area.width as u32 } else { area.height as u32 };
let sep_count = children.len().saturating_sub(1) as u32;
let usable = span.saturating_sub(sep_count);
let n = children.len();
let mut alloc: Vec<u32> = sizes.iter().take(n)
.map(|s| (*s as u32 * usable) / total)
.collect();
while alloc.len() < n { alloc.push(0); }
let used: u32 = alloc.iter().sum();
let mut leftover = usable.saturating_sub(used);
let alen = alloc.len();
let mut idx = 0;
while leftover > 0 && alen > 0 {
alloc[idx % alen] += 1;
idx += 1;
leftover -= 1;
}
let mut cursor: u32 = 0;
for (i, child) in children.iter().enumerate() {
let size = alloc[i] as u16;
let sub = if is_horiz {
Rect { x: area.x + cursor as u16, y: area.y, width: size, height: area.height }
} else {
Rect { x: area.x, y: area.y + cursor as u16, width: area.width, height: size }
};
rec(child, sub, out);
cursor += size as u32;
if i + 1 < children.len() {
cursor += 1;
}
}
}
}
}
rec(layout, area, &mut out);
out
}
pub fn layout_separators(
layout: &LayoutSimple,
area: ratatui::layout::Rect,
) -> Vec<(ratatui::layout::Rect, bool)> {
use ratatui::layout::Rect;
let mut out: Vec<(Rect, bool)> = Vec::new();
fn rec(node: &LayoutSimple, area: Rect, out: &mut Vec<(Rect, bool)>) {
if let LayoutSimple::Split { kind, sizes, children } = node {
if children.is_empty() { return; }
let total: u32 = sizes.iter().map(|s| *s as u32).sum::<u32>().max(1);
let is_horiz = kind.as_str() == "Horizontal";
let span = if is_horiz { area.width as u32 } else { area.height as u32 };
let sep_count = children.len().saturating_sub(1) as u32;
let usable = span.saturating_sub(sep_count);
let n = children.len();
let mut alloc: Vec<u32> = sizes.iter().take(n)
.map(|s| (*s as u32 * usable) / total)
.collect();
while alloc.len() < n { alloc.push(0); }
let used: u32 = alloc.iter().sum();
let mut leftover = usable.saturating_sub(used);
let alen = alloc.len();
let mut idx = 0;
while leftover > 0 && alen > 0 {
alloc[idx % alen] += 1;
idx += 1;
leftover -= 1;
}
let mut cursor: u32 = 0;
for (i, child) in children.iter().enumerate() {
let size = alloc[i] as u16;
let sub = if is_horiz {
Rect { x: area.x + cursor as u16, y: area.y, width: size, height: area.height }
} else {
Rect { x: area.x, y: area.y + cursor as u16, width: area.width, height: size }
};
rec(child, sub, out);
cursor += size as u32;
if i + 1 < children.len() {
if is_horiz {
out.push((Rect { x: area.x + cursor as u16, y: area.y, width: 1, height: area.height }, true));
} else {
out.push((Rect { x: area.x, y: area.y + cursor as u16, width: area.width, height: 1 }, false));
}
cursor += 1;
}
}
}
}
rec(layout, area, &mut out);
out
}
pub type DumpCache = HashMap<String, (crate::layout::LayoutJson, Instant)>;
pub const DUMP_TTL: Duration = Duration::from_millis(1500);
pub fn fetch_window_dump(home: &str, sess: &str, win_id: usize) -> Option<crate::layout::LayoutJson> {
let port_path = format!("{}\\.psmux\\{}.port", home, sess);
let port: u16 = std::fs::read_to_string(&port_path).ok()?.trim().parse().ok()?;
let key = read_session_key(sess).ok()?;
let cmd = format!("window-dump {}\n", win_id);
let resp = fetch_authed_response_multi(
&format!("127.0.0.1:{}", port),
&key,
cmd.as_bytes(),
CONNECT_TIMEOUT,
READ_TIMEOUT,
)?;
let trimmed = resp.trim();
if trimmed.is_empty() || trimmed == "{}" {
return None;
}
serde_json::from_str::<crate::layout::LayoutJson>(trimmed).ok()
}
pub fn get_or_fetch_dump(
cache: &mut DumpCache,
home: &str,
sess: &str,
win_id: usize,
) -> Option<crate::layout::LayoutJson> {
let key = format!("{}\t{}", sess, win_id);
if let Some((layout, ts)) = cache.get(&key) {
if ts.elapsed() < DUMP_TTL {
return Some(layout.clone());
}
}
let layout = fetch_window_dump(home, sess, win_id)?;
cache.insert(key, (layout.clone(), Instant::now()));
Some(layout)
}
fn run_style(fg: &str, bg: &str, flags: u8) -> Style {
let mut style = Style::default()
.fg(crate::style::map_color(fg))
.bg(crate::style::map_color(bg));
if flags & 1 != 0 { style = style.add_modifier(Modifier::DIM); }
if flags & 2 != 0 { style = style.add_modifier(Modifier::BOLD); }
if flags & 4 != 0 { style = style.add_modifier(Modifier::ITALIC); }
if flags & 8 != 0 { style = style.add_modifier(Modifier::UNDERLINED); }
if flags & 16 != 0 { style = style.add_modifier(Modifier::REVERSED); }
if flags & 32 != 0 { style = style.add_modifier(Modifier::SLOW_BLINK); }
if flags & 128 != 0 { style = style.add_modifier(Modifier::CROSSED_OUT); }
style
}
pub fn render_runs_line(
runs: &[crate::layout::CellRunJson],
width: u16,
) -> Line<'static> {
let mut spans: Vec<Span<'static>> = Vec::new();
let mut c: u16 = 0;
let mut last_bg = Color::Reset;
for run in runs {
if c >= width { break; }
let style = run_style(&run.fg, &run.bg, run.flags);
last_bg = style.bg.unwrap_or(Color::Reset);
let text: &str = if run.flags & 64 != 0 {
" "
} else if run.text.is_empty() {
" "
} else {
&run.text
};
let run_w = run.width.max(1);
if c + run_w > width {
let avail = (width - c) as usize;
let mut truncated = String::new();
let mut used = 0usize;
for ch in text.chars() {
let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1);
if used + cw > avail { break; }
used += cw;
truncated.push(ch);
}
if !truncated.is_empty() {
spans.push(Span::styled(truncated, style));
c += avail as u16;
}
break;
} else {
spans.push(Span::styled(text.to_string(), style));
c += run_w;
}
}
if c < width {
let pad = " ".repeat((width - c) as usize);
spans.push(Span::styled(pad, Style::default().bg(last_bg)));
}
Line::from(spans)
}
pub fn flatten_dump_rects<'a>(
layout: &'a crate::layout::LayoutJson,
area: ratatui::layout::Rect,
) -> Vec<(&'a crate::layout::LayoutJson, ratatui::layout::Rect)> {
use ratatui::layout::Rect;
let mut out: Vec<(&crate::layout::LayoutJson, Rect)> = Vec::new();
fn rec<'b>(
node: &'b crate::layout::LayoutJson,
area: Rect,
out: &mut Vec<(&'b crate::layout::LayoutJson, Rect)>,
) {
match node {
crate::layout::LayoutJson::Leaf { .. } => {
if area.width > 0 && area.height > 0 {
out.push((node, area));
}
}
crate::layout::LayoutJson::Split { kind, sizes, children } => {
if children.is_empty() { return; }
let total: u32 = sizes.iter().map(|s| *s as u32).sum::<u32>().max(1);
let is_horiz = kind == "Horizontal";
let span = if is_horiz { area.width as u32 } else { area.height as u32 };
let sep_count = children.len().saturating_sub(1) as u32;
let usable = span.saturating_sub(sep_count);
let n = children.len();
let mut alloc: Vec<u32> = sizes.iter().take(n)
.map(|s| (*s as u32 * usable) / total)
.collect();
while alloc.len() < n { alloc.push(0); }
let used: u32 = alloc.iter().sum();
let mut leftover = usable.saturating_sub(used);
let alen = alloc.len();
let mut idx = 0;
while leftover > 0 && alen > 0 {
alloc[idx % alen] += 1;
idx += 1;
leftover -= 1;
}
let mut cursor: u32 = 0;
for (i, child) in children.iter().enumerate() {
let size = alloc[i] as u16;
let sub = if is_horiz {
Rect { x: area.x + cursor as u16, y: area.y, width: size, height: area.height }
} else {
Rect { x: area.x, y: area.y + cursor as u16, width: area.width, height: size }
};
rec(child, sub, out);
cursor += size as u32;
if i + 1 < children.len() {
cursor += 1;
}
}
}
}
}
rec(layout, area, &mut out);
out
}
pub fn dump_separators(
layout: &crate::layout::LayoutJson,
area: ratatui::layout::Rect,
) -> Vec<(ratatui::layout::Rect, bool)> {
use ratatui::layout::Rect;
let mut out: Vec<(Rect, bool)> = Vec::new();
fn rec(node: &crate::layout::LayoutJson, area: Rect, out: &mut Vec<(Rect, bool)>) {
if let crate::layout::LayoutJson::Split { kind, sizes, children } = node {
if children.is_empty() { return; }
let total: u32 = sizes.iter().map(|s| *s as u32).sum::<u32>().max(1);
let is_horiz = kind == "Horizontal";
let span = if is_horiz { area.width as u32 } else { area.height as u32 };
let sep_count = children.len().saturating_sub(1) as u32;
let usable = span.saturating_sub(sep_count);
let n = children.len();
let mut alloc: Vec<u32> = sizes.iter().take(n)
.map(|s| (*s as u32 * usable) / total)
.collect();
while alloc.len() < n { alloc.push(0); }
let used: u32 = alloc.iter().sum();
let mut leftover = usable.saturating_sub(used);
let alen = alloc.len();
let mut idx = 0;
while leftover > 0 && alen > 0 {
alloc[idx % alen] += 1;
idx += 1;
leftover -= 1;
}
let mut cursor: u32 = 0;
for (i, child) in children.iter().enumerate() {
let size = alloc[i] as u16;
let sub = if is_horiz {
Rect { x: area.x + cursor as u16, y: area.y, width: size, height: area.height }
} else {
Rect { x: area.x, y: area.y + cursor as u16, width: area.width, height: size }
};
rec(child, sub, out);
cursor += size as u32;
if i + 1 < children.len() {
if is_horiz {
out.push((Rect { x: area.x + cursor as u16, y: area.y, width: 1, height: area.height }, true));
} else {
out.push((Rect { x: area.x, y: area.y + cursor as u16, width: area.width, height: 1 }, false));
}
cursor += 1;
}
}
}
}
rec(layout, area, &mut out);
out
}
pub fn render_dump_tree(
f: &mut ratatui::Frame,
layout: &crate::layout::LayoutJson,
area: ratatui::layout::Rect,
border_fg: Color,
active_border_fg: Color,
_highlight_pid: Option<usize>,
) {
if area.width == 0 || area.height == 0 { return; }
let active_rect = crate::client::compute_active_rect_json(layout, area);
let total_panes = layout.count_leaves();
crate::client::render_layout_json(
f, layout, area,
false, border_fg, active_border_fg,
false, Color::Reset, active_rect,
"", false, "off", "", total_panes,
);
crate::rendering::fix_border_intersections(f.buffer_mut());
}
#[cfg(test)]
mod tests_ansi {
use super::*;
use ratatui::style::{Color, Modifier};
#[test]
fn parse_ansi_lines_preserves_red_marker() {
let txt = "\x1b[31mABC\x1b[0m\ndef";
let lines = parse_ansi_lines(txt, 10, 5);
assert_eq!(lines.len(), 2, "expected 2 lines, got {}", lines.len());
let first = &lines[0];
let abc_span = first.spans.iter().find(|s| s.content == "ABC")
.expect("no ABC span");
assert_eq!(abc_span.style.fg, Some(Color::Red));
let second = &lines[1];
let def_span = second.spans.iter().find(|s| s.content == "def")
.expect("no def span");
assert_ne!(def_span.style.fg, Some(Color::Red));
}
#[test]
fn parse_ansi_lines_clips_to_width() {
let txt = "ABCDEFGHIJ";
let lines = parse_ansi_lines(txt, 4, 1);
assert_eq!(lines.len(), 1);
let total: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(total, "ABCD");
}
#[test]
fn parse_ansi_lines_handles_bold() {
let txt = "\x1b[1mBOLD\x1b[0m";
let lines = parse_ansi_lines(txt, 10, 1);
let span = lines[0].spans.iter().find(|s| s.content == "BOLD").expect("no BOLD span");
assert!(span.style.add_modifier.contains(Modifier::BOLD));
}
}