1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
//! Pane content: the terminal grid blit, the lone-pane header bar, and the
//! dot+path+close title drawn onto each split pane's top border.
use super::*;
/// Draw the dot + path (+ ✕ for the focused pane) as a title ON each pane's top
/// border row, after the borders are drawn, so it lands on the tab bar edge.
pub(super) fn draw_pane_titles(
f: &mut RenderTarget,
rects: &[(PaneId, Rect)],
focus: PaneId,
app: &App,
t: &Theme,
) -> Vec<(PaneId, Rect)> {
let mut title_rects = Vec::new();
for (id, rect) in rects {
if rect.width < 8 || rect.height < 2 {
continue;
}
// A view leaf's title is its file path + a state dot placeholder.
if let Some(crate::app::ViewKind::File(v)) = app.views.get(id) {
let focused = *id == focus;
let bg = t.mantle;
let inner_w = rect.width - 2;
let btn_w = title_buttons_w(focused, rect.width);
let title_w = inner_w.saturating_sub(btn_w);
let name = v
.path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let path_fg = if focused { t.accent } else { t.subtext0 };
// A plain small-square glyph (no emoji), neutral marker.
let dot = Span::styled(" ■ ", Style::new().fg(t.overlay1).bg(bg));
let label: String = name
.chars()
.take(title_w.saturating_sub(3) as usize)
.collect();
let text_w = (3 + label.chars().count() as u16).min(title_w);
let title_rect = Rect::new(rect.x + 1, rect.y, text_w, 1);
f.render_widget(
Paragraph::new(Line::from(vec![
dot,
Span::styled(label, Style::new().fg(path_fg).bg(bg)),
])),
title_rect,
);
title_rects.push((*id, title_rect));
draw_title_buttons(f, *rect, focused, app.zoomed, title_w, bg, t);
continue;
}
let Some(pane) = app.panes.get(id) else {
continue;
};
let focused = *id == focus;
let st = pane_state(app, *id);
let path_fg = if focused { t.accent } else { t.subtext0 };
// The top border is a thin rule now (not a filled bar), so the title
// sits on the dark background; only the text cells are painted, leaving
// the thin `▔` line visible on either side of the label.
let bg = t.mantle;
let inner_w = rect.width - 2; // inside the two corner cells
let btn_w = title_buttons_w(focused, rect.width);
let title_w = inner_w.saturating_sub(btn_w);
let path = short_path(&pane.cwd, title_w.saturating_sub(4));
let text_w = (3 + path.chars().count() as u16).min(title_w);
let title = Line::from(vec![
Span::styled(
format!(" {} ", st.dot()),
Style::new().fg(st.color(t)).bg(bg),
),
Span::styled(path, Style::new().fg(path_fg).bg(bg)),
]);
let title_rect = Rect::new(rect.x + 1, rect.y, text_w, 1);
f.render_widget(Paragraph::new(title), title_rect);
// Clicking the title opens the running-command overlay.
title_rects.push((*id, title_rect));
draw_title_buttons(f, *rect, focused, app.zoomed, title_w, bg, t);
}
title_rects
}
/// Cells reserved on the right of a focused pane's title for its buttons: the ✕,
/// plus the ⤢ zoom toggle when the pane is wide enough for both. Must match
/// `pane_close_rect`/`pane_zoom_rect` in `ui/mod.rs`, or a tap lands off the
/// glyph.
fn title_buttons_w(focused: bool, width: u16) -> u16 {
if !focused {
0
} else if width >= 12 {
6
} else {
3
}
}
/// Draw the focused pane's title buttons at the right edge: ⤢/⤡ (zoom/restore)
/// then ✕, each a 3-cell hit target aligned with the rects `ui/mod.rs` records.
fn draw_title_buttons(
f: &mut RenderTarget,
rect: Rect,
focused: bool,
zoomed: bool,
title_w: u16,
bg: Color,
t: &Theme,
) {
if !focused {
return;
}
let style = Style::new().fg(t.subtext1).bg(bg).bold();
let bx = rect.x + 1 + title_w;
let close = |f: &mut RenderTarget, x: u16| {
f.render_widget(
Paragraph::new(Span::styled(" × ", style)),
Rect::new(x, rect.y, 3, 1),
);
};
if rect.width >= 12 {
// ⤢ expands a split to fullscreen; ⤡ restores it (touch-reachable zoom).
let zoom = if zoomed { " ⤡ " } else { " ⤢ " };
f.render_widget(
Paragraph::new(Span::styled(zoom, style)),
Rect::new(bx, rect.y, 3, 1),
);
close(f, bx + 3);
} else {
close(f, bx);
}
}
// ── panes ─────────────────────────────────────────────────────────────────
pub(super) fn draw_panes(
f: &mut RenderTarget,
rects: &[(PaneId, Rect)],
bordered: bool,
app: &App,
t: &Theme,
) -> Option<(u16, u16)> {
let focus = app.layout().focus;
let mut cursor = None;
for (id, rect) in rects {
if let Some(c) = draw_one_pane(f, *rect, *id, *id == focus, bordered, app, t) {
cursor = Some(c);
}
}
cursor
}
fn draw_one_pane(
f: &mut RenderTarget,
area: Rect,
id: PaneId,
focused: bool,
bordered: bool,
app: &App,
t: &Theme,
) -> Option<(u16, u16)> {
// A view leaf (docs/38 FILE-3) renders natively, not from a PTY.
if let Some(crate::app::ViewKind::File(v)) = app.views.get(&id) {
let content = pane_content(area, bordered)?;
let sel = app.selection.filter(|s| s.pane == id);
super::files::draw_file_view(f, content, v, sel.as_ref(), t);
return None; // views own no terminal cursor
}
let pane = app.panes.get(&id)?;
let st = pane_state(app, id);
let content = pane_content(area, bordered)?;
// A lone pane has no border, so it shows a header bar on its top row.
// Bordered panes instead get their dot+path+close as a title ON the top
// border row (see `draw_pane_titles`), so it touches the tab bar.
if !bordered {
// Match the content's horizontal pad so the header bar aligns with the
// tab bar and the terminal text below it.
let pad = lone_pad(area.width);
let header = Rect::new(area.x + pad, area.y, area.width.saturating_sub(2 * pad), 1);
let hbg = if focused { t.surface1 } else { t.surface0 };
let path_fg = if focused { t.accent } else { t.overlay1 };
f.render_widget(Block::new().style(Style::new().bg(hbg)), header);
// When this lone pane is a *zoomed* split (not just the only pane), show a
// ⤡ restore button so a phone can un-zoom without a keyboard (docs/18).
let show_restore = app.zoomed && header.width >= 8;
let path_budget = header
.width
.saturating_sub(if show_restore { 8 } else { 5 });
let title = Line::from(vec![
Span::styled("▎", Style::new().fg(t.accent).bg(hbg)),
Span::styled(
format!(" {} ", st.dot()),
Style::new().fg(st.color(t)).bg(hbg),
),
Span::styled(
short_path(&pane.cwd, path_budget),
Style::new().fg(path_fg).bg(hbg),
),
]);
f.render_widget(Paragraph::new(title), header);
if show_restore {
let r = super::lone_zoom_rect(area);
f.render_widget(
Paragraph::new(Span::styled(
" ⤡ ",
Style::new().fg(t.subtext1).bg(hbg).bold(),
)),
r,
);
}
}
// Content background = the dark pane background.
f.render_widget(Block::new().style(Style::new().bg(t.mantle)), content);
let downsample = app.downsample;
// A mouse text-selection in this pane highlights its cells.
let sel = app.selection.filter(|s| s.pane == id);
let mut scrolled = 0usize;
let cursor_pos = match pane.engine.lock() {
Ok(engine) => {
{
let buf = f.buffer_mut();
engine.for_each_cell(&mut |row, col, sym, cell| {
if row >= content.height || col >= content.width {
return;
}
let x = content.x + col;
let y = content.y + row;
let conv = |c: Color| {
if downsample {
crate::ipc::protocol::to_256(c)
} else {
c
}
};
let fg = if cell.fg == Color::Reset {
t.text
} else {
conv(cell.fg)
};
let mut style = Style::new().fg(fg);
if !cell.mods.is_empty() {
style = style.add_modifier(cell.mods);
}
if cell.bg != Color::Reset {
style = style.bg(conv(cell.bg));
}
// Highlight the cell if it's inside the mouse selection.
if sel.is_some_and(|s| s.contains(x, y)) {
style = style.bg(t.sel_bg);
}
// ratatui panics if a control char reaches the buffer; the VT
// grid can hold stray C0/C1 bytes, so render those blank. `sym`
// is the whole grapheme cluster (base + combining/VS16/ZWJ), so
// emoji and accents print whole instead of losing their modifier
// chars to a tofu box.
let sym = if sym.starts_with(|c: char| c.is_control()) {
" "
} else {
sym
};
let target = &mut buf[(x, y)];
target.set_symbol(sym);
target.set_style(style);
// A double-width glyph (emoji / CJK) spans this cell *and* the
// next. The VT engine skips the spacer, so that next cell keeps
// the reset blank — which the client would blit as a space over
// the glyph's right half (crossterm advances its cursor +1 per
// cell, but the terminal advances +2 for the glyph), corrupting
// it and shifting the row. Mark it as a wide-char continuation:
// an empty symbol prints nothing, realigning the accounting.
if x + 1 < content.x + content.width
&& unicode_width::UnicodeWidthStr::width(sym) == 2
{
let next = &mut buf[(x + 1, y)];
next.set_symbol("");
next.set_style(style); // carry bg so a highlight stays contiguous
}
});
}
scrolled = engine.scroll_offset();
let cur = engine.cursor();
if focused && cur.visible && cur.x < content.width && cur.y < content.height {
Some((content.x + cur.x, content.y + cur.y))
} else {
None
}
}
Err(_) => None,
};
// Scrollback indicator: when the viewport is above the live bottom, show how
// far up (in lines) at the content's top-right so the state is never a
// mystery. Any keystroke — or scrolling back down — returns to live.
if scrolled > 0 && content.height > 0 {
let label = format!(" ↑{scrolled} ");
let w = crate::ui::display_width(&label) as u16;
if w < content.width {
let badge = Rect::new(content.x + content.width - w, content.y, w, 1);
f.render_widget(
Paragraph::new(Line::from(Span::styled(
label,
Style::new().fg(t.crust).bg(t.accent),
))),
badge,
);
}
}
cursor_pos
}