From 37d5916b2ecc3088e1a58ff43b038455510eb1cc Mon Sep 17 00:00:00 2001 From: leetcrypt Date: Sun, 7 Jun 2026 12:53:44 -0700 Subject: [PATCH] feat(layout): fullscreen + interactive pane resizing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/.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 --- hh/src/app.rs | 218 ++++++++++++++++++++++++++++++++++++++++-- hh/src/layout.rs | 239 +++++++++++++++++++++++++++++++++++++++++++++++ hh/src/main.rs | 1 + hh/src/ui.rs | 168 ++++++++++++++++++++++++++++----- 4 files changed, 597 insertions(+), 29 deletions(-) create mode 100644 hh/src/layout.rs diff --git a/hh/src/app.rs b/hh/src/app.rs index 5f37be1..20afba4 100644 --- a/hh/src/app.rs +++ b/hh/src/app.rs @@ -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, @@ -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, + /// 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, } 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 { + 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 · load · rm ", + 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 ` → 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 |load |list|rm |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 = 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 = app.users.iter().map(|u| u.username.clone()).collect(); diff --git a/hh/src/layout.rs b/hh/src/layout.rs new file mode 100644 index 0000000..fa0c228 --- /dev/null +++ b/hh/src/layout.rs @@ -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/.toml` and re-applied later +//! with `/layout load `, 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 + /// 20–90 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/.toml` so it can be re-applied + /// later with `/layout load `. Returns the slug actually written. + pub fn save(&self, name: &str) -> anyhow::Result { + 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/.toml`), re-clamped. + pub fn by_name(name: &str) -> anyhow::Result { + 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 { + let mut names: Vec = 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 `` (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); + } +} diff --git a/hh/src/main.rs b/hh/src/main.rs index 89953d5..3be6f56 100644 --- a/hh/src/main.rs +++ b/hh/src/main.rs @@ -7,6 +7,7 @@ mod app; mod crypto; mod ft; +mod layout; mod net; mod sbx; mod theme; diff --git a/hh/src/ui.rs b/hh/src/ui.rs index 4842533..3a0c2b3 100644 --- a/hh/src/ui.rs +++ b/hh/src/ui.rs @@ -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, + roster: Option, + sbx: Option, +} + +/// 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 { + 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 { + 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 · load ", + "remember an arrangement and re-apply it later", + ), + kv("/layout list · rm ", "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); }