use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Widget};
use unicode_width::UnicodeWidthStr;
use super::overlays;
use super::ui::Mode;
use super::{Client, ClientId, Server};
use crate::agent::AgentState;
use crate::ansi::Cursor;
use crate::keymap::Action;
use crate::layout::PaneId;
fn th() -> crate::theme::Theme {
crate::theme::current()
}
#[derive(Debug, Clone, PartialEq)]
pub enum BarItem {
Workspace,
Tab(usize),
NewTab,
Text,
}
enum SideRow {
Section(&'static str),
Entry {
item: Option<SideItem>,
line: Line<'static>,
selected: bool,
},
Blank,
}
#[derive(Debug, Clone, PartialEq)]
pub enum SideItem {
Workspace(u32),
Definition(String),
Pane(PaneId),
Tunnel(u16),
Port(u16),
}
impl Server {
pub fn compose(&mut self, id: ClientId) -> (Buffer, Option<Cursor>, crate::ansi::Links) {
let c = &self.clients[&id];
let full = Rect::new(0, 0, c.cols.max(1), c.rows.max(1));
let mut buf = Buffer::empty(full);
if c.rows < 3 || c.cols < 10 {
buf.set_string(0, 0, "kraal: terminal too small", Style::default().fg(Color::Yellow));
return (buf, None, Default::default());
}
let sidebar = self.sidebar_shown(c);
let rows = c.rows;
let cols = c.cols;
let sw = if sidebar { self.sidebar_width(cols) } else { 0 };
if sw > 0 {
self.draw_sidebar(id, Rect::new(0, 0, sw, rows), &mut buf);
}
self.draw_top_bar(id, &mut buf);
let mut links = crate::ansi::Links::new();
let mut cursor = self.draw_panes(id, &mut buf, &mut links);
let c = &self.clients[&id];
let overlay_cursor = match &c.mode {
Mode::Picker(p) => Some(overlays::draw_picker(p, full, &mut buf)),
Mode::Prompt(p) => Some(overlays::draw_prompt(p, full, &mut buf)),
Mode::Help { scroll } => {
let keys = [("↑↓", "scroll"), ("esc", "close")];
overlays::draw_viewer("Help", &self.help_lines(), *scroll, &keys, full, &mut buf);
None
}
Mode::Messages { scroll } => {
let keys = [("↑↓", "scroll"), ("esc", "close")];
overlays::draw_viewer("Messages", &self.message_lines(), *scroll, &keys, full, &mut buf);
None
}
Mode::Confirm(q) => {
overlays::draw_confirm(q, full, &mut buf);
None
}
Mode::Hints(h) => {
let place = self.pane_inner_rect(id, h.pane);
match &h.snapshot {
Some(snap) if place == Some(snap.area) => {
buf.merge(snap);
self.draw_hints(id, h.pane, &h.hints, &h.typed, &mut buf);
}
Some(_) => {}
None => self.draw_hints(id, h.pane, &h.hints, &h.typed, &mut buf),
}
None
}
Mode::Leader { typed, miss } => {
let specs: Vec<crate::input::KeySpec> =
typed.iter().map(|k| crate::input::KeySpec::new(k.code, k.mods)).collect();
let mut title = self.keymap.prefix_label();
if !specs.is_empty() {
let keys: Vec<String> = specs.iter().map(|k| crate::keymap::pretty(k, th().ascii)).collect();
let named = self.keymap.group_title(&specs).unwrap_or("…");
title = format!("{title} {} · {named}", keys.join(" "));
}
let entries = self.keymap.menu(typed);
let items = overlays::menu_items(&entries);
let footer = overlays::menu_footer(&entries);
let layout = overlays::menu_layout(&items, full);
let back = !typed.is_empty();
overlays::draw_menu(&items, &layout, (&title, &footer), back, miss.as_deref(), &mut buf);
links.retain(|&(x, y), _| !layout.area.contains(ratatui::layout::Position::new(x, y)));
None
}
_ => None,
};
let c = &self.clients[&id];
match &c.mode {
Mode::Picker(_) | Mode::Prompt(_) => cursor = overlay_cursor.flatten(),
Mode::Leader { .. } | Mode::Help { .. } | Mode::Messages { .. } | Mode::Confirm(_) | Mode::Hints(_) => {
cursor = None
}
Mode::Search { .. } => {
cursor = self.top_bar(c).cursor.map(|x| Cursor {
x: x.min(c.cols.saturating_sub(1)),
y: 0,
shape: crate::ansi::CursorShape::Bar,
blinking: true,
});
}
_ => {}
}
if !matches!(
self.clients[&id].mode,
Mode::Normal | Mode::Leader { .. } | Mode::Resize | Mode::Scroll | Mode::Search { .. }
) {
links.clear();
}
(buf, cursor, links)
}
pub(super) fn pane_inner_rect(&self, id: ClientId, pane: PaneId) -> Option<Rect> {
let wi = self.ws_index(self.clients.get(&id)?.workspace)?;
let (_, outer, floating) =
self.tab_rects(self.workspaces[wi].tab(), self.layout_area).into_iter().find(|(p, _, _)| *p == pane)?;
Some(self.inner(outer, floating))
}
fn draw_hints(&self, id: ClientId, pane: PaneId, hints: &[crate::hints::Hint], typed: &str, buf: &mut Buffer) {
let c = &self.clients[&id];
let Some(wi) = self.ws_index(c.workspace) else { return };
let tab = self.workspaces[wi].tab();
let Some((_, outer, floating)) = self.tab_rects(tab, self.layout_area).into_iter().find(|(p, _, _)| *p == pane)
else {
return;
};
let (_, inner) = self.placed(outer, floating, buf.area);
let typed = typed.to_ascii_lowercase();
let label_style = Style::default().fg(Color::Black).bg(Color::Yellow).add_modifier(Modifier::BOLD);
for h in hints {
let (x, y) = (inner.x + h.m.col, inner.y + h.m.row);
if y >= inner.y + inner.height || x >= inner.x + inner.width {
continue;
}
let live = h.label.starts_with(&typed);
let end = (x + h.m.width).min(inner.x + inner.width);
for cx in x..end {
let cell = &mut buf[(cx, y)];
cell.set_style(if live {
Style::default().fg(Color::Yellow).add_modifier(Modifier::UNDERLINED)
} else {
Style::default().fg(th().dim)
});
}
if live {
for (i, ch) in h.label.chars().enumerate() {
let cx = x + i as u16;
if cx >= inner.x + inner.width {
break;
}
let style = if i < typed.len() { label_style.fg(Color::DarkGray) } else { label_style };
buf[(cx, y)].set_char(ch).set_style(style);
}
}
}
}
pub fn top_bar(&self, c: &Client) -> TopBar {
let mut bar = TopBar::default();
let Some(wi) = self.ws_index(c.workspace) else { return bar };
let ws = &self.workspaces[wi];
let cols = c.cols;
let sw = if self.sidebar_shown(c) { self.sidebar_width(cols) } else { 0 };
let mut x = if sw > 0 { sw + 1 } else { 0 };
if sw == 0 {
let broadcasting = ws.sync != super::SyncScope::Off && ws.tab().focus.is_some();
let name_room = if broadcasting { (cols as usize / 4).clamp(1, 20) } else { 20 };
let name = format!(" {} ", crate::text::truncate(&ws.name, name_room));
let w = name.width() as u16;
let style = Style::default().fg(th().accent).add_modifier(Modifier::BOLD);
bar.items.push((x, w, BarItem::Workspace, vec![Span::styled(name, style)]));
x += w + 1;
}
let room = cols.saturating_sub(x).min(cols / 2 + 8);
if let Some(spans) = self.broadcast_spans(c, room) {
let w = spans_width(&spans).min(cols.saturating_sub(x));
bar.items.push((x, w, BarItem::Text, spans));
x += w + 1;
}
if let Some((spans, cursor)) = self.mode_spans(c, cols.saturating_sub(x)) {
let w = spans_width(&spans).min(cols.saturating_sub(x));
bar.cursor = cursor.map(|at| x.saturating_add(at));
bar.items.push((x, w, BarItem::Text, spans));
bar.right = self.bar_right(c, cols.saturating_sub(x + w + 1));
return bar;
}
let tabs = self.tab_labels(ws);
let active = tabs.get(ws.active).map_or(0, |t| t.0);
bar.right = self.bar_right(c, cols.saturating_sub(x + active + 9));
let limit = cols.saturating_sub(spans_width(&bar.right) + 1);
place_tabs(&mut bar.items, x, limit, tabs, ws.active);
bar
}
fn tab_labels(&self, ws: &super::Workspace) -> Vec<(u16, Vec<Span<'static>>)> {
let mut tabs = Vec::new();
for (i, tab) in ws.tabs.iter().enumerate() {
let active = i == ws.active;
let prod = tab.panes().iter().any(|p| self.pane_production_shown(*p).is_some());
let mut spans = Vec::new();
let base = match (active, prod) {
(true, true) => Style::default().fg(Color::Black).bg(th().production).add_modifier(Modifier::BOLD),
(true, false) => Style::default().fg(Color::Black).bg(th().accent).add_modifier(Modifier::BOLD),
(false, true) => Style::default().fg(th().production),
(false, false) => Style::default().fg(Color::Gray),
};
let number = if active || prod { base } else { base.fg(th().dim) };
spans.push(Span::styled(format!(" {} ", i + 1), number));
spans.push(Span::styled(format!("{} ", crate::text::truncate(&self.tab_label(tab), 24)), base));
let mark = |text: &str, color: Color| Span::styled(format!(" {text}"), Style::default().fg(color));
let state = tab
.panes()
.iter()
.filter_map(|p| self.panes.get(p))
.filter(|p| p.agent.kind.is_some())
.map(|p| p.agent.state)
.max_by_key(|s| match s {
AgentState::Blocked => 4,
AgentState::Done => 3,
AgentState::Working => 2,
_ => 0,
});
if let Some(s) = state.filter(|s| matches!(s, AgentState::Blocked | AgentState::Done | AgentState::Working))
{
spans.push(mark(th().icon(s), th().state_color(s)));
}
if tab.zoom.is_some() {
spans.push(mark("Z", Color::LightBlue));
}
let synced = match ws.sync {
super::SyncScope::Tab => active,
super::SyncScope::Workspace => true,
super::SyncScope::Marked => tab.panes().iter().any(|p| ws.marked.contains(p)),
super::SyncScope::Off => false,
};
if synced {
spans.push(mark("S", th().sync));
}
if tab.panes().iter().any(|p| self.panes.get(p).is_some_and(|p| p.bell)) && !active {
spans.push(mark("!", Color::Red));
}
tabs.push((spans_width(&spans), spans));
}
tabs
}
fn broadcast_spans(&self, c: &Client, room: u16) -> Option<Vec<Span<'static>>> {
let ws = self.ws_index(c.workspace).map(|i| &self.workspaces[i])?;
if ws.sync == super::SyncScope::Off {
return None;
}
let targets = ws.sync_targets(ws.tab().focus?);
let n = targets.len();
let prod = targets.iter().filter(|p| self.pane_production_shown(**p).is_some()).count();
let prod_text = if prod > 0 { format!(", {prod} PROD") } else { String::new() };
let full = format!("BROADCAST → {n} pane{}{prod_text}", if n == 1 { "" } else { "s" });
let stop = self.keymap.hint(Action::BroadcastToggle).or_else(|| self.keymap.hint(Action::BroadcastOff));
let stop_w = stop.as_ref().map_or(0, |k| k.width() + " stop".len());
let room = room as usize;
let tiny = format!("B{n}{}", if prod > 0 { " PROD" } else { "" });
let what =
[full, format!("BROADCAST {n}{prod_text}")].into_iter().find(|w| w.width() + 2 <= room).unwrap_or(tiny);
let color = if prod > 0 { th().production } else { th().sync };
let mut out = vec![badge_span(&what, color)];
if let Some(stop) = stop.filter(|_| what.width() + 3 + stop_w <= room) {
out.push(Span::raw(" "));
out.extend(key_list(&[(&stop, "stop")]));
}
Some(out)
}
fn mode_spans(&self, c: &Client, room: u16) -> Option<(Vec<Span<'static>>, Option<u16>)> {
let t = th();
let label = c.mode.label();
let mut out = Vec::new();
let mut cursor = None;
match &c.mode {
Mode::Resize => {
out.push(badge_span(label, t.accent));
out.push(Span::raw(" "));
out.extend(key_list(&[("←↓↑→", "resize"), ("⏎", "done"), ("esc", "done")]));
}
Mode::Scroll => {
out.push(badge_span(label, t.working));
out.push(Span::raw(" "));
let prompts = self.client_focus(c.id).and_then(|p| self.panes.get(&p)).is_some_and(|p| p.has_prompts());
let mut list = vec![("arrows", "move"), ("v", "select"), ("y", "copy"), ("/", "search")];
if prompts {
list.extend([("p P", "prompts"), ("o", "output")]);
}
list.push(("esc", "exit"));
out.extend(key_list(&list));
}
Mode::Search { query, found } => {
out.push(badge_span(label, t.working));
out.push(Span::raw(" "));
out.push(Span::styled("/", Style::default().fg(t.working).add_modifier(Modifier::BOLD)));
let shown = (room as usize / 2).saturating_sub(spans_width(&out) as usize).max(8);
out.push(Span::raw(crate::text::truncate_start(query, shown)));
cursor = Some(spans_width(&out));
out.push(Span::raw(" "));
if !query.is_empty() && !found {
out.push(Span::styled("no match", Style::default().fg(t.production)));
} else {
out.extend(key_list(&[("↑↓", "older/newer"), ("⏎", "keep"), ("esc", "cancel")]));
}
}
Mode::Hints(crate::server::ui::HintsMode { hints, act, .. }) => {
out.push(badge_span(if *act { "HINTS: ACT" } else { "HINTS: COPY" }, t.blocked));
out.push(Span::raw(" "));
if *act {
let mut acts: Vec<&str> = Vec::new();
for h in hints {
let a = h.m.kind.action();
if !acts.contains(&a) {
acts.push(a);
}
}
out.push(dim_span(&format!("type a label to {} · ", acts.join(", "))));
out.extend(key_list(&[("tab", "copy instead"), ("esc", "cancel")]));
} else {
out.push(dim_span("type a label to copy it · "));
out.extend(key_list(&[("tab", "act on it instead"), ("esc", "cancel")]));
}
}
_ => return None,
}
Some((out, cursor))
}
fn bar_right(&self, c: &Client, room: u16) -> Vec<Span<'static>> {
let t = th();
let normal = matches!(c.mode, Mode::Normal);
let waiting = self.attention_queue().len();
let mut notice = Vec::new();
if let Some((msg, _)) = &c.toast {
notice.push(Span::styled(msg.clone(), Style::default().fg(t.blocked)));
} else if normal && c.welcome && waiting == 0 {
notice.push(Span::styled("Welcome to kraal", Style::default().fg(t.accent).add_modifier(Modifier::BOLD)));
let find = self.keymap.hint(Action::CommandPalette);
let help = self.keymap.hint(Action::Help);
let list: Vec<(&str, &str)> = [(find.as_deref(), "find any command"), (help.as_deref(), "help")]
.into_iter()
.filter_map(|(k, what)| Some((k?, what)))
.collect();
if !list.is_empty() {
notice.push(Span::raw(" "));
notice.extend(key_list(&list));
}
} else if normal && waiting > 0 {
let agents = self.panes.values().filter(|p| p.agent.state.needs_attention()).count() == waiting;
notice.push(Span::styled(
format!(
"{} {waiting} {}{} need{} you",
t.icon(AgentState::Blocked),
if agents { "agent" } else { "pane" },
if waiting == 1 { "" } else { "s" },
if waiting == 1 { "s" } else { "" }
),
Style::default().fg(t.blocked),
));
if let Some(k) = self.keymap.hint(Action::NextAttention) {
notice.push(Span::raw(" "));
notice.extend(key_list(&[(&k, "go")]));
}
}
let menu = if normal { key_list(&[(&self.keymap.prefix_label(), "menu")]) } else { Vec::new() };
let session = if self.session == "main" {
Vec::new()
} else {
vec![Span::styled(self.session.clone(), Style::default().fg(t.dim))]
};
let mut pieces = [notice, self.focused_details(c), self.agent_counts(), menu, session];
let room = room.saturating_sub(1);
let mut used = 0u32;
let mut keep = [false; 5];
for (i, p) in pieces.iter_mut().enumerate() {
if p.is_empty() {
continue;
}
let gap = if used > 0 { 2 } else { 0 };
if i == 0 && spans_width(p) > room && room >= 12 {
*p = fit_spans(std::mem::take(p), room);
}
let w = u32::from(spans_width(p));
if used + gap + w <= u32::from(room) {
keep[i] = true;
used += gap + w;
}
}
let mut out = Vec::new();
for (p, kept) in pieces.into_iter().zip(keep) {
if kept {
if !out.is_empty() {
out.push(Span::raw(" "));
}
out.extend(p);
}
}
if !out.is_empty() {
out.push(Span::raw(" "));
}
out
}
fn focused_details(&self, c: &Client) -> Vec<Span<'static>> {
let Some(wi) = self.ws_index(c.workspace) else { return Vec::new() };
let tab = self.workspaces[wi].tab();
let Some(f) = tab.focus else { return Vec::new() };
let Some((_, outer, floating)) = self.tab_rects(tab, self.layout_area).into_iter().find(|(p, ..)| *p == f)
else {
return Vec::new();
};
if floating || self.inner(outer, false).y > outer.y {
return Vec::new();
}
let mut out = Vec::new();
if self.pane_production_shown(f).is_some() {
out.push(badge_span("PROD", th().production));
}
out.extend(self.pane_details(f));
out
}
fn agent_counts(&self) -> Vec<Span<'static>> {
let (mut blocked, mut working, mut done) = (0, 0, 0);
for p in self.panes.values() {
match p.agent.state {
AgentState::Blocked => blocked += 1,
AgentState::Working => working += 1,
AgentState::Done => done += 1,
_ => {}
}
}
let mut out: Vec<Span<'static>> = Vec::new();
for (n, s) in [(blocked, AgentState::Blocked), (done, AgentState::Done), (working, AgentState::Working)] {
if n > 0 {
if !out.is_empty() {
out.push(Span::raw(" "));
}
out.push(Span::styled(
format!("{}{n}", th().icon(s)),
Style::default().fg(th().state_color(s)).add_modifier(Modifier::BOLD),
));
}
}
out
}
pub fn tab_bar_hit(&self, id: ClientId, col: u16) -> Option<BarItem> {
let c = self.clients.get(&id)?;
self.top_bar(c).items.into_iter().find(|(x, w, ..)| col >= *x && col < x + w).map(|(_, _, item, _)| item)
}
fn draw_top_bar(&self, id: ClientId, buf: &mut Buffer) {
let c = &self.clients[&id];
let bar = self.top_bar(c);
for (x, w, _, spans) in bar.items {
buf.set_line(x, 0, &Line::from(spans), w);
}
let rw = spans_width(&bar.right);
if rw > 0 && rw < c.cols {
buf.set_line(c.cols - rw, 0, &Line::from(bar.right), rw);
}
}
fn sidebar_rows(&self, c: &Client, height: u16) -> Vec<SideRow> {
let t = th();
let mut rows = vec![SideRow::Section("WORKSPACES")];
let entry = |rows: &mut Vec<SideRow>,
item: SideItem,
dot: Span<'static>,
name: Span<'static>,
detail: Span<'static>,
selected: bool| {
rows.push(SideRow::Entry { item: Some(item.clone()), line: Line::from(vec![dot, name]), selected });
rows.push(SideRow::Entry { item: Some(item), line: Line::from(vec![Span::raw(" "), detail]), selected });
};
let dot = |icon: &str, color: Color| Span::styled(format!(" {icon} "), Style::default().fg(color));
let strong = |text: String| Span::styled(text, Style::default().fg(t.text).add_modifier(Modifier::BOLD));
let faint = |text: String, color: Color| Span::styled(text, Style::default().fg(color));
let plural = |n: usize, what: &str| format!("{n} {what}{}", if n == 1 { "" } else { "s" });
let sep = if t.ascii { " - " } else { " · " };
let urgency = |s: &AgentState| match s {
AgentState::Blocked => 4,
AgentState::Done => 3,
AgentState::Working => 2,
AgentState::Idle => 1,
AgentState::Unknown => 0,
};
let mut agents = Vec::new();
for ws in &self.workspaces {
let panes = ws.panes();
let states: Vec<(PaneId, AgentState)> = panes
.iter()
.filter_map(|p| {
self.panes.get(p).filter(|pane| pane.agent.kind.is_some()).map(|pane| (*p, pane.agent.state))
})
.collect();
let (icon, color) = match states.iter().map(|(_, s)| *s).max_by_key(urgency) {
Some(s) => (t.icon(s), t.state_color(s)),
None => (t.icon(AgentState::Working), t.dim),
};
let detail = format!("{}{sep}{}", plural(ws.tabs.len(), "tab"), plural(panes.len(), "pane"));
entry(
&mut rows,
SideItem::Workspace(ws.id),
dot(icon, color),
strong(ws.name.clone()),
faint(detail, t.dim),
ws.id == c.workspace,
);
agents.extend(states.into_iter().map(|(p, s)| (p, s, ws)));
}
for n in self.def_names.iter().filter(|n| !self.workspaces.iter().any(|w| &w.name == *n)) {
let name = faint(n.clone(), t.dim);
let detail = faint("not running".into(), t.dim);
entry(
&mut rows,
SideItem::Definition(n.clone()),
dot(t.icon(AgentState::Idle), t.dim),
name,
detail,
false,
);
}
if !agents.is_empty() {
rows.push(SideRow::Blank);
rows.push(SideRow::Section("AGENTS"));
for (p, s, ws) in agents {
let pane = &self.panes[&p];
let kind = match &pane.spec.account {
Some(a) => a.replace(':', "·"),
None => pane.agent.kind.clone().unwrap_or_default(),
};
let mut detail = format!("{}{sep}{kind}", s.label());
if ws.id != c.workspace {
detail = format!("{detail}{sep}{}", ws.name);
}
let color = t.state_color(s);
entry(
&mut rows,
SideItem::Pane(p),
dot(t.icon(s), color),
strong(pane.display_name()),
faint(detail, color),
false,
);
}
}
if !self.tunnels.is_empty() {
rows.push(SideRow::Blank);
rows.push(SideRow::Section("TUNNELS"));
for tunnel in &self.tunnels {
let (icon, color) = match tunnel.state {
super::net::TunnelState::Up => (t.icon(AgentState::Working), t.done),
super::net::TunnelState::Connecting => (t.icon(AgentState::Idle), t.working),
super::net::TunnelState::Down => (t.icon(AgentState::Blocked), t.blocked),
};
let detail = format!("{}:{}", tunnel.spec.server, tunnel.spec.remote_port);
let name = strong(format!(":{}", tunnel.spec.local));
entry(
&mut rows,
SideItem::Tunnel(tunnel.spec.local),
dot(icon, color),
name,
faint(detail, t.dim),
false,
);
}
}
if !self.ports.is_empty() {
rows.push(SideRow::Blank);
rows.push(SideRow::Section("PORTS"));
for (port, pane) in &self.ports {
let name = self.panes.get(pane).map(|p| p.display_name()).unwrap_or_default();
let icon = t.icon(AgentState::Working);
entry(
&mut rows,
SideItem::Port(*port),
dot(icon, t.accent),
strong(format!(":{port}")),
faint(name, t.dim),
false,
);
}
}
let now = crate::accounts::now();
let accounts: Vec<Line<'static>> = self
.accounts
.all()
.into_iter()
.filter_map(|a| {
let peak = self.usage.get(&a)?.peak(now);
let color = if peak >= self.accounts.threshold(&a) as f64 {
t.blocked
} else if peak >= 70.0 {
t.working
} else {
t.text
};
Some(Line::from(vec![
Span::styled(format!(" {} ", crate::accounts::label(&a)), Style::default().fg(Color::Gray)),
Span::styled(format!("{peak:.0}%"), Style::default().fg(color).add_modifier(Modifier::BOLD)),
]))
})
.collect();
let top = (height as usize).saturating_sub(2).saturating_sub(accounts.len());
if !accounts.is_empty() && rows.len() <= top {
rows.resize_with(top, || SideRow::Blank);
rows.push(SideRow::Section("ACCOUNTS"));
rows.extend(accounts.into_iter().map(|line| SideRow::Entry { item: None, line, selected: false }));
}
rows
}
pub fn sidebar_hit(&self, id: ClientId, row: u16) -> Option<SideItem> {
let c = self.clients.get(&id)?;
if row.saturating_add(1) >= c.rows {
return None;
}
match self.sidebar_rows(c, c.rows).into_iter().nth(row as usize)? {
SideRow::Entry { item, .. } => item,
_ => None,
}
}
fn draw_sidebar(&self, id: ClientId, area: Rect, buf: &mut Buffer) {
if area.width < 4 || area.height < 3 {
return;
}
let c = &self.clients[&id];
let set = th().border(false);
let line = Style::default().fg(th().dim);
Block::default().borders(Borders::ALL).border_set(set).border_style(line).render(area, buf);
let (left, right) = th().tees();
let width = area.width - 2;
for (i, row) in self.sidebar_rows(c, area.height).into_iter().enumerate() {
let y = area.y + i as u16;
if y + 1 >= area.bottom() {
break;
}
match row {
SideRow::Section(name) => {
if i > 0 {
buf[(area.x, y)].set_symbol(left).set_style(line);
for x in area.x + 1..area.right() - 1 {
buf[(x, y)].set_symbol(set.horizontal_top).set_style(line);
}
buf[(area.right() - 1, y)].set_symbol(right).set_style(line);
}
let title = Line::from(vec![
Span::styled(set.horizontal_top, line),
Span::styled(
format!(" {name} "),
Style::default().fg(Color::Gray).add_modifier(Modifier::BOLD),
),
]);
buf.set_line(area.x + 1, y, &title, width);
}
SideRow::Entry { line: text, selected, .. } => {
if selected {
for x in area.x + 1..area.right() - 1 {
buf[(x, y)].set_bg(th().selection);
}
}
buf.set_line(area.x + 1, y, &text, width);
}
SideRow::Blank => {}
}
}
}
fn draw_panes(&mut self, id: ClientId, buf: &mut Buffer, links: &mut crate::ansi::Links) -> Option<Cursor> {
let c = &self.clients[&id];
let wi = self.ws_index(c.workspace)?;
let area = self.layout_area;
let clip = buf.area;
let images = self.can_show_images(id);
let ws = &self.workspaces[wi];
let tab = ws.tab();
let rects = self.tab_rects(tab, area);
let focus = tab.focus;
let synced: Vec<PaneId> = match (ws.sync, focus) {
(super::SyncScope::Off, _) | (_, None) => Vec::new(),
(_, Some(f)) => ws.sync_targets(f),
};
let marked = ws.marked.clone();
let show_floating = tab.show_floating;
let floating_focus = focus.is_some_and(|f| tab.is_floating(f));
let mut cursor = None;
for (pid, outer, floating) in rects {
let (tile, inner) = self.placed(outer, floating, clip);
if tile.is_empty() {
continue;
}
if floating {
Clear.render(tile, buf);
links.retain(|(x, y), _| !tile.contains((*x, *y).into()));
}
let focused = focus == Some(pid) && (!floating_focus || show_floating);
if inner != tile {
let (sync, mark) = (synced.contains(&pid), marked.contains(&pid));
self.draw_frame(pid, tile, focused, sync, floating, mark, buf);
}
if inner.is_empty() {
continue;
}
let Some(pane) = self.panes.get_mut(&pid) else { continue };
let content = Rect::new(inner.x, inner.y, inner.width.min(pane.cols), inner.height.min(pane.rows));
if let crate::pane::Content::Files(f) = &mut pane.content {
f.set_viewer(id);
}
pane.render(content, buf, links);
pane.show_images(content, buf, images);
if let Some(code) = pane.exit_code {
let msg = match code {
_ if pane.suspended => " not started · enter run · x close ".to_string(),
Some(c) => format!(" exited ({c}) · enter restart · x close "),
None => " exited · enter restart · x close ".to_string(),
};
let y = inner.y + inner.height.saturating_sub(1);
let color = if pane.suspended {
Color::Yellow
} else if code == Some(0) {
Color::Gray
} else {
Color::LightRed
};
let style = Style::default().fg(Color::Black).bg(color);
buf.set_stringn(inner.x, y, msg, inner.width as usize, style);
}
if focused && matches!(self.clients[&id].mode, Mode::Normal | Mode::Leader { .. } | Mode::Scroll) {
cursor = self.panes[&pid].cursor(content);
}
}
cursor
}
fn pane_details(&self, pid: PaneId) -> Vec<Span<'static>> {
let Some(pane) = self.panes.get(&pid) else { return Vec::new() };
let mut out = Vec::new();
if let Some(g) = self.pane_git(pid) {
let color = if g.dirty || g.untracked { Color::Yellow } else { th().dim };
out.push(Span::styled(format!(" {} ", g.label(th().ascii)), Style::default().fg(color)));
}
for (label, is_prod) in self.context_labels(pid) {
let color = if is_prod { th().production } else { th().dim };
out.push(Span::styled(format!(" {label} "), Style::default().fg(color)));
}
if let Some(kind) = &pane.agent.kind {
let state = pane.agent.state;
let kind = pane.spec.account.as_ref().map(|a| a.replace(':', "·")).unwrap_or_else(|| kind.clone());
let sep = if th().ascii { "-" } else { "·" };
out.push(Span::styled(
format!(" {} {} {sep} {kind} ", th().icon(state), state.label()),
Style::default().fg(th().state_color(state)),
));
}
if let Some((code, took)) = pane.last_failure() {
let took = took.filter(|d| d.as_secs() >= 10).map(|d| format!(" {}", crate::pane::short_duration(d)));
let mark = if th().ascii { "x" } else { "✗" };
out.push(Span::styled(
format!(" {mark} {code}{} ", took.unwrap_or_default()),
Style::default().fg(Color::LightRed),
));
}
let offset = pane.display_offset();
if offset > 0 {
out.push(Span::styled(format!(" ↑{offset} "), Style::default().fg(Color::LightBlue)));
}
out
}
#[allow(clippy::too_many_arguments)]
fn draw_frame(
&self,
pid: PaneId,
tile: Rect,
focused: bool,
sync: bool,
floating: bool,
marked: bool,
buf: &mut Buffer,
) {
let Some(pane) = self.panes.get(&pid) else { return };
let prod = self.pane_production_shown(pid).is_some();
let color = if prod {
th().production
} else if sync {
th().sync
} else if focused {
th().accent
} else {
th().dim
};
let mut title = vec![Span::styled(
format!(" {} ", pane.display_name()),
if focused {
Style::default().fg(color).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Gray)
},
)];
if prod {
title.push(badge_span("PROD", th().production));
if sync {
title.push(Span::styled(" sync ", Style::default().fg(th().sync).add_modifier(Modifier::BOLD)));
}
}
if floating {
title.insert(0, Span::styled(" ◇", Style::default().fg(color)));
}
if marked {
title.insert(0, Span::styled(" ◆", Style::default().fg(th().sync).add_modifier(Modifier::BOLD)));
}
let tw = spans_width(&title);
Block::default()
.borders(Borders::ALL)
.border_set(th().border(false))
.border_style(Style::default().fg(color))
.title(Line::from(title))
.render(tile, buf);
let right = self.pane_details(pid);
let rw = spans_width(&right);
if rw > 0 && u32::from(tw) + u32::from(rw) + 4 <= u32::from(tile.width) {
buf.set_line(tile.right() - 2 - rw, tile.y, &Line::from(right), rw);
}
}
pub(super) fn help_lines(&self) -> Vec<Line<'static>> {
let km = &self.keymap;
let p = km.prefix_label();
let mut out = vec![
Line::from(vec![
Span::raw("Press "),
Span::styled(p.clone(), Style::default().fg(th().accent).add_modifier(Modifier::BOLD)),
Span::raw(", then a key: a menu shows what each key does. "),
]),
Line::from(match km.hint(crate::keymap::Action::CommandPalette) {
Some(k) => format!("Search every command with {k}. Keys preset: {}.", km.preset),
None => format!("Keys preset: {}.", km.preset),
}),
];
if !km.menus.is_empty() {
out.push(Line::from(format!("Lowercase: a theme's main action ({p} s). Capital: its menu ({p} S).")));
}
out.push(Line::raw(""));
let sections = km.reference(th().ascii);
let key_w = sections.iter().flat_map(|(_, v)| v.iter().map(|(k, _)| k.width())).max().unwrap_or(10).min(26);
for (title, rows) in sections {
if rows.is_empty() {
continue;
}
out.push(overlays::heading(&title));
for (k, what) in rows {
out.push(overlays::entry(&k, &what, key_w));
}
out.push(Line::raw(""));
}
out.push(Line::from(Span::styled(
format!(
"Exited command panes: ⏎ restarts, x closes. Settings: {}",
crate::paths::shorten_home(&crate::paths::config_path())
),
Style::default().fg(th().dim),
)));
out
}
pub(super) fn message_lines(&self) -> Vec<Line<'static>> {
if self.messages.is_empty() {
return vec![Line::from(Span::styled("No messages yet.", Style::default().fg(th().dim)))];
}
self.messages
.iter()
.rev()
.map(|m| {
let when = crate::text::local_datetime(crate::text::unix_seconds(m.at));
let style = if m.warning { Style::default().fg(th().blocked) } else { Style::default() };
Line::from(vec![
Span::styled(format!("{} ", &when[11..]), Style::default().fg(th().dim)),
Span::styled(m.text.clone(), style),
])
})
.collect()
}
}
pub type BarEntry = (u16, u16, BarItem, Vec<Span<'static>>);
#[derive(Default)]
pub struct TopBar {
pub items: Vec<BarEntry>,
pub right: Vec<Span<'static>>,
pub cursor: Option<u16>,
}
fn place_tabs(out: &mut Vec<BarEntry>, mut x: u16, limit: u16, tabs: Vec<(u16, Vec<Span<'static>>)>, active: usize) {
let room = limit.saturating_sub(x + 4);
let active = active.min(tabs.len().saturating_sub(1));
let (mut lo, mut hi) = (active, active);
let mut used = tabs.get(active).map_or(0, |t| t.0 + 1);
loop {
let marks = |lo: usize, hi: usize| if lo > 0 { 4 } else { 0 } + if hi + 1 < tabs.len() { 4 } else { 0 };
let mut grew = false;
if hi + 1 < tabs.len() && used + tabs[hi + 1].0 + 1 + marks(lo, hi + 1) <= room {
hi += 1;
used += tabs[hi].0 + 1;
grew = true;
}
if lo > 0 && used + tabs[lo - 1].0 + 1 + marks(lo - 1, hi) <= room {
lo -= 1;
used += tabs[lo].0 + 1;
grew = true;
}
if !grew {
break;
}
}
let more = |n: usize, left: bool| {
let text = if left { format!("‹{n} ") } else { format!(" {n}›") };
vec![Span::styled(text, Style::default().fg(th().dim))]
};
let n = tabs.len();
if lo > 0 {
out.push((x, 3, BarItem::Tab(lo - 1), more(lo, true)));
x += 4;
}
for (i, (w, spans)) in tabs.into_iter().enumerate().skip(lo).take(hi + 1 - lo) {
out.push((x, w, BarItem::Tab(i), spans));
x += w + 1;
}
if hi + 1 < n {
out.push((x, 3, BarItem::Tab(hi + 1), more(n - hi - 1, false)));
x += 4;
}
if x + 3 <= limit {
out.push((x, 3, BarItem::NewTab, vec![Span::styled(" + ", Style::default().fg(th().dim))]));
}
}
fn spans_width(spans: &[Span]) -> u16 {
spans.iter().map(|s| s.content.width()).sum::<usize>().min(u16::MAX as usize) as u16
}
fn fit_spans(spans: Vec<Span<'static>>, max: u16) -> Vec<Span<'static>> {
let mut out = Vec::new();
let mut left = max as usize;
for s in spans {
let w = s.content.width();
if w <= left {
left -= w;
out.push(s);
} else {
if left > 0 {
out.push(Span::styled(crate::text::truncate(&s.content, left), s.style));
}
break;
}
}
out
}
fn badge_span(text: &str, color: Color) -> Span<'static> {
Span::styled(format!(" {text} "), Style::default().fg(Color::Black).bg(color).add_modifier(Modifier::BOLD))
}
fn dim_span(text: &str) -> Span<'static> {
Span::styled(text.to_string(), Style::default().fg(th().dim))
}
fn key_list(list: &[(&str, &str)]) -> Vec<Span<'static>> {
let mut out = Vec::new();
for (i, (k, what)) in list.iter().enumerate() {
if i > 0 {
out.push(dim_span(" · "));
}
out.push(Span::styled(overlays::shown_key(k), Style::default().fg(th().accent).add_modifier(Modifier::BOLD)));
out.push(dim_span(&format!(" {what}")));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn messages_too_long_are_cut() {
let spans = vec![Span::raw("deploy "), Span::raw("failed on web01")];
let cut = fit_spans(spans, 12);
assert_eq!(spans_width(&cut), 12);
assert_eq!(cut.iter().map(|s| s.content.as_ref()).collect::<String>(), "deploy fail…");
}
}