feat(layout): fullscreen + interactive pane resizing
CI / rust client (hh) (macos-latest) (push) Waiting to run
CI / rust client (hh) (ubuntu-latest) (push) Waiting to run
CI / rust coverage (push) Waiting to run
CI / python server (3.10) (push) Waiting to run
CI / python server (3.11) (push) Waiting to run
CI / python server (3.12) (push) Waiting to run
CI / headless e2e smoke (push) Waiting to run
CI / dependency audit (push) Waiting to run
CI / secret scanning (push) Waiting to run

Add a window-management layer so the chat, roster and sandbox-terminal
panes can be resized live and fullscreened, with named presets for
recall.

- New layout.rs: single source of truth for the body split (pty_pct,
  roster_width) and a Zoom state (Normal/Term/Chat), persisted to
  layouts/<slug>.toml like themes. Both ui::draw and app::sbx_dims read
  from it, so resizing just mutates state and clears announced_dims —
  the per-tick loop re-syncs and broadcasts the new PTY grid.
- F4 cycles terminal/chat fullscreen (F-keys aren't forwarded to the
  shell, so nothing is stolen from in-shell apps).
- Interactive editing: click a pane (or F5 to cycle terminal -> chat ->
  roster) to select it; arrows then resize it live, Esc/Enter finishes.
  The selected pane gets a bold accent border and an ✎ title marker.
  ui::pane_at hit-tests against the same body_areas rects draw() paints.
- /layout slimmed to presets only (save/load/list/rm/reset); the old
  numeric pty/chat/roster and full/chatfull/normal verbs are replaced by
  the interactive flow.
- Help menu updated: LAYOUT cluster + KEYS line document F4, click/F5,
  arrows and Esc.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
leetcrypt
2026-06-07 12:53:44 -07:00
parent c9cdd2feca
commit 37d5916b2e
4 changed files with 597 additions and 29 deletions
+212 -6
View File
@@ -1,6 +1,7 @@
//! TUI application state, network event model, and the async run loop.
use crate::ft;
use crate::layout::Layout;
use crate::net::{self, Session};
use crate::sbx;
use crate::theme::Theme;
@@ -10,7 +11,7 @@ use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use crossterm::event::{
DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyCode, KeyEventKind,
KeyModifiers, MouseEventKind,
KeyModifiers, MouseButton, MouseEventKind,
};
use crossterm::execute;
use crossterm::terminal::{
@@ -159,6 +160,15 @@ pub struct VboxPicker {
pub selected: usize,
}
/// A resizable region of the window, for interactive layout editing. Selected by
/// clicking it (or cycling with F5); once selected, arrow keys resize it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Pane {
Chat,
Roster,
Terminal,
}
pub struct App {
pub me: String,
pub lines: Vec<ChatLine>,
@@ -211,6 +221,13 @@ pub struct App {
/// Tracked so the broker Ready handler can re-grant its drive and `/ai stop`
/// can revoke the right ACL entry.
pub agent_name: Option<String>,
/// Window layout: chat/terminal split, roster width, fullscreen zoom. Read
/// by `ui::draw` and `sbx_dims`; mutated by F4/F5 + interactive editing.
pub layout: Layout,
/// Interactive layout editing: the pane currently selected for resizing
/// (click it or cycle with F5), or None when not editing. When set, arrow
/// keys resize this pane instead of scrolling.
pub focused_pane: Option<Pane>,
}
impl App {
@@ -244,9 +261,25 @@ impl App {
spin: 0,
agent_sbx_allow: false,
agent_name: None,
layout: Layout::default(),
focused_pane: None,
}
}
/// Cycle the interactive-edit selection: Terminal → Chat → Roster → off.
/// Bound to F5; clicking a pane jumps straight to it instead. The Terminal
/// step is skipped when no sandbox is up (nothing to resize there).
fn cycle_focus(&mut self) {
let has_term = self.sandbox.is_some();
self.focused_pane = match self.focused_pane {
None if has_term => Some(Pane::Terminal),
None => Some(Pane::Chat),
Some(Pane::Terminal) => Some(Pane::Chat),
Some(Pane::Chat) => Some(Pane::Roster),
Some(Pane::Roster) => None,
};
}
/// Append a chat line. Holds the viewport steady if scrolled up, and caps
/// the in-memory backlog.
fn push_line(&mut self, l: ChatLine) {
@@ -463,9 +496,32 @@ impl App {
}
}
fn sbx_dims(term_w: u16, term_h: u16) -> (u16, u16) {
/// Human label for a resizable pane (used in layout-editing hints).
fn pane_label(p: Pane) -> &'static str {
match p {
Pane::Chat => "chat",
Pane::Roster => "roster",
Pane::Terminal => "terminal",
}
}
/// Join saved-preset names for display, or "none" when there are none.
fn once_or_none(names: Vec<String>) -> String {
if names.is_empty() {
"none".to_string()
} else {
names.join(" · ")
}
}
/// PTY grid (rows, cols) for the sandbox terminal. `pty_pct` is the terminal's
/// share of the body height (see `layout::Layout`); it must match the split
/// `ui::draw` paints, so they stay in lock-step via `app.layout`. Width is the
/// full body width — the terminal pane always spans the frame, only its height
/// changes with the split.
fn sbx_dims(term_w: u16, term_h: u16, pty_pct: u16) -> (u16, u16) {
let body_h = term_h.saturating_sub(4);
let sbx_h = (body_h as u32 * 55 / 100) as u16;
let sbx_h = (body_h as u32 * pty_pct as u32 / 100) as u16;
(
sbx_h.saturating_sub(2).max(1),
term_w.saturating_sub(2).max(1),
@@ -842,6 +898,10 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
let mut app = App::new(session.username.clone());
app.password = params.password.clone();
// Seed the roster width from the active vestment so a `--theme` with a wider
// roster is honoured; from here on the live layout owns it (so /theme swaps
// don't stomp a width the user has set with /layout).
app.layout.roster_width = theme.roster_width;
let mut events = EventStream::new();
let mut tick = tokio::time::interval(Duration::from_millis(50));
@@ -863,7 +923,7 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
if broker.is_some() {
if let Ok(sz) = term.size() {
let dims = sbx_dims(sz.width, sz.height);
let dims = sbx_dims(sz.width, sz.height, app.layout.effective_pty_pct());
if announced_dims != Some(dims) {
announced_dims = Some(dims);
if let Some(sb) = &broker {
@@ -1043,6 +1103,64 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
} else {
app.sys("you don't have drive permission — the owner can /grant you");
}
} else if k.code == KeyCode::F(4) {
// Fullscreen the terminal (cycle terminal → chat → split).
// Caught before the drive branch so it works mid-session;
// F-keys aren't forwarded to the shell anyway (key_to_pty).
if app.sandbox.is_some() {
app.layout.cycle_zoom();
announced_dims = None; // re-sync PTY to the new height
app.sys(format!("{}", app.layout.describe()));
} else {
app.sys("no sandbox to fullscreen — /sbx launch first");
}
} else if k.code == KeyCode::F(5) {
// Enter/cycle interactive layout editing: pick a pane to
// resize (Terminal → Chat → Roster → off). Clicking a pane
// selects it directly; arrows then resize it.
app.cycle_focus();
match app.focused_pane {
Some(p) => app.sys(format!(
"⛧ editing {} — arrows resize · Esc done",
pane_label(p)
)),
None => app.sys("⛧ layout editing off"),
}
} else if app.focused_pane.is_some() && !app.driving {
// Interactive layout editing: a pane is selected (via click
// or F5). Arrows resize it live; Esc/Enter finishes. Sits
// before the driving branch but is gated on !driving so the
// shell still gets its keys while you hold the PTY.
let pane = app.focused_pane.unwrap();
match (pane, k.code) {
(_, KeyCode::Esc) | (_, KeyCode::Enter) => {
app.focused_pane = None;
app.sys(format!("{}", app.layout.describe()));
}
// Terminal taller / chat shorter (and the mirror image
// when the chat pane is the one selected).
(Pane::Terminal, KeyCode::Up) | (Pane::Chat, KeyCode::Down) => {
app.layout.grow_pty(3);
announced_dims = None;
app.sys(format!("{}", app.layout.describe()));
}
(Pane::Terminal, KeyCode::Down) | (Pane::Chat, KeyCode::Up) => {
app.layout.shrink_pty(3);
announced_dims = None;
app.sys(format!("{}", app.layout.describe()));
}
(Pane::Roster, KeyCode::Left) => {
app.layout.roster_width =
app.layout.roster_width.saturating_sub(2);
app.sys(format!("{}", app.layout.describe()));
}
(Pane::Roster, KeyCode::Right) => {
app.layout.roster_width =
(app.layout.roster_width + 2).min(Layout::MAX_ROSTER);
app.sys(format!("{}", app.layout.describe()));
}
_ => {} // ignore other keys while editing
}
} else if app.driving {
// Esc is NOT a release key here — vim & friends need it,
// so it's forwarded to the PTY like any other key (see
@@ -1127,6 +1245,27 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
app.chat_scroll = app.chat_scroll.saturating_sub(3);
}
}
// Left-click a pane to select it for interactive resize
// (then arrows adjust it; Esc finishes). Skipped while
// driving the shell or with an overlay up, so clicks there
// don't hijack the terminal/help/picker.
MouseEventKind::Down(MouseButton::Left)
if !app.driving && !app.show_help && app.vbox_picker.is_none() =>
{
if let Ok(sz) = term.size() {
if let Some(p) =
ui::pane_at(sz.width, sz.height, &app, m.column, m.row)
{
app.focused_pane = Some(p);
app.sys(format!(
"⛧ editing {} — arrows resize · Esc done",
pane_label(p)
));
} else {
app.focused_pane = None;
}
}
}
_ => {}
}
}
@@ -1516,6 +1655,73 @@ fn handle_command(
)),
}
}
} else if let Some(rest) = line.strip_prefix("/layout") {
// Named layout presets only — live resizing is interactive now (F4
// fullscreen, F5 or click a pane then arrows). `/layout` keeps just the
// memorable verbs for saving/recalling arrangements, plus `reset`.
// Loading a preset clears announced_dims so the run loop re-syncs (and
// broadcasts) the live PTY size on the next tick.
let rest = rest.trim();
let (cmd, arg) = rest
.split_once(char::is_whitespace)
.map(|(c, a)| (c, a.trim()))
.unwrap_or((rest, ""));
let mut resized = false;
match cmd {
"" => {
app.sys(format!("⛧ layout: {}", app.layout.describe()));
app.sys(" resize live: F4 fullscreen · F5 or click a pane, then arrows · Esc done");
app.sys(format!(
" presets: {} — /layout save <name> · load <name> · rm <name>",
once_or_none(Layout::available())
));
}
"reset" | "default" => {
let roster = app.layout.roster_width;
app.layout = Layout::default();
app.layout.roster_width = roster; // keep your roster choice on reset
resized = true;
app.sys(format!("⛧ layout reset — {}", app.layout.describe()));
}
"save" if !arg.is_empty() => match app.layout.save(arg) {
Ok(slug) => app.sys(format!(
"⛧ saved layout '{slug}' — re-apply anytime with /layout load {slug}"
)),
Err(e) => app.err(format!("couldn't save layout: {e}")),
},
"load" | "apply" if !arg.is_empty() => match Layout::by_name(arg) {
Ok(l) => {
app.layout = l;
resized = true;
app.sys(format!("⛧ loaded layout '{arg}' — {}", app.layout.describe()));
}
Err(_) => app.err(format!(
"no saved layout '{arg}' — saved: {}",
once_or_none(Layout::available())
)),
},
"list" | "presets" | "ls" => {
app.sys(format!("⛧ saved layouts: {}", once_or_none(Layout::available())));
}
"rm" | "delete" | "del" if !arg.is_empty() => match Layout::remove(arg) {
Ok(()) => app.sys(format!("⛧ deleted layout '{arg}'")),
Err(e) => app.err(format!("{e}")),
},
// Bare `/layout <name>` → load a saved preset if it exists.
name => match Layout::by_name(name) {
Ok(l) => {
app.layout = l;
resized = true;
app.sys(format!("⛧ loaded layout '{name}' — {}", app.layout.describe()));
}
Err(_) => app.sys(
"usage: /layout [save <name>|load <name>|list|rm <name>|reset] — resize live with F4/F5 or click",
),
},
}
if resized {
*announced_dims = None; // force the run loop to re-sync the PTY size
}
} else if line == "/drive" {
// Mobile-friendly alternative to F2 (no function key needed).
if app.sandbox.is_none() {
@@ -1656,7 +1862,7 @@ fn handle_command(
// install also leaves its daemon up).
let install_first = !installed;
let sz = term.size().map(|s| (s.width, s.height)).unwrap_or((80, 24));
let (rows, cols) = sbx_dims(sz.0, sz.1);
let (rows, cols) = sbx_dims(sz.0, sz.1, app.layout.effective_pty_pct());
*launching = true;
let members: Vec<String> =
app.users.iter().map(|u| u.username.clone()).collect();
@@ -1762,7 +1968,7 @@ fn handle_command(
// (stopped, preserved) instance and re-attaches its shell.
let label = label.to_string();
let sz = term.size().map(|s| (s.width, s.height)).unwrap_or((80, 24));
let (rows, cols) = sbx_dims(sz.0, sz.1);
let (rows, cols) = sbx_dims(sz.0, sz.1, app.layout.effective_pty_pct());
*launching = true;
let members: Vec<String> =
app.users.iter().map(|u| u.username.clone()).collect();
+239
View File
@@ -0,0 +1,239 @@
//! Window layout ("cell plan"): how the chat, roster and sandbox-terminal panes
//! divide the screen, plus a fullscreen ("zoom") mode for the terminal or chat.
//!
//! The split is a single source of truth shared by `ui::draw` (what's painted)
//! and `app::sbx_dims` (the PTY grid we resize the real shell to). Because the
//! run loop re-syncs the PTY every tick, changing these values is all it takes
//! to live-resize the terminal and broadcast the new dims to the room.
//!
//! Named arrangements can be saved to `layouts/<slug>.toml` and re-applied later
//! with `/layout load <name>`, mirroring how `theme.rs` persists vestments.
use anyhow::Context;
use serde::{Deserialize, Serialize};
/// Where saved layout presets live (mirrors `theme::THEMES_DIR`).
pub const LAYOUTS_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/layouts");
/// Fullscreen state. `Normal` honours the `pty_pct` split; `Term` gives the
/// whole body to the sandbox terminal (chat + roster hidden); `Chat` hides the
/// terminal so chat + roster fill the body. The 3-line input bar always stays
/// visible, so you can always type `/layout normal` or press F4 to get back.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Zoom {
Normal,
Term,
Chat,
}
impl Default for Zoom {
fn default() -> Self {
Zoom::Normal
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Layout {
/// Sandbox terminal's share of the body height, as a percentage (clamped
/// 2090 so neither chat nor terminal can collapse to nothing).
pub pty_pct: u16,
/// Roster column width in cells. `0` hides the roster entirely.
pub roster_width: u16,
/// Fullscreen state (not all presets care; defaults to `Normal`).
pub zoom: Zoom,
}
impl Default for Layout {
fn default() -> Self {
Self {
pty_pct: 55,
roster_width: 22,
zoom: Zoom::Normal,
}
}
}
impl Layout {
/// Lower/upper bounds for the terminal's height share.
pub const MIN_PCT: u16 = 20;
pub const MAX_PCT: u16 = 90;
/// Upper bound on roster width (keeps it from eating the whole chat column).
pub const MAX_ROSTER: u16 = 60;
/// Re-clamp every field into its valid range. Call after any mutation that
/// came from user input so out-of-range values can never reach the layout.
pub fn clamp(&mut self) {
self.pty_pct = self.pty_pct.clamp(Self::MIN_PCT, Self::MAX_PCT);
self.roster_width = self.roster_width.min(Self::MAX_ROSTER);
}
/// The terminal's effective height share once zoom is taken into account:
/// `Term` fills the body (100%); `Normal`/`Chat` use the stored split (the
/// terminal is simply not painted under `Chat`, so its size stays steady).
pub fn effective_pty_pct(&self) -> u16 {
match self.zoom {
Zoom::Term => 100,
_ => self.pty_pct,
}
}
/// Cycle the fullscreen state: Normal → Term → Chat → Normal. Bound to F4.
pub fn cycle_zoom(&mut self) {
self.zoom = match self.zoom {
Zoom::Normal => Zoom::Term,
Zoom::Term => Zoom::Chat,
Zoom::Chat => Zoom::Normal,
};
}
/// Grow the terminal pane by `step` percent (drops out of any zoom first so
/// the change is visible). Stays within [MIN_PCT, MAX_PCT].
pub fn grow_pty(&mut self, step: u16) {
self.zoom = Zoom::Normal;
self.pty_pct = (self.pty_pct + step).min(Self::MAX_PCT);
}
/// Shrink the terminal pane by `step` percent (counterpart of `grow_pty`).
pub fn shrink_pty(&mut self, step: u16) {
self.zoom = Zoom::Normal;
self.pty_pct = self.pty_pct.saturating_sub(step).max(Self::MIN_PCT);
}
/// A one-line description of the current arrangement (for `/layout`).
pub fn describe(&self) -> String {
let zoom = match self.zoom {
Zoom::Normal => "split",
Zoom::Term => "terminal-fullscreen",
Zoom::Chat => "chat-fullscreen",
};
let roster = if self.roster_width == 0 {
"off".to_string()
} else {
self.roster_width.to_string()
};
format!(
"terminal {}% · chat {}% · roster {} · {}",
self.pty_pct,
100 - self.pty_pct,
roster,
zoom
)
}
/// Persist this arrangement to `layouts/<slug>.toml` so it can be re-applied
/// later with `/layout load <slug>`. Returns the slug actually written.
pub fn save(&self, name: &str) -> anyhow::Result<String> {
let slug = slugify(name);
anyhow::ensure!(!slug.is_empty(), "give the layout a name (letters/digits)");
std::fs::create_dir_all(LAYOUTS_DIR)
.with_context(|| format!("creating {LAYOUTS_DIR}"))?;
let path = format!("{LAYOUTS_DIR}/{slug}.toml");
let body = toml::to_string_pretty(self)
.with_context(|| format!("serialize layout '{slug}'"))?;
std::fs::write(&path, body).with_context(|| format!("write {path}"))?;
Ok(slug)
}
/// Load a saved arrangement by name (`layouts/<slug>.toml`), re-clamped.
pub fn by_name(name: &str) -> anyhow::Result<Self> {
let slug = slugify(name);
let path = format!("{LAYOUTS_DIR}/{slug}.toml");
let s = std::fs::read_to_string(&path)
.with_context(|| format!("layout '{slug}' ({path})"))?;
let mut l: Layout = toml::from_str(&s)?;
l.clamp();
Ok(l)
}
/// Names of saved presets, sorted, for `/layout list`.
pub fn available() -> Vec<String> {
let mut names: Vec<String> = std::fs::read_dir(LAYOUTS_DIR)
.into_iter()
.flatten()
.flatten()
.filter_map(|e| {
e.file_name()
.to_str()
.and_then(|f| f.strip_suffix(".toml"))
.map(str::to_string)
})
.collect();
names.sort();
names
}
/// Delete a saved preset. Errors if it doesn't exist.
pub fn remove(name: &str) -> anyhow::Result<()> {
let slug = slugify(name);
let path = format!("{LAYOUTS_DIR}/{slug}.toml");
std::fs::remove_file(&path).with_context(|| format!("no saved layout '{slug}'"))?;
Ok(())
}
}
/// Reduce a free-form name to a safe lowercase `<slug>` (ascii letters/digits,
/// any other run folded to a single '-'), matching theme.rs's convention.
fn slugify(name: &str) -> String {
let mut out = String::new();
let mut dash = false;
for c in name.trim().chars() {
if c.is_ascii_alphanumeric() {
if dash && !out.is_empty() {
out.push('-');
}
out.extend(c.to_lowercase());
dash = false;
} else {
dash = true;
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clamp_bounds_pct_and_roster() {
let mut l = Layout {
pty_pct: 999,
roster_width: 999,
zoom: Zoom::Normal,
};
l.clamp();
assert_eq!(l.pty_pct, Layout::MAX_PCT);
assert_eq!(l.roster_width, Layout::MAX_ROSTER);
let mut low = Layout {
pty_pct: 1,
roster_width: 0,
zoom: Zoom::Normal,
};
low.clamp();
assert_eq!(low.pty_pct, Layout::MIN_PCT);
assert_eq!(low.roster_width, 0); // 0 is valid (hidden)
}
#[test]
fn effective_pct_full_under_term_zoom() {
let mut l = Layout::default();
assert_eq!(l.effective_pty_pct(), 55);
l.zoom = Zoom::Term;
assert_eq!(l.effective_pty_pct(), 100);
l.zoom = Zoom::Chat;
assert_eq!(l.effective_pty_pct(), 55); // chat hidden ≠ resize the pty
}
#[test]
fn cycle_and_resize_drop_out_of_zoom() {
let mut l = Layout::default();
l.cycle_zoom();
assert_eq!(l.zoom, Zoom::Term);
l.grow_pty(5);
assert_eq!(l.zoom, Zoom::Normal);
assert_eq!(l.pty_pct, 60);
l.shrink_pty(100);
assert_eq!(l.pty_pct, Layout::MIN_PCT);
}
}
+1
View File
@@ -7,6 +7,7 @@
mod app;
mod crypto;
mod ft;
mod layout;
mod net;
mod sbx;
mod theme;
+145 -23
View File
@@ -1,9 +1,10 @@
//! ratatui rendering — top bar, chat, roster, input.
use crate::app::{App, ChatLine, Role};
use crate::layout::Zoom;
use crate::theme::Theme;
use ratatui::layout::{Constraint, Layout, Position, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Clear, List, ListItem, Paragraph, Wrap};
use ratatui::Frame;
@@ -26,20 +27,17 @@ pub fn draw(f: &mut Frame, app: &App, theme: &Theme) {
draw_top(f, rows[0], app, theme);
// When a sandbox is live, split the body: chat+roster on top, PTY below.
let (chat_area, sbx_area) = if app.sandbox.is_some() {
let split = Layout::vertical([Constraint::Percentage(45), Constraint::Percentage(55)])
.split(rows[1]);
(split[0], Some(split[1]))
} else {
(rows[1], None)
};
let body = Layout::horizontal([Constraint::Min(1), Constraint::Length(theme.roster_width)])
.split(chat_area);
draw_chat(f, body[0], app, theme);
draw_roster(f, body[1], app, theme);
if let Some(area) = sbx_area {
// Divide the body into chat / roster / sandbox-terminal rects. `body_areas`
// is the single source of truth for that geometry, shared with `pane_at`
// (mouse hit-testing) so what you click is exactly what's painted.
let areas = body_areas(rows[1], app);
if let Some(chat_area) = areas.chat {
draw_chat(f, chat_area, app, theme);
}
if let Some(roster_area) = areas.roster {
draw_roster(f, roster_area, app, theme);
}
if let Some(area) = areas.sbx {
draw_sandbox(f, area, app, theme);
}
draw_input(f, rows[2], app, theme);
@@ -55,6 +53,101 @@ pub fn draw(f: &mut Frame, app: &App, theme: &Theme) {
}
}
/// The body's pane rectangles. Any field is `None` when that pane is hidden
/// (terminal absent, fullscreen zoom, or roster width 0).
struct BodyAreas {
chat: Option<Rect>,
roster: Option<Rect>,
sbx: Option<Rect>,
}
/// Carve the body region (`rows[1]`) into chat / roster / sandbox rectangles,
/// honouring the sandbox presence, `Zoom`, and roster width. This is the single
/// source of truth used by both `draw` (painting) and `pane_at` (hit-testing).
fn body_areas(body: Rect, app: &App) -> BodyAreas {
// Vertical split: chat-column vs sandbox terminal.
let (chat_col, sbx) = if app.sandbox.is_some() {
match app.layout.zoom {
Zoom::Term => (None, Some(body)), // terminal fullscreen
Zoom::Chat => (Some(body), None), // chat fullscreen (terminal hidden)
Zoom::Normal => {
let pty = app.layout.pty_pct;
let split = Layout::vertical([
Constraint::Percentage(100 - pty),
Constraint::Percentage(pty),
])
.split(body);
(Some(split[0]), Some(split[1]))
}
}
} else {
(Some(body), None) // no sandbox → chat owns the whole body
};
// Horizontal split of the chat column into chat vs roster.
let (chat, roster) = match chat_col {
Some(col) if app.layout.roster_width != 0 => {
let lr = Layout::horizontal([
Constraint::Min(1),
Constraint::Length(app.layout.roster_width),
])
.split(col);
(Some(lr[0]), Some(lr[1]))
}
Some(col) => (Some(col), None), // roster hidden
None => (None, None),
};
BodyAreas { chat, roster, sbx }
}
/// Hit-test a screen cell against the laid-out panes, for click-to-select in
/// interactive layout editing. Recomputes the exact rects `draw` used (via the
/// shared `body_areas`) so a click lands on the pane the user actually sees.
pub fn pane_at(w: u16, h: u16, app: &App, col: u16, row: u16) -> Option<crate::app::Pane> {
use crate::app::Pane;
let area = Rect {
x: 0,
y: 0,
width: w,
height: h,
};
// Mirror draw()'s top-bar / body / input split; only the body is selectable.
let rows = Layout::vertical([
Constraint::Length(1),
Constraint::Min(1),
Constraint::Length(3),
])
.split(area);
let areas = body_areas(rows[1], app);
let p = Position { x: col, y: row };
if areas.sbx.is_some_and(|r| r.contains(p)) {
Some(Pane::Terminal)
} else if areas.roster.is_some_and(|r| r.contains(p)) {
Some(Pane::Roster)
} else if areas.chat.is_some_and(|r| r.contains(p)) {
Some(Pane::Chat)
} else {
None
}
}
/// Border decoration for a pane: when it's the one selected for interactive
/// resize (F5 / click), give it a bold accent border and an "✎" title marker so
/// it's obvious which pane the arrows will move; otherwise the plain `base`.
fn edit_decor(app: &App, pane: crate::app::Pane, theme: &Theme, base: Color) -> (Style, &'static str) {
if app.focused_pane == Some(pane) {
(
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
"",
)
} else {
(Style::default().fg(base), "")
}
}
/// Transient error popup, anchored top-right over the clergy so it never bleeds
/// onto the input box. Cleared by the next keypress (see the run loop).
fn draw_error(f: &mut Frame, area: Rect, theme: &Theme, msg: &str) {
@@ -267,11 +360,34 @@ fn help_clusters(theme: &Theme) -> Vec<HelpCluster> {
),
],
},
HelpCluster {
title: "LAYOUT (resize panes)",
items: vec![
kv("F4", "fullscreen the terminal (cycle: terminal → chat → split)"),
kv(
"click a pane · F5",
"select a pane to resize (✎ marks it) — F5 cycles terminal → chat → roster",
),
kv(
"↑ / ↓ (terminal/chat selected)",
"grow / shrink that pane's height share",
),
kv("← / → (roster selected)", "narrow / widen the roster column"),
kv("Esc / Enter", "finish editing the selected pane"),
kv("/layout reset", "restore the default split"),
kv(
"/layout save <name> · load <name>",
"remember an arrangement and re-apply it later",
),
kv("/layout list · rm <name>", "list or delete saved layouts"),
],
},
HelpCluster {
title: "KEYS",
items: vec![
kv("Enter", "send chat message"),
kv("F1 · /help", "toggle this help"),
kv("F4 · F5 · click", "layout: fullscreen terminal · select pane to resize (see LAYOUT)"),
kv("Ctrl-C (while driving)", "interrupt the running command"),
kv(
"Ctrl-X (owner, not driving)",
@@ -472,15 +588,16 @@ fn draw_sandbox(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &T
} else {
" · /drive (or F2) · ↑/↓/wheel scroll".to_string()
};
let title = format!(" sandbox · {}{} ", sv.backend, drive);
let border = if app.driving {
let base = if app.driving {
theme.accent
} else {
theme.border
};
let (border_style, mark) = edit_decor(app, crate::app::Pane::Terminal, theme, base);
let title = format!(" {mark}sandbox · {}{} ", sv.backend, drive);
let pane = Paragraph::new(lines).block(
Block::bordered()
.border_style(Style::default().fg(border))
.border_style(border_style)
.title(Span::styled(title, Style::default().fg(theme.title))),
);
f.render_widget(pane, area);
@@ -586,15 +703,16 @@ fn draw_chat(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Them
let max_scroll = total_rows.saturating_sub(inner_h);
let scroll = max_scroll.saturating_sub(app.chat_scroll) as u16;
let (border_style, mark) = edit_decor(app, crate::app::Pane::Chat, theme, theme.border);
let title = if app.chat_scroll > 0 {
format!(" chat ↑{} (End=live) ", app.chat_scroll)
format!(" {mark}chat ↑{} (End=live) ", app.chat_scroll)
} else {
" chat ".to_string()
format!(" {mark}chat ")
};
let chat = Paragraph::new(lines)
.block(
Block::bordered()
.border_style(Style::default().fg(theme.border))
.border_style(border_style)
.title(Span::styled(title, Style::default().fg(theme.title))),
)
.wrap(Wrap { trim: false })
@@ -639,10 +757,14 @@ fn draw_roster(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Th
)))
})
.collect();
let (border_style, mark) = edit_decor(app, crate::app::Pane::Roster, theme, theme.border);
let roster = List::new(items).block(
Block::bordered()
.border_style(Style::default().fg(theme.border))
.title(Span::styled(" clergy ", Style::default().fg(theme.title))),
.border_style(border_style)
.title(Span::styled(
format!(" {mark}clergy "),
Style::default().fg(theme.title),
)),
);
f.render_widget(roster, area);
}