pub mod theme;
use std::path::Path;
use ratatui::buffer::Buffer;
use ratatui::layout::{Alignment, Constraint, Layout, Position, Rect};
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph, Widget};
use ratatui::Frame;
use crate::app::{App, DockKind, Mode, Side};
use crate::ids::PaneId;
use crate::ui::theme::{State, Theme};
pub struct RenderTarget<'a> {
buf: &'a mut Buffer,
area: Rect,
cursor: Option<(u16, u16)>,
}
impl<'a> RenderTarget<'a> {
pub fn new(buf: &'a mut Buffer, area: Rect) -> Self {
RenderTarget {
buf,
area,
cursor: None,
}
}
pub fn area(&self) -> Rect {
self.area
}
pub fn buffer_mut(&mut self) -> &mut Buffer {
self.buf
}
pub fn render_widget<W: Widget>(&mut self, widget: W, area: Rect) {
widget.render(area, self.buf);
}
pub fn set_cursor_position<P: Into<Position>>(&mut self, position: P) {
let p = position.into();
self.cursor = Some((p.x, p.y));
}
}
mod board;
mod borders;
mod cmdinfo;
mod git;
mod help;
mod menu;
mod panes;
mod picker;
mod settings;
mod sidebar;
mod status;
mod tabbar;
pub fn render(f: &mut Frame, app: &mut App) {
let area = f.area();
let cursor = {
let mut target = RenderTarget {
buf: f.buffer_mut(),
area,
cursor: None,
};
render_into(&mut target, app);
target.cursor
};
if let Some(p) = cursor {
f.set_cursor_position(p);
}
}
pub fn render_into(f: &mut RenderTarget, app: &mut App) {
let t = app.theme.clone();
let cat = app.catalog;
let area = f.area();
f.render_widget(Block::new().style(Style::new().bg(t.mantle)), area);
if area.width < 24 || area.height < 6 {
if area.height > 0 {
let msg = "◱ enlarge terminal";
let y = area.y + area.height / 2;
f.render_widget(
Paragraph::new(Line::from(Span::styled(msg, Style::new().fg(t.overlay1)))),
Rect::new(area.x, y, area.width, 1),
);
}
return;
}
let [main, status] = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(area);
let min = crate::app::SIDEBAR_WIDTH_MIN;
let fit = |w: u16, budget: u16| -> u16 {
let w = w.min(budget.saturating_sub(24));
if w >= min {
w
} else {
0
}
};
let lw = if app.sidebars.left.shown() {
fit(app.sidebars.left.width, main.width)
} else {
0
};
let rw = if app.sidebars.right.shown() {
fit(app.sidebars.right.width, main.width.saturating_sub(lw))
} else {
0
};
let [left_area, content, right_area] = Layout::horizontal([
Constraint::Length(lw),
Constraint::Min(0),
Constraint::Length(rw),
])
.areas(main);
let sidebar_left = (lw > 0).then_some(left_area);
let sidebar_right = (rw > 0).then_some(right_area);
let [tabbar, pane_area] =
Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(content);
app.last_pane_area = pane_area;
let focus = app.layout().focus;
let rects: Vec<(PaneId, Rect)> = if app.zoomed {
vec![(focus, pane_area)]
} else {
app.layout()
.panes(pane_area)
.into_iter()
.map(|p| (p.id, p.rect))
.collect()
};
let bordered = rects.len() > 1;
for (id, rect) in &rects {
if let Some(content) = pane_content(*rect, bordered) {
if let Some(p) = app.panes.get_mut(id) {
p.resize(content.width, content.height);
}
}
}
app.settings_icon_rect = None;
app.sidebar_toggle_rect = None;
app.right_sidebar_toggle_rect = None;
app.workspaces_area = Rect::ZERO;
app.agents_area = Rect::ZERO;
app.agents_filter_rects.clear();
app.workspace_branch_rects.clear();
app.module_dock_rects.clear();
let mut ws_rects = Vec::new();
let mut agent_rects = Vec::new();
let mut session_rects = Vec::new();
let mut new_ws_rect = None;
for (opt, side) in [(sidebar_left, Side::Left), (sidebar_right, Side::Right)] {
if let Some(s) = opt {
let (w, a, se, n) = sidebar::draw_sidebar(f, side, s, app, &t);
ws_rects.extend(w);
agent_rects.extend(a);
session_rects.extend(se);
new_ws_rect = new_ws_rect.or(n);
}
}
let (tab_rects, tab_close_rects, tab_prev, tab_next) = tabbar::draw_tabbar(f, tabbar, app, &t);
f.render_widget(Block::new().style(Style::new().bg(t.mantle)), pane_area);
app.pane_close_rect = if bordered {
rects
.iter()
.find(|(id, _)| *id == focus)
.and_then(|(_, r)| pane_close_rect(*r, bordered))
} else {
None
};
let mut git_section_rects = Vec::new();
let mut title_rects: Vec<(PaneId, Rect)> = Vec::new();
let cursor = if app.active_is_orch() {
app.orch_area = pane_area;
let live: Vec<Option<board::RowLive>> = app
.orch
.tasks
.iter()
.map(|task| {
task.assignee
.map(crate::ids::PaneId)
.and_then(|pid| app.status.get(&pid))
.map(|st| board::RowLive {
agent: st.agent.clone(),
state: st.state,
})
})
.collect();
app.orch_scroll = board::render(
f,
pane_area,
&app.orch,
&live,
app.orch_scroll,
app.orch_cursor,
cat,
&t,
);
None
} else if let Some(g) = app.active_git_mut() {
git_section_rects = git::render(f, pane_area, g, cat, &t);
None
} else {
let cursor = panes::draw_panes(f, &rects, bordered, app, &t);
if bordered {
borders::render_pane_borders(f, &rects, focus, app.hover_divider.as_ref(), &t);
if app.config.layout.show_titles {
title_rects = panes::draw_pane_titles(f, &rects, focus, app, &t);
}
}
cursor
};
app.git_section_rects = git_section_rects;
app.pane_title_rects = title_rects;
app.pane_content_rects = if app.active_is_git() || app.active_is_orch() {
Vec::new()
} else {
rects
.iter()
.filter_map(|(id, r)| pane_content(*r, bordered).map(|c| (*id, c)))
.collect()
};
status::draw_status(f, status, app, &t);
let settings_hits = app
.settings
.is_some()
.then(|| settings::draw_settings(f, area, app, &t));
if let Some(h) = &settings_hits {
app.settings_modal_rect = Some(h.modal);
app.settings_close_rect = Some(h.close);
app.settings_tab_rects = h.tabs.clone();
app.settings_ctl_rects = h.ctls.clone();
app.settings_arrow_rects = h.arrows.clone();
} else {
app.settings_modal_rect = None;
app.settings_close_rect = None;
app.settings_tab_rects.clear();
app.settings_ctl_rects.clear();
app.settings_arrow_rects.clear();
}
let picker_open = app.picker.is_some();
let mut picker_rects = Vec::new();
if let Some(p) = &app.picker {
picker_rects = picker::draw_picker(f, area, p, cat, &t);
}
app.picker_rects = picker_rects;
if app.help_open {
help::draw_help(f, area, app, &t);
}
if let Some(c) = app.cmd_inspect.as_ref() {
cmdinfo::draw(f, area, c, &t);
}
let hover = app.hover;
app.modal_commit_rect = None;
app.modal_cancel_rect = None;
if let Some(buf) = app.worktree_prompt.clone() {
let err = app.worktree_error.clone();
let (c, x) = picker::draw_worktree_prompt(f, area, &buf, err.as_deref(), hover, cat, &t);
app.modal_commit_rect = c;
app.modal_cancel_rect = x;
}
if let Some(buf) = app.tab_rename.as_ref().map(|r| r.buffer.clone()) {
let (c, x) = picker::draw_tab_rename(f, area, &buf, hover, cat, &t);
app.modal_commit_rect = c;
app.modal_cancel_rect = x;
}
if let Some(buf) = app.ws_rename.as_ref().map(|r| r.buffer.clone()) {
let (c, x) = picker::draw_ws_rename(f, area, &buf, hover, cat, &t);
app.modal_commit_rect = c;
app.modal_cancel_rect = x;
}
if app.pane_menu.is_some() {
menu::draw_pane_menu(f, area, app, cat, &t);
}
if app.agent_menu.is_some() {
menu::draw_agent_menu(f, area, app, cat, &t);
}
if app.ws_menu.is_some() {
menu::draw_ws_menu(f, area, app, cat, &t);
}
if let Some(form) = &app.orch_form {
board::draw_form(f, area, form, cat, &t);
}
if let Some(start) = &app.orch_start {
board::draw_start(f, area, start, cat, &t);
}
if let Some(id) = &app.orch_detail {
let clamped = app
.orch
.tasks
.iter()
.find(|task| &task.id == id)
.map(|task| board::draw_detail(f, area, task, app.orch_detail_scroll, cat, &t));
if let Some(s) = clamped {
app.orch_detail_scroll = s;
}
}
if let Some((text, _)) = &app.toast {
draw_toast(f, area, text, &t);
}
let cursor = if settings_hits.is_some()
|| picker_open
|| app.help_open
|| app.worktree_prompt.is_some()
|| app.tab_rename.is_some()
|| app.ws_rename.is_some()
|| app.ws_menu.is_some()
|| app.pane_menu.is_some()
|| app.agent_menu.is_some()
|| app.orch_form.is_some()
|| app.orch_start.is_some()
|| app.orch_detail.is_some()
{
None
} else {
cursor
};
if let Some(p) = cursor {
f.set_cursor_position(p);
}
app.last_cursor = cursor;
app.pane_rects = rects;
app.tab_rects = tab_rects;
app.tab_close_rects = tab_close_rects;
app.tab_prev_rect = tab_prev;
app.tab_next_rect = tab_next;
app.ws_rects = ws_rects;
app.agent_rects = agent_rects;
app.session_rects = session_rects;
app.new_ws_rect = new_ws_rect;
}
fn pane_inner(rect: Rect, bordered: bool) -> Option<Rect> {
if !bordered {
if rect.width < 1 || rect.height < 1 {
return None;
}
return Some(rect);
}
if rect.width < 4 || rect.height < 4 {
return None;
}
Some(Rect::new(
rect.x + 1,
rect.y + 1,
rect.width - 2,
rect.height - 2,
))
}
pub(super) const LONE_PANE_HPAD: u16 = 1;
pub(super) fn hint_line(pairs: &[(&str, &str)], t: &Theme) -> Line<'static> {
let mut spans = vec![Span::raw(" ")];
for (i, (key, label)) in pairs.iter().enumerate() {
if i > 0 {
spans.push(Span::styled(" · ", Style::new().fg(t.overlay0)));
}
spans.push(Span::styled(
key.to_string(),
Style::new().fg(t.accent).bold(),
));
if !label.is_empty() {
spans.push(Span::styled(
format!(" {label}"),
Style::new().fg(t.subtext1),
));
}
}
Line::from(spans)
}
pub(super) fn display_width(s: &str) -> usize {
use unicode_width::UnicodeWidthStr;
s.width()
}
fn draw_toast(f: &mut RenderTarget, area: Rect, text: &str, t: &Theme) {
use ratatui::widgets::{Borders, Clear};
let w = (display_width(text) as u16 + 6).min(area.width);
let h = 3u16;
if w < 6 || area.height < h + 3 {
return;
}
let x = area.x + area.width.saturating_sub(w) / 2;
let y = area.bottom().saturating_sub(h + 2); let rect = Rect::new(x, y, w, h);
f.render_widget(Clear, rect);
let block = Block::new()
.borders(Borders::ALL)
.border_style(Style::new().fg(t.accent).bg(t.surface0))
.style(Style::new().bg(t.surface0));
let inner = block.inner(rect);
f.render_widget(block, rect);
f.render_widget(
Paragraph::new(Line::from(vec![
Span::styled("✓ ", Style::new().fg(t.green)),
Span::styled(text.to_string(), Style::new().fg(t.text).bold()),
]))
.alignment(Alignment::Center),
inner,
);
}
pub(super) fn lone_pad(width: u16) -> u16 {
if width > 2 * LONE_PANE_HPAD + 2 {
LONE_PANE_HPAD
} else {
0
}
}
fn pane_content(rect: Rect, bordered: bool) -> Option<Rect> {
if bordered {
return pane_inner(rect, true);
}
let pad = lone_pad(rect.width);
let c = Rect::new(
rect.x + pad,
rect.y + 1,
rect.width.saturating_sub(2 * pad),
rect.height.saturating_sub(1),
);
if c.width < 1 || c.height < 1 {
return None;
}
Some(c)
}
fn pane_close_rect(area: Rect, bordered: bool) -> Option<Rect> {
if !bordered || area.width < 9 {
return None;
}
Some(Rect::new(area.x + area.width - 4, area.y, 3, 1))
}
fn pane_state(app: &App, id: PaneId) -> State {
app.status
.get(&id)
.map(|s| s.state)
.unwrap_or(State::Unknown)
}
fn short_path(p: &Path, max: u16) -> String {
let mut s = p.display().to_string();
if let Some(home) = crate::platform::home_dir() {
if let Some(rest) = s.strip_prefix(home.to_string_lossy().as_ref()) {
s = format!("~{rest}");
}
}
let max = max as usize;
if s.chars().count() > max && max > 1 {
let tail: String = s
.chars()
.rev()
.take(max - 1)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
format!("…{tail}")
} else {
s
}
}