use std::collections::{HashMap, HashSet};
use std::io::Write;
use unicode_width::UnicodeWidthStr;
use crossterm::{
cursor, queue,
style::{
Attribute, Color, Print, ResetColor, SetAttribute, SetBackgroundColor, SetForegroundColor,
},
terminal::{self, ClearType},
};
use serde::{Deserialize, Serialize};
use crate::fuzzy;
use crate::layout::{Layout, Rect};
use crate::pane::Pane;
use crate::theme::{Resolved, ResolvedPalette};
pub fn resolved_to_crossterm(resolved: &Resolved) -> Color {
match *resolved {
Resolved::Rgb(c) => Color::Rgb {
r: c.r,
g: c.g,
b: c.b,
},
Resolved::Indexed(i) => Color::AnsiValue(i),
}
}
fn palette_color(palette: Option<&ResolvedPalette>, pick: PaletteSlot) -> Color {
match (palette, pick) {
(Some(p), PaletteSlot::BorderActive) => resolved_to_crossterm(&p.border_active),
(Some(p), PaletteSlot::Border) => resolved_to_crossterm(&p.border),
(Some(p), PaletteSlot::StatusBg) => resolved_to_crossterm(&p.status_bg),
(Some(p), PaletteSlot::StatusFg) => resolved_to_crossterm(&p.status_fg),
(Some(p), PaletteSlot::Broadcast) => resolved_to_crossterm(&p.broadcast_indicator),
(Some(p), PaletteSlot::DeadFg) => resolved_to_crossterm(&p.tab_inactive_fg),
(None, PaletteSlot::BorderActive) => ACTIVE_COLOR,
(None, PaletteSlot::Border) => BORDER_COLOR,
(None, PaletteSlot::StatusBg) => STATUS_BG,
(None, PaletteSlot::StatusFg) => STATUS_FG,
(None, PaletteSlot::Broadcast) => BROADCAST_COLOR,
(None, PaletteSlot::DeadFg) => DEAD_FG,
}
}
#[derive(Clone, Copy)]
enum PaletteSlot {
BorderActive,
Border,
StatusBg,
StatusFg,
Broadcast,
#[allow(dead_code)]
DeadFg,
}
const ACTIVE_COLOR: Color = Color::Cyan;
const BORDER_COLOR: Color = Color::DarkGrey;
const STATUS_BG: Color = Color::Rgb {
r: 36,
g: 38,
b: 48,
};
const STATUS_FG: Color = Color::White;
const HINT_FG: Color = Color::Rgb {
r: 160,
g: 170,
b: 190,
};
const CLOSE_COLOR: Color = Color::DarkRed;
const DEAD_FG: Color = Color::DarkGrey;
const BROADCAST_COLOR: Color = Color::Rgb {
r: 255,
g: 140,
b: 50,
};
const DRAG_COLOR: Color = Color::Yellow;
const MUTED_FG: Color = Color::Rgb {
r: 100,
g: 100,
b: 110,
};
#[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,
palette: Option<&ResolvedPalette>,
) -> 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(Color::Red),
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);
let border_active_color = palette_color(palette, PaletteSlot::BorderActive);
let border_color = palette_color(palette, PaletteSlot::Border);
let broadcast_color = palette_color(palette, PaletteSlot::Broadcast);
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() {
Color::Rgb {
r: 70,
g: 75,
b: 90,
}
} else if dragging_sep {
DRAG_COLOR
} else if broadcast {
broadcast_color
} else if is_active {
border_active_color
} else {
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,
)?;
}
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)?;
}
if !is_alive {
draw_dead_overlay(stdout, rect)?;
}
}
}
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(), "")?;
}
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 = " ".repeat(rect.w as usize);
for row in 0..rect.h {
queue!(
stdout,
cursor::MoveTo(rect.x, rect.y + row),
ResetColor,
Print(&blanks)
)?;
}
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 = " ".repeat(rect.w as usize);
queue!(stdout, cursor::MoveTo(x, y), ResetColor, Print(&blanks))?;
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>,
) -> 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 {
ACTIVE_COLOR
} else {
BORDER_COLOR
};
if borderless {
let title_bg = if is_active {
Color::Rgb {
r: 30,
g: 34,
b: 46,
}
} else {
Color::Rgb {
r: 18,
g: 20,
b: 28,
}
};
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(Color::White),
SetAttribute(Attribute::Bold)
)?;
}
if !is_alive {
queue!(stdout, SetForegroundColor(DEAD_FG))?;
}
queue!(stdout, Print(&title))?;
queue!(stdout, SetAttribute(Attribute::Reset))?;
if borderless {
let title_bg = if is_active {
Color::Rgb {
r: 30,
g: 34,
b: 46,
}
} else {
Color::Rgb {
r: 18,
g: 20,
b: 28,
}
};
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 { MUTED_FG } else { 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(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(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) -> anyhow::Result<()> {
if rect.w < 5 || rect.h < 1 {
return Ok(());
}
let dim_bg = Color::Rgb {
r: 10,
g: 10,
b: 14,
};
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(Color::Rgb {
r: 120,
g: 60,
b: 60
}),
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(Color::DarkGrey),
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)>,
) -> 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 { Color::Black } else { f },
if b == Color::Reset { Color::White } else { b },
)
} else {
(
vt100_to_crossterm(cell.fgcolor()),
vt100_to_crossterm(cell.bgcolor()),
)
};
if !is_alive {
fg = Color::DarkGrey;
}
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,
) -> anyhow::Result<()> {
draw_status_bar_full(
stdout, term_w, term_h, active_idx, total, mode_label, "", 0, None,
)
}
#[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,
palette: Option<&ResolvedPalette>,
) -> anyhow::Result<()> {
let y = term_h - 1;
let w = term_w as usize;
let status_bg = palette_color(palette, PaletteSlot::StatusBg);
let status_fg = palette_color(palette, PaletteSlot::StatusFg);
let accent = palette_color(palette, PaletteSlot::BorderActive);
queue!(
stdout,
cursor::MoveTo(0, y),
SetBackgroundColor(status_bg),
SetForegroundColor(status_fg)
)?;
for _ in 0..w {
queue!(stdout, Print(" "))?;
}
queue!(
stdout,
cursor::MoveTo(1, y),
SetForegroundColor(accent),
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(Color::Rgb {
r: 40,
g: 20,
b: 60
}),
SetForegroundColor(Color::Rgb {
r: 200,
g: 160,
b: 255
}),
SetAttribute(Attribute::Bold),
Print(format!(" {} ", sel_label)),
SetAttribute(Attribute::Reset),
SetBackgroundColor(status_bg),
)?;
left_end += sel_label.len() + 2;
} else if !mode_label.is_empty() {
queue!(
stdout,
SetAttribute(Attribute::Reset),
SetBackgroundColor(Color::Rgb {
r: 60,
g: 40,
b: 10
}),
SetForegroundColor(Color::Rgb {
r: 255,
g: 200,
b: 50
}),
SetAttribute(Attribute::Bold),
Print(format!(" {} ", mode_label)),
SetAttribute(Attribute::Reset),
SetBackgroundColor(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(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"],
":" => &["↑↓ navigate", "Enter select", "Tab complete", "Esc cancel"],
"RENAME" => &["Enter confirm", "Esc cancel"],
_ => &[
"Ctrl+D/E split",
"Ctrl+N next",
"Ctrl+B prefix",
"Ctrl+B p palette",
"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 = Color::Rgb {
r: 220,
g: 225,
b: 240,
};
let desc_fg = Color::Rgb {
r: 120,
g: 130,
b: 150,
};
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(status_bg),
SetForegroundColor(desc_fg),
Print(desc),
)?;
} else {
queue!(
stdout,
SetForegroundColor(key_fg),
SetAttribute(Attribute::Bold),
Print(*hint),
SetAttribute(Attribute::Reset),
SetBackgroundColor(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(status_bg),
SetForegroundColor(HINT_FG),
Print(&clock),
)?;
}
queue!(stdout, ResetColor, SetAttribute(Attribute::Reset))?;
Ok(())
}
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FlashLevel {
Info,
Error,
}
#[allow(dead_code)]
pub fn draw_flash_overlay(
stdout: &mut impl Write,
term_w: u16,
term_h: u16,
msg: &str,
level: FlashLevel,
) -> anyhow::Result<()> {
if term_w == 0 || term_h == 0 {
return Ok(());
}
let y = term_h - 1;
let (bg, fg) = match level {
FlashLevel::Info => (
Color::Rgb {
r: 30,
g: 90,
b: 50,
},
Color::Rgb {
r: 220,
g: 255,
b: 220,
},
),
FlashLevel::Error => (
Color::Rgb {
r: 120,
g: 30,
b: 30,
},
Color::Rgb {
r: 255,
g: 220,
b: 220,
},
),
};
let max_inner = (term_w as usize).saturating_sub(4);
let truncated: String = if msg.width() > max_inner {
let mut acc = String::new();
let mut w = 0usize;
for ch in msg.chars() {
let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
if w + cw > max_inner.saturating_sub(1) {
acc.push('…');
break;
}
acc.push(ch);
w += cw;
}
acc
} else {
msg.to_string()
};
let pill = format!(" {} ", truncated);
let pill_len = pill.width() as u16;
let x = if pill_len + 2 < term_w { 2 } else { 0 };
queue!(
stdout,
cursor::MoveTo(x, y),
SetBackgroundColor(bg),
SetForegroundColor(fg),
SetAttribute(Attribute::Bold),
Print(&pill),
SetAttribute(Attribute::Reset),
ResetColor,
)?;
Ok(())
}
pub fn draw_text_input(
stdout: &mut impl Write,
term_w: u16,
term_h: u16,
prompt: &str,
buffer: &str,
) -> anyhow::Result<()> {
let y = term_h - 1;
let w = term_w as usize;
let input_bg = Color::Rgb {
r: 30,
g: 35,
b: 50,
};
let prompt_fg = Color::Rgb {
r: 102,
g: 217,
b: 239,
};
let text_fg = Color::White;
let cursor_bg = Color::Rgb {
r: 80,
g: 90,
b: 120,
};
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 struct PaletteOverlayState<'a> {
pub query: &'a str,
pub matches: &'a [fuzzy::Match],
pub selected: usize,
pub entries: &'a [fuzzy::Entry],
}
pub fn draw_palette_overlay(
stdout: &mut impl Write,
state: &PaletteOverlayState<'_>,
term_width: u16,
term_height: u16,
palette: Option<&ResolvedPalette>,
) -> anyhow::Result<()> {
let matches_ = state.matches;
let entries = state.entries;
let query = state.query;
let selected = state.selected;
if term_width < 10 || term_height < 8 {
return Ok(());
}
const OVERLAY_ROWS: u16 = 8;
let top = term_height.saturating_sub(OVERLAY_ROWS);
let w = term_width as usize;
let bg = Color::Rgb {
r: 18,
g: 22,
b: 32,
};
let prompt_fg = palette
.map(|p| resolved_to_crossterm(&p.border_active))
.unwrap_or(Color::Rgb {
r: 102,
g: 217,
b: 239,
});
let select_bg = palette
.map(|p| resolved_to_crossterm(&p.border_active))
.unwrap_or(Color::Rgb {
r: 50,
g: 90,
b: 130,
});
let select_fg = palette
.map(|p| resolved_to_crossterm(&p.tab_active_fg))
.unwrap_or(Color::White);
let row_fg = palette
.map(|p| resolved_to_crossterm(&p.fg))
.unwrap_or(Color::Rgb {
r: 220,
g: 225,
b: 240,
});
let dim_fg = Color::Rgb {
r: 110,
g: 120,
b: 140,
};
let kind_fg = palette
.map(|p| resolved_to_crossterm(&p.tab_inactive_fg))
.unwrap_or(Color::Rgb {
r: 130,
g: 165,
b: 200,
});
let blank = " ".repeat(w);
for dy in 0..OVERLAY_ROWS {
queue!(
stdout,
cursor::MoveTo(0, top + dy),
SetBackgroundColor(bg),
Print(&blank)
)?;
}
queue!(
stdout,
cursor::MoveTo(1, top),
SetBackgroundColor(bg),
SetForegroundColor(prompt_fg),
SetAttribute(Attribute::Bold),
Print(":"),
SetAttribute(Attribute::Reset),
SetBackgroundColor(bg),
SetForegroundColor(row_fg),
Print(" "),
Print(query),
)?;
let visible_rows = (OVERLAY_ROWS - 2) as usize; if matches_.is_empty() {
queue!(
stdout,
cursor::MoveTo(2, top + 2),
SetBackgroundColor(bg),
SetForegroundColor(dim_fg),
SetAttribute(Attribute::Italic),
Print("(no matches)"),
SetAttribute(Attribute::Reset),
)?;
} else {
for (i, m) in matches_.iter().take(visible_rows).enumerate() {
let row_y = top + 2 + i as u16;
let is_sel = i == selected;
let row_bg = if is_sel { select_bg } else { bg };
let label_fg = if is_sel { select_fg } else { row_fg };
queue!(
stdout,
cursor::MoveTo(0, row_y),
SetBackgroundColor(row_bg),
Print(&blank),
cursor::MoveTo(2, row_y),
)?;
let entry = entries.get(m.index);
let icon = match entry.map(|e| e.kind) {
Some(fuzzy::EntryKind::Session) => '@',
Some(fuzzy::EntryKind::Pane) => '#',
Some(fuzzy::EntryKind::Tab) => 'T',
Some(fuzzy::EntryKind::Command) => '>',
Some(fuzzy::EntryKind::Recent) => '*',
None => '?',
};
queue!(
stdout,
SetForegroundColor(if is_sel { select_fg } else { kind_fg }),
SetAttribute(Attribute::Bold),
Print(icon),
SetAttribute(Attribute::Reset),
SetBackgroundColor(row_bg),
SetForegroundColor(label_fg),
Print(" "),
)?;
let display = entry.map(|e| e.display.as_str()).unwrap_or("?");
queue!(stdout, Print(display))?;
if let Some(e) = entry {
if e.payload != e.display {
let tail = format!(" {}", e.payload);
queue!(
stdout,
SetForegroundColor(if is_sel { select_fg } else { dim_fg }),
Print(&tail),
)?;
}
}
}
}
queue!(stdout, ResetColor, SetAttribute(Attribute::Reset))?;
Ok(())
}
pub fn draw_osc52_confirm_overlay(
stdout: &mut impl Write,
pane_id: usize,
byte_count: usize,
palette: Option<&ResolvedPalette>,
term_w: u16,
term_h: u16,
) -> anyhow::Result<()> {
if term_w == 0 || term_h == 0 {
return Ok(());
}
let y = term_h.saturating_sub(1);
let bg = Color::Rgb {
r: 130,
g: 90,
b: 20,
};
let fg = palette
.map(|p| resolved_to_crossterm(&p.tab_active_fg))
.unwrap_or(Color::Rgb {
r: 255,
g: 240,
b: 200,
});
let msg = format!(" OSC52 pane #{pane_id}: allow {byte_count} bytes to clipboard? [y/n/Esc] ");
let max_inner = (term_w as usize).saturating_sub(2);
let truncated: String = if msg.width() > max_inner {
let mut acc = String::new();
let mut w = 0usize;
for ch in msg.chars() {
let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
if w + cw > max_inner.saturating_sub(1) {
acc.push('…');
break;
}
acc.push(ch);
w += cw;
}
acc
} else {
msg
};
queue!(
stdout,
cursor::MoveTo(0, y),
SetBackgroundColor(bg),
SetForegroundColor(fg),
SetAttribute(Attribute::Bold),
Print(&truncated),
SetAttribute(Attribute::Reset),
ResetColor,
cursor::Hide,
)?;
Ok(())
}
pub fn draw_flash_message(
stdout: &mut impl Write,
term_w: u16,
term_h: u16,
text: &str,
) -> anyhow::Result<()> {
let y = term_h.saturating_sub(1);
let w = term_w as usize;
let bg = Color::Rgb {
r: 60,
g: 30,
b: 10,
};
let fg = Color::Rgb {
r: 255,
g: 200,
b: 80,
};
queue!(stdout, cursor::MoveTo(0, y), SetBackgroundColor(bg))?;
for _ in 0..w {
queue!(stdout, Print(" "))?;
}
queue!(
stdout,
cursor::MoveTo(1, y),
SetForegroundColor(fg),
SetAttribute(Attribute::Bold),
Print(text),
SetAttribute(Attribute::Reset),
ResetColor,
)?;
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,
palette: Option<&ResolvedPalette>,
) -> 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 = Color::Rgb {
r: 24,
g: 26,
b: 34,
};
let active_tab_bg = palette
.map(|p| resolved_to_crossterm(&p.tab_active_bg))
.unwrap_or(Color::Rgb {
r: 50,
g: 55,
b: 70,
});
let active_tab_fg = palette
.map(|p| resolved_to_crossterm(&p.tab_active_fg))
.unwrap_or(Color::Rgb {
r: 220,
g: 225,
b: 240,
});
let inactive_fg = palette
.map(|p| resolved_to_crossterm(&p.tab_inactive_fg))
.unwrap_or(Color::Rgb {
r: 100,
g: 110,
b: 130,
});
let index_fg = palette
.map(|p| resolved_to_crossterm(&p.border_active))
.unwrap_or(Color::Rgb {
r: 80,
g: 180,
b: 220,
});
let sep_fg = Color::Rgb {
r: 50,
g: 55,
b: 65,
};
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(())
}
#[allow(clippy::too_many_arguments)]
#[allow(dead_code)]
pub fn redraw_status_only(
stdout: &mut impl Write,
term_w: u16,
term_h: u16,
active_idx: usize,
total: usize,
mode_label: &str,
pane_name: &str,
selection_chars: usize,
palette: Option<&ResolvedPalette>,
) -> anyhow::Result<()> {
queue!(stdout, terminal::BeginSynchronizedUpdate)?;
draw_status_bar_full(
stdout,
term_w,
term_h,
active_idx,
total,
mode_label,
pane_name,
selection_chars,
palette,
)?;
queue!(stdout, cursor::Hide, terminal::EndSynchronizedUpdate)?;
Ok(())
}
#[allow(dead_code)]
pub fn redraw_tabs_only(
stdout: &mut impl Write,
term_w: u16,
term_h: u16,
tabs: &[(usize, String, bool)],
show_status_bar: bool,
palette: Option<&ResolvedPalette>,
) -> anyhow::Result<()> {
queue!(stdout, terminal::BeginSynchronizedUpdate)?;
draw_tab_bar(stdout, term_w, term_h, tabs, show_status_bar, palette)?;
queue!(stdout, cursor::Hide, terminal::EndSynchronizedUpdate)?;
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,
) -> 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(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(ACTIVE_COLOR),
Print(chars.h),
SetForegroundColor(Color::White),
SetAttribute(Attribute::Bold),
Print(&title),
SetAttribute(Attribute::Reset),
SetForegroundColor(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)?;
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) -> 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 = Color::Rgb {
r: 16,
g: 18,
b: 24,
};
let border_fg = Color::Rgb {
r: 80,
g: 90,
b: 110,
};
queue!(
stdout,
SetBackgroundColor(Color::Rgb { r: 4, g: 5, b: 8 }),
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(Color::White),
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(Color::Rgb {
r: 102,
g: 217,
b: 239
}),
SetAttribute(Attribute::Bold),
Print(format!("{:<width$}", line, width = w)),
SetAttribute(Attribute::Reset),
)?;
} else if line.contains("Press any key") {
queue!(
stdout,
SetForegroundColor(Color::Rgb {
r: 90,
g: 98,
b: 110
}),
Print(format!("{:<width$}", line, width = w)),
)?;
} else {
queue!(
stdout,
SetForegroundColor(Color::Rgb {
r: 190,
g: 200,
b: 212,
}),
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,
) -> 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 = Color::Rgb {
r: 20,
g: 24,
b: 32,
};
let fg = Color::Rgb {
r: 102,
g: 217,
b: 239,
};
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(Color::Rgb {
r: 90,
g: 98,
b: 110,
}),
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,
}
}
fn vt100_to_crossterm(color: vt100::Color) -> Color {
match color {
vt100::Color::Default => Color::Reset,
vt100::Color::Idx(i) => Color::AnsiValue(i),
vt100::Color::Rgb(r, g, b) => Color::Rgb { r, g, b },
}
}
#[cfg(test)]
mod partial_redraw_tests {
use super::*;
#[test]
fn redraw_status_only_emits_bytes_and_hides_cursor() {
let mut buf: Vec<u8> = Vec::new();
redraw_status_only(&mut buf, 80, 24, 0, 1, "PREFIX", "shell", 0, None)
.expect("redraw_status_only succeeds");
assert!(!buf.is_empty(), "status-only redraw must emit output");
let s = String::from_utf8_lossy(&buf);
assert!(
s.contains("\x1b[?25l"),
"status-only redraw must hide the cursor at the end"
);
}
#[test]
fn redraw_status_only_handles_empty_mode_label() {
let mut buf: Vec<u8> = Vec::new();
redraw_status_only(&mut buf, 80, 24, 0, 1, "", "", 0, None).expect("succeeds");
assert!(!buf.is_empty());
}
#[test]
fn redraw_status_only_handles_palette_mode_label() {
let mut buf: Vec<u8> = Vec::new();
redraw_status_only(&mut buf, 80, 24, 0, 1, ":", "shell", 0, None)
.expect("palette mode succeeds");
assert!(!buf.is_empty(), "palette mode must emit output");
}
#[test]
fn redraw_status_only_handles_rename_mode_label() {
let mut buf: Vec<u8> = Vec::new();
redraw_status_only(&mut buf, 80, 24, 0, 1, "RENAME", "shell", 0, None)
.expect("rename mode succeeds");
assert!(!buf.is_empty(), "rename mode must emit output");
}
#[test]
fn redraw_tabs_only_emits_bytes_when_multiple_tabs() {
let tabs = vec![
(0_usize, "main".to_string(), true),
(1_usize, "logs".to_string(), false),
];
let mut buf: Vec<u8> = Vec::new();
redraw_tabs_only(&mut buf, 80, 24, &tabs, true, None).expect("succeeds");
assert!(!buf.is_empty(), "tab-only redraw must emit output");
let s = String::from_utf8_lossy(&buf);
assert!(s.contains("\x1b[?25l"), "must hide cursor at the end");
}
#[test]
fn redraw_tabs_only_is_noop_with_single_tab() {
let tabs = vec![(0_usize, "main".to_string(), true)];
let mut buf: Vec<u8> = Vec::new();
redraw_tabs_only(&mut buf, 80, 24, &tabs, true, None).expect("succeeds");
assert!(!buf.is_empty());
}
}