use std::collections::{HashMap, HashSet};
use std::io::Write;
use std::sync::OnceLock;
use unicode_width::UnicodeWidthStr;
static BLANK_ROW_BUF: OnceLock<String> = OnceLock::new();
const BLANK_ROW_CAP: usize = 1024;
fn blank_row(width: usize) -> std::borrow::Cow<'static, str> {
let buf = BLANK_ROW_BUF.get_or_init(|| " ".repeat(BLANK_ROW_CAP));
if width <= buf.len() {
std::borrow::Cow::Borrowed(&buf[..width])
} else {
std::borrow::Cow::Owned(" ".repeat(width))
}
}
use crossterm::{
cursor, queue,
style::{
Attribute, Color, Print, ResetColor, SetAttribute, SetBackgroundColor, SetForegroundColor,
},
terminal::{self, ClearType},
};
use serde::{Deserialize, Serialize};
use crate::layout::{Layout, Rect};
use crate::pane::Pane;
use crate::theme::{vt100_to_crossterm, AdaptedTheme};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BorderStyle {
Single,
Rounded,
Heavy,
Double,
None,
}
impl BorderStyle {
pub fn from_str(s: &str) -> Option<Self> {
match s {
"single" => Some(Self::Single),
"rounded" => Some(Self::Rounded),
"heavy" => Some(Self::Heavy),
"double" => Some(Self::Double),
"none" | "borderless" => Some(Self::None),
_ => Option::None,
}
}
pub fn is_none(self) -> bool {
matches!(self, Self::None)
}
pub fn chars(self) -> BorderChars {
match self {
Self::Single => BorderChars {
h: "─",
v: "│",
tl: "┌",
tr: "┐",
bl: "└",
br: "┘",
tj: "┬",
bj: "┴",
lj: "├",
rj: "┤",
xj: "┼",
},
Self::Rounded => BorderChars {
h: "─",
v: "│",
tl: "╭",
tr: "╮",
bl: "╰",
br: "╯",
tj: "┬",
bj: "┴",
lj: "├",
rj: "┤",
xj: "┼",
},
Self::Heavy => BorderChars {
h: "━",
v: "┃",
tl: "┏",
tr: "┓",
bl: "┗",
br: "┛",
tj: "┳",
bj: "┻",
lj: "┣",
rj: "┫",
xj: "╋",
},
Self::Double => BorderChars {
h: "═",
v: "║",
tl: "╔",
tr: "╗",
bl: "╚",
br: "╝",
tj: "╦",
bj: "╩",
lj: "╠",
rj: "╣",
xj: "╬",
},
Self::None => BorderChars {
h: " ",
v: "│",
tl: " ",
tr: " ",
bl: " ",
br: " ",
tj: "│",
bj: "│",
lj: "│",
rj: "│",
xj: "│",
},
}
}
}
pub struct BorderChars {
pub h: &'static str,
pub v: &'static str,
pub tl: &'static str,
pub tr: &'static str,
pub bl: &'static str,
pub br: &'static str,
pub tj: &'static str,
pub bj: &'static str,
pub lj: &'static str,
pub rj: &'static str,
pub xj: &'static str,
}
pub struct BorderCell {
pub x: u16,
pub y: u16,
pub flags: [bool; 4],
}
pub struct BorderCache {
inner: Rect,
pane_order: Vec<usize>,
pane_rects: HashMap<usize, Rect>,
cells: Vec<BorderCell>,
}
impl BorderCache {
pub fn pane_order(&self) -> &[usize] {
&self.pane_order
}
pub fn pane_rects(&self) -> &HashMap<usize, Rect> {
&self.pane_rects
}
pub fn inner(&self) -> &Rect {
&self.inner
}
}
struct BorderMap {
cells: HashMap<(u16, u16), [bool; 4]>,
}
impl BorderMap {
fn new() -> Self {
Self {
cells: HashMap::new(),
}
}
fn add_h_line(&mut self, x1: u16, x2: u16, y: u16) {
for x in x1..=x2 {
let e = self.cells.entry((x, y)).or_insert([false; 4]);
if x > x1 {
e[2] = true;
}
if x < x2 {
e[3] = true;
}
}
}
fn add_v_line(&mut self, x: u16, y1: u16, y2: u16) {
for y in y1..=y2 {
let e = self.cells.entry((x, y)).or_insert([false; 4]);
if y > y1 {
e[0] = true;
}
if y < y2 {
e[1] = true;
}
}
}
}
fn border_char<'a>(flags: &[bool; 4], ch: &'a BorderChars) -> &'a str {
match (flags[2], flags[3], flags[0], flags[1]) {
(true, true, true, true) => ch.xj,
(true, true, false, false) => ch.h,
(false, false, true, true) => ch.v,
(false, true, false, true) => ch.tl,
(true, false, false, true) => ch.tr,
(false, true, true, false) => ch.bl,
(true, false, true, false) => ch.br,
(true, true, false, true) => ch.tj,
(true, true, true, false) => ch.bj,
(true, false, true, true) => ch.rj,
(false, true, true, true) => ch.lj,
(_, true, false, false) | (true, _, false, false) => ch.h,
(false, false, _, true) | (false, false, true, _) => ch.v,
_ => " ",
}
}
pub fn build_border_cache(
layout: &Layout,
show_status_bar: bool,
term_w: u16,
term_h: u16,
) -> BorderCache {
build_border_cache_with_style(
layout,
show_status_bar,
term_w,
term_h,
BorderStyle::Rounded,
)
}
pub fn build_border_cache_with_style(
layout: &Layout,
show_status_bar: bool,
term_w: u16,
term_h: u16,
style: BorderStyle,
) -> BorderCache {
let status_h = if show_status_bar { 1u16 } else { 0 };
let border_h = term_h.saturating_sub(status_h);
let borderless = style.is_none();
let outer = Rect {
x: 0,
y: 0,
w: term_w,
h: border_h,
};
let inner = if borderless {
Rect {
x: 0,
y: 1,
w: term_w,
h: border_h.saturating_sub(1),
}
} else {
Rect {
x: 1,
y: 1,
w: term_w.saturating_sub(2),
h: border_h.saturating_sub(2),
}
};
let pane_order = layout.pane_ids();
let pane_rects = layout.pane_rects(&inner);
let separators = layout.separators(&inner, &outer);
let mut bmap = BorderMap::new();
if !borderless && outer.w > 0 && outer.h > 0 {
bmap.add_h_line(outer.x, outer.x + outer.w - 1, outer.y);
bmap.add_h_line(outer.x, outer.x + outer.w - 1, outer.y + outer.h - 1);
bmap.add_v_line(outer.x, outer.y, outer.y + outer.h - 1);
bmap.add_v_line(outer.x + outer.w - 1, outer.y, outer.y + outer.h - 1);
}
for sep in &separators {
if sep.horizontal {
bmap.add_h_line(sep.x, sep.x + sep.length - 1, sep.y);
} else {
bmap.add_v_line(sep.x, sep.y, sep.y + sep.length - 1);
}
}
let cells = bmap
.cells
.into_iter()
.map(|((x, y), flags)| BorderCell { x, y, flags })
.collect();
BorderCache {
inner,
pane_order,
pane_rects,
cells,
}
}
pub type PaneSelection = Option<(usize, u16, u16, u16, u16)>;
#[allow(clippy::too_many_arguments)]
pub fn render_panes(
stdout: &mut impl Write,
panes: &HashMap<usize, Pane>,
_layout: &Layout,
active_id: usize,
border_style: BorderStyle,
show_status_bar: bool,
term_w: u16,
term_h: u16,
dragging_sep: bool,
border_cache: &BorderCache,
dirty_panes: &HashSet<usize>,
full_redraw: bool,
selection: PaneSelection,
broadcast: bool,
theme: &AdaptedTheme,
) -> anyhow::Result<()> {
queue!(stdout, cursor::Hide)?;
if full_redraw {
queue!(stdout, terminal::Clear(ClearType::All))?;
}
let chars = border_style.chars();
let inner = border_cache.inner();
if inner.w == 0 || inner.h == 0 {
let msg = "Terminal too small";
let mx = term_w.saturating_sub(msg.len() as u16) / 2;
let my = term_h / 2;
queue!(
stdout,
cursor::MoveTo(mx, my),
SetForegroundColor(theme.warn_fg),
Print(msg)
)?;
queue!(stdout, ResetColor)?;
return Ok(());
}
let pane_rects = border_cache.pane_rects();
if full_redraw {
let active_rect = pane_rects.get(&active_id);
for cell in &border_cache.cells {
let is_active = active_rect
.map(|r| is_pane_border(cell.x, cell.y, r))
.unwrap_or(false);
let color = if border_style.is_none() {
theme.sec_fg
} else if dragging_sep {
theme.drag_color
} else if broadcast {
theme.broadcast_color
} else if is_active {
theme.active_color
} else {
theme.border_color
};
queue!(
stdout,
cursor::MoveTo(cell.x, cell.y),
SetForegroundColor(color),
Print(border_char(&cell.flags, &chars))
)?;
}
}
let ids = border_cache.pane_order();
for (display_idx, &pid) in ids.iter().enumerate() {
if !full_redraw && !dirty_panes.contains(&pid) {
continue;
}
if let Some(rect) = pane_rects.get(&pid) {
if !full_redraw {
clear_rect(stdout, rect)?;
clear_title(stdout, rect)?;
}
let is_active = pid == active_id;
let pane_ref = panes.get(&pid);
let is_alive = pane_ref.is_some_and(|p| p.is_alive());
let label = pane_ref.map(|p| p.launch_label("")).unwrap_or_default();
let is_scrolled = pane_ref.is_some_and(|p| p.is_scrolled());
let exit_code = pane_ref.and_then(|p| p.exit_code());
{
draw_pane_title(
stdout,
rect,
display_idx,
is_active,
is_alive,
&label,
is_scrolled,
&chars,
exit_code,
theme,
)?;
}
if let Some(pane) = panes.get(&pid) {
let pane_sel = selection
.filter(|(sel_pid, ..)| *sel_pid == pid)
.map(|(_, sr, sc, er, ec)| (sr, sc, er, ec));
draw_content(stdout, pane, rect, is_alive, pane_sel, theme)?;
}
if !is_alive {
draw_dead_overlay(stdout, rect, theme)?;
}
}
}
if show_status_bar && full_redraw {
let active_idx = ids.iter().position(|&id| id == active_id).unwrap_or(0);
draw_status_bar(stdout, term_w, term_h, active_idx, ids.len(), "", theme)?;
}
if let (Some(rect), Some(pane)) = (pane_rects.get(&active_id), panes.get(&active_id)) {
if pane.is_alive() {
let screen = pane.screen();
let (cr, cc) = screen.cursor_position();
if cc < rect.w && cr < rect.h {
queue!(
stdout,
cursor::MoveTo(rect.x + cc, rect.y + cr),
cursor::Show
)?;
}
}
}
queue!(stdout, ResetColor, SetAttribute(Attribute::Reset))?;
Ok(())
}
fn clear_rect(stdout: &mut impl Write, rect: &Rect) -> anyhow::Result<()> {
if rect.w == 0 || rect.h == 0 {
return Ok(());
}
let blanks = blank_row(rect.w as usize);
for row in 0..rect.h {
queue!(
stdout,
cursor::MoveTo(rect.x, rect.y + row),
ResetColor,
Print(blanks.as_ref())
)?;
}
Ok(())
}
fn clear_title(stdout: &mut impl Write, rect: &Rect) -> anyhow::Result<()> {
if rect.w == 0 {
return Ok(());
}
let y = rect.y.saturating_sub(1);
let x = rect.x;
let blanks = blank_row(rect.w as usize);
queue!(
stdout,
cursor::MoveTo(x, y),
ResetColor,
Print(blanks.as_ref())
)?;
Ok(())
}
fn is_pane_border(x: u16, y: u16, r: &Rect) -> bool {
let top = r.y.saturating_sub(1);
let bot = r.y + r.h;
let left = r.x.saturating_sub(1);
let right = r.x + r.w;
(y == top || y == bot) && x >= left && x <= right
|| (x == left || x == right) && y >= top && y <= bot
}
#[allow(clippy::too_many_arguments)]
fn draw_pane_title(
stdout: &mut impl Write,
rect: &Rect,
idx: usize,
is_active: bool,
is_alive: bool,
label: &str,
is_scrolled: bool,
chars: &BorderChars,
exit_code: Option<u32>,
theme: &AdaptedTheme,
) -> anyhow::Result<()> {
let title_y = rect.y.saturating_sub(1);
let title_x = rect.x;
let avail = rect.w as usize;
if avail < 4 {
return Ok(());
}
let borderless = chars.h == " ";
let scroll_ind = if is_scrolled { " [SCROLL]" } else { "" };
let title = if !is_alive {
match exit_code {
Some(code) => {
if borderless {
format!("{} [exit {}]", idx + 1, code)
} else {
format!(" {} [exit {}] ", idx + 1, code)
}
}
None => {
if borderless {
format!("{} [exited]", idx + 1)
} else {
format!(" {} [exited] ", idx + 1)
}
}
}
} else if label.is_empty() || avail < 12 {
if borderless {
format!("{}{}", idx + 1, scroll_ind)
} else {
format!(" {}{} ", idx + 1, scroll_ind)
}
} else {
let pad = if borderless { 4 } else { 8 };
let max_label = avail.saturating_sub(pad + scroll_ind.len());
let short = truncate_label(label, max_label);
if borderless {
format!("{}:{}{}", idx + 1, short, scroll_ind)
} else {
format!(" {}:{}{} ", idx + 1, short, scroll_ind)
}
};
let tlen = title.len();
let show_buttons = avail >= tlen + 13;
let btn_len = if show_buttons { 11 } else { 0 };
let show_close = !show_buttons && avail >= tlen + 4;
let close_len = if show_close { 2 } else { 0 };
let right_len = btn_len + close_len;
if avail >= tlen + 1 + right_len {
let color = if is_active {
theme.active_color
} else {
theme.border_color
};
let title_bg = if is_active { theme.focus_bg } else { theme.bg };
if borderless {
queue!(
stdout,
cursor::MoveTo(title_x, title_y),
SetBackgroundColor(title_bg),
)?;
let blanks = " ".repeat(avail);
queue!(stdout, Print(&blanks))?;
queue!(stdout, cursor::MoveTo(title_x, title_y))?;
} else {
queue!(
stdout,
cursor::MoveTo(title_x, title_y),
SetForegroundColor(color)
)?;
queue!(stdout, Print(chars.h))?;
}
if is_active {
queue!(
stdout,
SetForegroundColor(theme.status_fg),
SetAttribute(Attribute::Bold)
)?;
}
if !is_alive {
queue!(stdout, SetForegroundColor(theme.dead_fg))?;
}
queue!(stdout, Print(&title))?;
queue!(stdout, SetAttribute(Attribute::Reset))?;
if borderless {
queue!(stdout, SetBackgroundColor(title_bg))?;
}
queue!(stdout, SetForegroundColor(color))?;
let leading = if borderless { 0 } else { 1 };
let fill = avail.saturating_sub(tlen + leading + right_len);
if !borderless {
for _ in 0..fill {
queue!(stdout, Print(chars.h))?;
}
}
if show_buttons {
let btn_fg = if is_active {
theme.muted_fg
} else {
theme.border_color
};
if borderless {
let btn_x = title_x + (avail as u16).saturating_sub(11);
queue!(stdout, cursor::MoveTo(btn_x, title_y))?;
}
queue!(
stdout,
SetForegroundColor(btn_fg),
Print("[━] [┃] "),
SetForegroundColor(theme.close_color),
Print("[×]")
)?;
} else if show_close {
if borderless {
let btn_x = title_x + (avail as u16).saturating_sub(2);
queue!(stdout, cursor::MoveTo(btn_x, title_y))?;
}
queue!(stdout, SetForegroundColor(theme.close_color), Print(" ×"))?;
}
}
queue!(stdout, ResetColor, SetAttribute(Attribute::Reset))?;
Ok(())
}
fn truncate_label(label: &str, max_cols: usize) -> String {
if max_cols == 0 {
return String::new();
}
let mut out = String::new();
let mut width = 0usize;
for ch in label.chars() {
let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
if width + cw > max_cols {
break;
}
out.push(ch);
width += cw;
}
out
}
fn draw_dead_overlay(
stdout: &mut impl Write,
rect: &Rect,
theme: &AdaptedTheme,
) -> anyhow::Result<()> {
if rect.w < 5 || rect.h < 1 {
return Ok(());
}
let dim_bg = theme.bg;
for row in 0..rect.h {
queue!(
stdout,
cursor::MoveTo(rect.x, rect.y + row),
SetBackgroundColor(dim_bg),
)?;
for _ in 0..rect.w {
queue!(stdout, Print(" "))?;
}
}
let my = rect.y + rect.h / 2;
if rect.h >= 3 {
let label = "Process exited";
let lx = rect.x + rect.w.saturating_sub(label.len() as u16) / 2;
queue!(
stdout,
cursor::MoveTo(lx, my.saturating_sub(1)),
SetBackgroundColor(dim_bg),
SetForegroundColor(theme.close_color),
SetAttribute(Attribute::Bold),
Print(label),
SetAttribute(Attribute::Reset),
)?;
}
let msg = "Press Enter to respawn";
let mx = rect.x + rect.w.saturating_sub(msg.len() as u16) / 2;
queue!(
stdout,
cursor::MoveTo(mx, my),
SetBackgroundColor(dim_bg),
SetForegroundColor(theme.dead_fg),
SetAttribute(Attribute::Italic),
Print(msg),
SetAttribute(Attribute::Reset),
)?;
Ok(())
}
fn draw_content(
stdout: &mut impl Write,
pane: &Pane,
rect: &Rect,
is_alive: bool,
selection: Option<(u16, u16, u16, u16)>,
theme: &AdaptedTheme,
) -> anyhow::Result<()> {
let screen = pane.screen();
if rect.w == 0 || rect.h == 0 {
return Ok(());
}
let mut last_fg = Color::Reset;
let mut last_bg = Color::Reset;
let mut has_attrs = false;
let mut buf = String::with_capacity(rect.w as usize);
for r in 0..rect.h {
queue!(stdout, cursor::MoveTo(rect.x, rect.y + r))?;
buf.clear();
for c in 0..rect.w {
let is_selected = selection.is_some_and(|(sr, sc, er, ec)| {
if r < sr || r > er {
false
} else if r == sr && r == er {
c >= sc && c <= ec
} else if r == sr {
c >= sc
} else if r == er {
c <= ec
} else {
true
}
});
if let Some(cell) = screen.cell(r, c) {
if cell.is_wide_continuation() {
continue;
}
if cell.is_wide() && c + 1 >= rect.w {
buf.push(' ');
continue;
}
let (mut fg, bg) = if is_selected {
let f = vt100_to_crossterm(cell.bgcolor());
let b = vt100_to_crossterm(cell.fgcolor());
(
if f == Color::Reset { theme.bg } else { f },
if b == Color::Reset { theme.lbl_fg } else { b },
)
} else {
(
vt100_to_crossterm(cell.fgcolor()),
vt100_to_crossterm(cell.bgcolor()),
)
};
if !is_alive {
fg = theme.dead_fg;
}
let ca = cell.bold() || cell.italic() || cell.underline() || cell.inverse();
let style_changed =
fg != last_fg || bg != last_bg || (ca && !has_attrs) || (!ca && has_attrs);
if style_changed && !buf.is_empty() {
queue!(stdout, Print(&buf))?;
buf.clear();
}
if fg != last_fg {
queue!(stdout, SetForegroundColor(fg))?;
last_fg = fg;
}
if bg != last_bg {
queue!(stdout, SetBackgroundColor(bg))?;
last_bg = bg;
}
if ca && is_alive {
if !has_attrs {
if cell.bold() {
queue!(stdout, SetAttribute(Attribute::Bold))?;
}
if cell.italic() {
queue!(stdout, SetAttribute(Attribute::Italic))?;
}
if cell.underline() {
queue!(stdout, SetAttribute(Attribute::Underlined))?;
}
if cell.inverse() {
queue!(stdout, SetAttribute(Attribute::Reverse))?;
}
has_attrs = true;
}
let contents = cell.contents();
if contents.is_empty() {
queue!(stdout, Print(" "))?;
} else {
queue!(stdout, Print(contents))?;
}
} else {
if has_attrs {
queue!(stdout, SetAttribute(Attribute::Reset))?;
last_fg = Color::Reset;
last_bg = Color::Reset;
has_attrs = false;
}
let contents = cell.contents();
if contents.is_empty() {
buf.push(' ');
} else {
buf.push_str(&contents);
}
}
} else {
buf.push(' ');
}
}
if !buf.is_empty() {
queue!(stdout, Print(&buf))?;
buf.clear();
}
}
queue!(stdout, ResetColor, SetAttribute(Attribute::Reset))?;
Ok(())
}
pub fn draw_status_bar(
stdout: &mut impl Write,
term_w: u16,
term_h: u16,
active_idx: usize,
total: usize,
mode_label: &str,
theme: &AdaptedTheme,
) -> anyhow::Result<()> {
draw_status_bar_full(
stdout, term_w, term_h, active_idx, total, mode_label, "", 0, theme,
)
}
#[allow(clippy::too_many_arguments)]
pub fn draw_status_bar_full(
stdout: &mut impl Write,
term_w: u16,
term_h: u16,
active_idx: usize,
total: usize,
mode_label: &str,
pane_name: &str,
selection_chars: usize,
theme: &AdaptedTheme,
) -> anyhow::Result<()> {
let y = term_h - 1;
let w = term_w as usize;
queue!(
stdout,
cursor::MoveTo(0, y),
SetBackgroundColor(theme.status_bg),
SetForegroundColor(theme.status_fg)
)?;
for _ in 0..w {
queue!(stdout, Print(" "))?;
}
queue!(
stdout,
cursor::MoveTo(1, y),
SetForegroundColor(theme.active_color),
SetAttribute(Attribute::Bold)
)?;
let left = if pane_name.is_empty() {
format!("Pane {}/{}", active_idx + 1, total)
} else {
format!("Pane {}/{} {}", active_idx + 1, total, pane_name)
};
queue!(stdout, Print(&left))?;
let mut left_end = 1 + left.len();
if selection_chars > 0 {
let sel_label = format!("{} chars", selection_chars);
queue!(
stdout,
SetAttribute(Attribute::Reset),
SetBackgroundColor(theme.focus_bg),
SetForegroundColor(theme.accent),
SetAttribute(Attribute::Bold),
Print(format!(" {} ", sel_label)),
SetAttribute(Attribute::Reset),
SetBackgroundColor(theme.status_bg),
)?;
left_end += sel_label.len() + 2;
} else if !mode_label.is_empty() {
queue!(
stdout,
SetAttribute(Attribute::Reset),
SetBackgroundColor(theme.focus_bg),
SetForegroundColor(theme.warn_fg),
SetAttribute(Attribute::Bold),
Print(format!(" {} ", mode_label)),
SetAttribute(Attribute::Reset),
SetBackgroundColor(theme.status_bg),
)?;
left_end += mode_label.len() + 2;
}
let clock = {
let now = now_hhmm();
format!(" {} ", now)
};
let clock_len = clock.len();
queue!(
stdout,
SetAttribute(Attribute::Reset),
SetBackgroundColor(theme.status_bg)
)?;
let hints: &[&str] = match mode_label {
"PREFIX" => &[
"c new-tab",
"n/p next/prev-tab",
"%/\" split H/V",
"o next-pane",
"←↑↓→ navigate",
"z zoom",
"B broadcast",
"R resize",
"[ scroll",
"d detach",
"x close-pane",
"& close-tab",
"? help",
],
"RESIZE" => &["←→↑↓/hjkl resize pane", "q/Esc exit resize"],
"COPY" => &[
"hjkl move",
"v select",
"V line-select",
"y copy",
"/? search",
"n/N next/prev",
"w/b word",
"q exit",
],
"VISUAL" | "V-LINE" => &["hjkl extend", "y copy+exit", "v/V toggle", "Esc cancel"],
"SEARCH" => &["type query", "Enter find", "n/N next/prev", "Esc cancel"],
"SELECT" => &["1-9 jump to pane", "0 for 10th", "any key cancel"],
"KILL SESSION? y/n" => &["y kill session", "any key cancel"],
"CLOSE PANE? y/n" => &["y close pane", "any key cancel"],
"CLOSE TAB? y/n" => &["y close tab", "any key cancel"],
"ZOOM" => &["Ctrl+B z unzoom", "Ctrl+D/E split", "type normally"],
"BROADCAST" => &["typing in ALL panes", "Ctrl+B B stop broadcast"],
_ => &[
"Ctrl+D/E split",
"Ctrl+N next",
"Ctrl+B prefix",
"drag text→copy",
"scroll↕output",
"Ctrl+G settings",
"Ctrl+B ? help",
],
};
let separator = " ";
let sep_len = separator.len();
let max_w = w.saturating_sub(left_end + 4 + clock_len);
let mut fitted: Vec<&str> = Vec::new();
let mut total_len = 0usize;
for hint in hints.iter() {
let added = if fitted.is_empty() {
hint.len()
} else {
sep_len + hint.len()
};
if total_len + added <= max_w {
total_len += added;
fitted.push(hint);
} else {
break;
}
}
if !fitted.is_empty() {
let rx = (w as u16).saturating_sub(total_len as u16 + clock_len as u16 + 1);
queue!(stdout, cursor::MoveTo(rx, y))?;
let key_fg = theme.lbl_fg;
let desc_fg = theme.muted_fg;
for (i, hint) in fitted.iter().enumerate() {
if i > 0 {
queue!(stdout, SetForegroundColor(desc_fg), Print(separator))?;
}
if let Some(sp) = hint.find(' ') {
let (key, desc) = hint.split_at(sp);
queue!(
stdout,
SetForegroundColor(key_fg),
SetAttribute(Attribute::Bold),
Print(key),
SetAttribute(Attribute::Reset),
SetBackgroundColor(theme.status_bg),
SetForegroundColor(desc_fg),
Print(desc),
)?;
} else {
queue!(
stdout,
SetForegroundColor(key_fg),
SetAttribute(Attribute::Bold),
Print(*hint),
SetAttribute(Attribute::Reset),
SetBackgroundColor(theme.status_bg),
)?;
}
}
}
if clock_len + 1 < w {
let cx = (w as u16).saturating_sub(clock_len as u16);
queue!(
stdout,
cursor::MoveTo(cx, y),
SetBackgroundColor(theme.status_bg),
SetForegroundColor(theme.hint_fg),
Print(&clock),
)?;
}
queue!(stdout, ResetColor, SetAttribute(Attribute::Reset))?;
Ok(())
}
pub fn draw_text_input(
stdout: &mut impl Write,
term_w: u16,
term_h: u16,
prompt: &str,
buffer: &str,
theme: &AdaptedTheme,
) -> anyhow::Result<()> {
let y = term_h - 1;
let w = term_w as usize;
let input_bg = theme.focus_bg;
let prompt_fg = theme.accent;
let text_fg = theme.status_fg;
let cursor_bg = theme.sec_fg;
queue!(stdout, cursor::MoveTo(0, y), SetBackgroundColor(input_bg),)?;
for _ in 0..w {
queue!(stdout, Print(" "))?;
}
queue!(
stdout,
cursor::MoveTo(1, y),
SetBackgroundColor(input_bg),
SetForegroundColor(prompt_fg),
SetAttribute(Attribute::Bold),
Print(prompt),
SetAttribute(Attribute::Reset),
SetBackgroundColor(input_bg),
SetForegroundColor(text_fg),
Print(buffer),
)?;
let cursor_x = 1 + prompt.len() as u16 + buffer.len() as u16;
if (cursor_x as usize) < w {
queue!(
stdout,
cursor::MoveTo(cursor_x, y),
SetBackgroundColor(cursor_bg),
Print(" "),
)?;
}
queue!(stdout, ResetColor, SetAttribute(Attribute::Reset))?;
Ok(())
}
pub fn tab_bar_y(term_h: u16, show_status_bar: bool) -> u16 {
if show_status_bar {
term_h.saturating_sub(2)
} else {
term_h.saturating_sub(1)
}
}
pub fn tab_bar_hit(x: u16, tabs: &[(usize, String, bool)], term_w: u16) -> Option<usize> {
if tabs.len() <= 1 {
return None;
}
let w = term_w as usize;
let mut col = 2usize;
for (idx, name, _) in tabs {
let name_w = UnicodeWidthStr::width(name.as_str());
let tab_width = 4 + name_w + 2; if col + tab_width >= w {
break;
}
if (x as usize) >= col && (x as usize) < col + tab_width {
return Some(*idx);
}
col += tab_width + 2; }
None
}
pub fn draw_tab_bar(
stdout: &mut impl Write,
term_w: u16,
term_h: u16,
tabs: &[(usize, String, bool)],
show_status_bar: bool,
theme: &AdaptedTheme,
) -> anyhow::Result<()> {
if tabs.len() <= 1 {
return Ok(());
}
let y = tab_bar_y(term_h, show_status_bar);
let w = term_w as usize;
let tab_bg = theme.bg;
let active_tab_bg = theme.focus_bg;
let active_tab_fg = theme.lbl_fg;
let inactive_fg = theme.muted_fg;
let index_fg = theme.accent;
let sep_fg = theme.div_fg;
queue!(stdout, cursor::MoveTo(0, y), SetBackgroundColor(tab_bg),)?;
for _ in 0..w {
queue!(stdout, Print(" "))?;
}
queue!(stdout, cursor::MoveTo(1, y))?;
let mut col = 2usize;
for (idx, name, is_active) in tabs {
let name_w = UnicodeWidthStr::width(name.as_str());
let tab_width = 4 + name_w + 2; if col + tab_width + 1 >= w {
break;
}
let bg = if *is_active { active_tab_bg } else { tab_bg };
queue!(stdout, SetBackgroundColor(bg), SetForegroundColor(index_fg),)?;
if *is_active {
queue!(stdout, SetAttribute(Attribute::Bold))?;
}
queue!(stdout, Print(format!(" {}: ", idx + 1)))?;
queue!(
stdout,
SetForegroundColor(if *is_active {
active_tab_fg
} else {
inactive_fg
})
)?;
queue!(stdout, Print(name))?;
queue!(stdout, Print(" "))?;
if *is_active {
queue!(stdout, SetAttribute(Attribute::Reset))?;
}
queue!(
stdout,
SetBackgroundColor(tab_bg),
SetForegroundColor(sep_fg),
Print(" │"),
)?;
col += tab_width + 2; }
queue!(stdout, ResetColor, SetAttribute(Attribute::Reset))?;
Ok(())
}
fn now_hhmm() -> String {
#[cfg(unix)]
{
let mut t: libc::time_t = 0;
unsafe { libc::time(&mut t) };
let mut tm: libc::tm = unsafe { std::mem::zeroed() };
let result = unsafe { libc::localtime_r(&t, &mut tm) };
if result.is_null() {
return "--:--".to_string();
}
format!("{:02}:{:02}", tm.tm_hour, tm.tm_min)
}
#[cfg(not(unix))]
{
"--:--".to_string()
}
}
pub enum TitleAction {
Close(usize),
SplitH(usize),
SplitV(usize),
}
pub fn title_button_hit(x: u16, y: u16, layout: &Layout, inner: &Rect) -> Option<TitleAction> {
let rects = layout.pane_rects(inner);
for (&pid, rect) in &rects {
let btn_y = rect.y.saturating_sub(1);
if y != btn_y {
continue;
}
let avail = rect.w as usize;
if avail >= 13 {
let end = rect.x + rect.w; if x >= end.saturating_sub(3) && x < end {
return Some(TitleAction::Close(pid));
}
if x >= end.saturating_sub(7) && x < end.saturating_sub(4) {
return Some(TitleAction::SplitV(pid));
}
if x >= end.saturating_sub(11) && x < end.saturating_sub(8) {
return Some(TitleAction::SplitH(pid));
}
} else if avail >= 4 {
let btn_x = rect.x + rect.w - 1;
if x == btn_x || x == btn_x.saturating_sub(1) {
return Some(TitleAction::Close(pid));
}
}
}
None
}
#[allow(clippy::too_many_arguments)]
pub fn render_zoomed_pane(
stdout: &mut impl Write,
pane: &Pane,
pane_idx: usize,
label: &str,
border_style: BorderStyle,
term_w: u16,
term_h: u16,
show_status_bar: bool,
theme: &AdaptedTheme,
) -> anyhow::Result<()> {
queue!(stdout, cursor::Hide, terminal::Clear(ClearType::All))?;
let chars = border_style.chars();
let status_h = if show_status_bar { 1u16 } else { 0 };
let border_h = term_h.saturating_sub(status_h);
if term_w == 0 || border_h == 0 {
return Ok(());
}
let mut bmap = BorderMap::new();
if term_w > 0 && border_h > 0 {
bmap.add_h_line(0, term_w - 1, 0);
bmap.add_h_line(0, term_w - 1, border_h - 1);
bmap.add_v_line(0, 0, border_h - 1);
bmap.add_v_line(term_w - 1, 0, border_h - 1);
}
for ((x, y), flags) in &bmap.cells {
queue!(
stdout,
cursor::MoveTo(*x, *y),
SetForegroundColor(theme.active_color),
Print(border_char(flags, &chars))
)?;
}
let title = format!(" {}:{} [ZOOM] ", pane_idx + 1, label);
let avail = term_w.saturating_sub(2) as usize;
if avail > title.len() + 1 {
queue!(
stdout,
cursor::MoveTo(1, 0),
SetForegroundColor(theme.active_color),
Print(chars.h),
SetForegroundColor(theme.status_fg),
SetAttribute(Attribute::Bold),
Print(&title),
SetAttribute(Attribute::Reset),
SetForegroundColor(theme.active_color),
)?;
for _ in 0..avail - title.len() - 1 {
queue!(stdout, Print(chars.h))?;
}
}
let rect = Rect {
x: 1,
y: 1,
w: term_w.saturating_sub(2),
h: border_h.saturating_sub(2),
};
draw_content(stdout, pane, &rect, pane.is_alive(), None, theme)?;
if pane.is_alive() {
let screen = pane.screen();
let (cr, cc) = screen.cursor_position();
if cc < rect.w && cr < rect.h {
queue!(
stdout,
cursor::MoveTo(rect.x + cc, rect.y + cr),
cursor::Show
)?;
}
}
queue!(stdout, ResetColor, SetAttribute(Attribute::Reset))?;
Ok(())
}
pub fn draw_help_overlay(
stdout: &mut impl Write,
term_w: u16,
term_h: u16,
theme: &AdaptedTheme,
) -> anyhow::Result<()> {
let help_lines = [
"",
" DIRECT SHORTCUTS",
" Ctrl+D Split left|right",
" Ctrl+E Split top/bottom",
" Ctrl+N Next pane",
" Ctrl+G / F1 Settings panel",
" F2 Equalize all pane sizes",
" Alt+Arrow Navigate directional",
" Ctrl+W Quit",
"",
" PREFIX MODE (Ctrl+B then)",
" TABS:",
" c New tab",
" n / p Next / previous tab",
" 0-9 Jump to tab by number",
" & Close current tab",
" PANES:",
" % / \" Split H / V",
" o / Arrow Next / navigate pane",
" x Close pane",
" z Zoom toggle",
" B Broadcast mode (type in all)",
" R Resize mode (arrows/hjkl, q)",
" { } Swap pane prev/next",
" [ Copy mode (hjkl/v/y/search, q)",
" d Detach session",
" s Toggle status bar",
" ? This help",
"",
" MOUSE",
" Click Select pane",
" Double-click Zoom toggle",
" Drag border Resize panes",
" Drag text Select & copy to clipboard",
" Scroll wheel Scrollback history",
" [━][┃][×] Split/close buttons",
"",
" FEATURES",
" .ezpn.toml Project config (ezpn init)",
" Layout DSL -l '7:3/5:5' or presets",
" Auto-restart restart = always|on_failure",
" Broadcast Type in all panes at once",
" Workspaces Save/load via ezpn-ctl",
"",
" Press any key to close",
];
let w: usize = 50;
let h = help_lines.len() + 2; let ox = term_w.saturating_sub(w as u16) / 2;
let oy = term_h.saturating_sub(h as u16) / 2;
let bg = theme.bg;
let border_fg = theme.sec_fg;
queue!(
stdout,
SetBackgroundColor(theme.bg),
terminal::Clear(ClearType::All)
)?;
let blank = " ".repeat(w);
for dy in 0..h as u16 {
queue!(
stdout,
cursor::MoveTo(ox, oy + dy),
SetBackgroundColor(bg),
Print(&blank)
)?;
}
queue!(
stdout,
cursor::MoveTo(ox, oy),
SetBackgroundColor(bg),
SetForegroundColor(border_fg),
)?;
let title = " Help (Ctrl+B ?) ";
let pad = w.saturating_sub(title.len() + 2);
let lp = pad / 2;
let rp = pad - lp;
queue!(
stdout,
Print("─".repeat(lp)),
SetForegroundColor(theme.status_fg),
SetAttribute(Attribute::Bold),
Print(title),
SetAttribute(Attribute::Reset),
SetForegroundColor(border_fg),
SetBackgroundColor(bg),
Print("─".repeat(rp)),
)?;
for (i, line) in help_lines.iter().enumerate() {
let y = oy + 1 + i as u16;
queue!(stdout, cursor::MoveTo(ox, y), SetBackgroundColor(bg))?;
if line.contains("SHORTCUTS")
|| line.contains("PREFIX MODE")
|| line.contains("MOUSE")
|| line.contains("FEATURES")
{
queue!(
stdout,
SetForegroundColor(theme.accent),
SetAttribute(Attribute::Bold),
Print(format!("{:<width$}", line, width = w)),
SetAttribute(Attribute::Reset),
)?;
} else if line.contains("Press any key") {
queue!(
stdout,
SetForegroundColor(theme.dim_fg),
Print(format!("{:<width$}", line, width = w)),
)?;
} else {
queue!(
stdout,
SetForegroundColor(theme.lbl_fg),
Print(format!("{:<width$}", line, width = w)),
)?;
}
}
queue!(
stdout,
cursor::MoveTo(ox, oy + h as u16 - 1),
SetBackgroundColor(bg),
SetForegroundColor(border_fg),
Print("─".repeat(w)),
)?;
queue!(
stdout,
ResetColor,
SetAttribute(Attribute::Reset),
cursor::Hide
)?;
Ok(())
}
pub fn draw_pane_numbers(
stdout: &mut impl Write,
layout: &Layout,
inner: &Rect,
theme: &AdaptedTheme,
) -> anyhow::Result<()> {
let rects = layout.pane_rects(inner);
let ids = layout.pane_ids();
for (display_idx, &pid) in ids.iter().enumerate() {
let Some(num) = quick_jump_label(display_idx) else {
continue;
};
if let Some(rect) = rects.get(&pid) {
let num = num.to_string();
let num_w = num.len() as u16;
if rect.w < num_w + 2 || rect.h < 3 {
continue;
}
let cx = rect.x + (rect.w - num_w - 2) / 2;
let cy = rect.y + rect.h / 2 - 1;
let bg = theme.bg;
let fg = theme.accent;
let box_w = (num_w + 4) as usize;
for dy in 0..3u16 {
queue!(
stdout,
cursor::MoveTo(cx, cy + dy),
SetBackgroundColor(bg),
Print(" ".repeat(box_w)),
)?;
}
queue!(
stdout,
cursor::MoveTo(cx + 2, cy + 1),
SetBackgroundColor(bg),
SetForegroundColor(fg),
SetAttribute(Attribute::Bold),
Print(&num),
SetAttribute(Attribute::Reset),
)?;
}
}
let hint = "Press 1-9 or 0 to jump, any other key to cancel";
let hx = inner.x + inner.w.saturating_sub(hint.len() as u16) / 2;
let hy = inner.y + inner.h;
queue!(
stdout,
cursor::MoveTo(hx, hy),
SetForegroundColor(theme.dim_fg),
Print(hint),
ResetColor,
cursor::Hide,
)?;
Ok(())
}
fn quick_jump_label(index: usize) -> Option<char> {
match index {
0..=8 => char::from_u32('1' as u32 + index as u32),
9 => Some('0'),
_ => None,
}
}